Skip to content

FSM extractor: implicit self-loops for canonical kernel pattern#25

Closed
twitzelbos wants to merge 27 commits intosamitbasu:mainfrom
twitzelbos:feat/fsm-extractor-implicit-self-loops
Closed

FSM extractor: implicit self-loops for canonical kernel pattern#25
twitzelbos wants to merge 27 commits intosamitbasu:mainfrom
twitzelbos:feat/fsm-extractor-implicit-self-loops

Conversation

@twitzelbos
Copy link
Copy Markdown
Contributor

Summary

  • When both walkers run cleanly but find no state-overriding op for a match arm, interpret it as a self-loop on the source variant rather than Unanalyzable. This is the correct semantic given CLAUDE.md §3's canonical pattern: kernels write d.<state_field> = q.<state_field> once at the kernel top and only override in arms that transition.
  • The convention applies at the d-struct walker's union points (Select branches, Case arms) plus the top-level fallback. Leaf paths keep Ok(vec![]) so the value-form walker still wins on state-typed slots — the let-binding form is unaffected.
  • Unanalyzable is now reserved exclusively for genuinely malformed IR (Enum opcode whose discriminant matches no variant, etc.), pinned by an inverted negative test.

Justification (per CLAUDE.md §11.1)

1. What guarantee does this preserve, strengthen, or introduce?
Strengthens Layer 2 acceptance criterion #2 (fsm-architecture.md §5.4): "zero false positives on the existing widget corpus." PR #6 self-tested only against synthetic widgets in doc.rs — none used the canonical kernel-top default + selective override pattern. First validation against the real core::can_master showed it produced 4 spurious Unanalyzable diagnostics on guarded-transition arms whose else-branches only update auxiliary state (a bit counter). Every production protocol-PHY kernel (CAN, I²C, SPI, UART RX, DHT22, etc.) shares this shape; this PR makes the auto-extractor usable on real widgets.

2. What loophole does this not introduce?
A naive "no state-write found = self-loop" rule applied at every leaf would pollute the value-form walker (which is also called on Enum slots), interpreting a Tier-3 widget that mistakenly used Add on a state slot as "self-loop on source" instead of surfacing the type-system violation. The implementation restricts the convention to union points (where the d-struct context is unambiguous) plus the top-level fallback. The leaf paths (find_definer-None, unrecognised opcode, Struct-without-state-field) still return empty so the value walker's interpretation can win at the top. The negative test arm_with_unmatched_enum_discriminant_yields_unanalyzable pins this — a genuinely malformed enum still produces Unanalyzable with a discriminant-mentioning diagnostic.

3. What downstream code does this affect, and why is the effect intentional?
No widget HDL snapshots changed (verified by cargo test --package rhdl-fpga --lib — 442 tests passing, 0 snapshot diffs). The change is purely additive in the FSM extractor: no IR opcode added, no lowering modified, no Verilog emitted differently. Three pre-existing fsm::extraction::tests were reframed (rename + assertion update) because they were testing the old incorrect behaviour — keeping them as-is would have masked the can_master regression. The reframed tests now assert the correct (new) semantics with comments explaining the semantic shift.

4. What is the alternative design considered and rejected?

  • Rewrite every widget to make self-loops explicit (d.field = q.field inside each else-branch). Mechanically tractable across 55 widgets but adds boilerplate that the kernel-top default pattern was specifically designed to avoid — and contradicts the canonical idiom CLAUDE.md §3 documents.
  • Declare can_master "not an FSM" and remove #[derive(FsmWidget)]. Contradicts the entire FSM track and §5.4 acceptance criteria.
  • Verify the kernel-top default exists before applying the implicit-self-loop interpretation. Cleaner guarantee but adds a Layer 2 prerequisite check that's not strictly necessary — the canonical pattern is universal so far. Tracked as an optional follow-up.

5. Is this change reversible?
Yes — purely a semantic interpretation in the extractor. No IR changes, no on-disk artefacts, no public API additions. Reverting the extractor change would re-introduce the can_master Unanalyzable diagnostics but break nothing else.

Test coverage

Pass-level (crates/rhdl-core/src/fsm/extraction.rs):

  • 4 new synthetic-RHIF unit tests:
    • kernel_top_default_plus_guarded_transition_yields_both_edges — explicit then-branch + implicit else-branch produce both edges.
    • guarded_transition_with_implicit_else_yields_self_loop — the can_master Id arm verbatim: else-branch writes a different field (other_field), not state.
    • arm_with_no_state_write_at_all_yields_self_loop — bare arm with no state Splice anywhere in the chain.
    • arm_with_unmatched_enum_discriminant_yields_unanalyzable — negative test: genuinely malformed IR still produces Unanalyzable.
  • 3 pre-existing tests reframed for the new semantics:
    • arm_with_unanalyzable_target_is_flaggedarm_with_no_recognisable_target_yields_implicit_self_loop.
    • opaque_arm_result_yields_unanalyzable_diagnosticopaque_arm_result_yields_implicit_self_loop.
    • struct_opcode_without_state_field_is_unanalyzablestruct_opcode_without_state_field_yields_implicit_self_loop.

Kernel-level integration (crates/rhdl-fpga/src/doc.rs):

  • 2 new adversarial widgets compiling through the full pipeline (Stage 1 → RHIF → extractor):
    • adv_can_master_guarded_else_writes_other_field — 3-state FSM with kernel-top default, guarded transitions, else-branches updating only d.bit_idx. Faithful synthetic stand-in for the can_master Id/Eof arms.
    • adv_nested_conditional_implicit_self_loops — nested if-else inside one arm where two paths independently omit d.state; verifies dedup at union points.

Validation

  • 65 fsm:: tests pass (was 61, +4 new + 3 reframed).
  • 442 rhdl-fpga lib tests pass — no widget HDL snapshot regression.
  • 120 rhdl-core lib tests pass.
  • CHANGELOG entry added per §16.
  • fsm-architecture.md §5.6 status note added.

Test plan

  • cargo test --package rhdl-core fsm:: — 65 passing
  • cargo test --package rhdl-fpga --lib — 442 passing, 0 snapshot diffs
  • cargo test --package rhdl-core --lib — 120 passing
  • CHANGELOG + fsm-architecture.md §5.6 updated
  • Reviewer verifies the Justification section's "what loophole does this not introduce" reasoning by reading extraction.rs variants_in_d_state_field and confirming the Select/Case empty-branch self-loop conversion does NOT propagate through Enum/Add/Struct-without-field leaves
  • Reviewer confirms arm_with_unmatched_enum_discriminant_yields_unanalyzable still trips on genuinely-malformed IR (try changing the discriminant value to a valid one and confirm the test fails as expected)

🤖 Generated with Claude Code

twitzelbos and others added 27 commits April 29, 2026 02:30
- rhdl-deep-dive.md: end-to-end walk of an RHDL widget through the IR
  stack (RHIF/RTL/NTL/Verilog), public API catalog, AI-assist
  intervention map, and comparison vs Chisel/Spade/SpinalHDL.
- manifesto.md: essay on why a Rust-based HDL is the right substrate
  for LLM-assisted hardware design, focused on compile-time
  correctness and validation speed.
Four root-level design docs that situate widget work alongside
compiler-level and language-level tracks:

- architecture.md: structural blueprint for the workspace and
  cross-crate boundaries
- auto-pipelining-plan.md: design plan for compiler-driven
  pipeline-register insertion to meet target frequencies
- kernel-language-extensions.md: spec for expanding the Rust
  subset accepted inside #[kernel]
- vendor-primitive-architecture.md: target-provider trait system
  for emitting vendor primitives (Xilinx DSP, Lattice EBR, etc.)
  with portable Verilog fallback

Each track is independently shippable; widgets can record cross-track
dependencies in their CHANGELOG entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The widget-roadmap.md "recommended first eight" plus seven follow-on
widgets in Tiers 0-2.  Lib test count: 149 -> 275 (0 regressions, 1
ignored which was pre-existing).

CLAUDE.md changes:
- TL;DR now lists FIVE non-negotiables (added CHANGELOG entry
  requirement)
- §4 documents two subtle kernel semantics agents were tripping on:
  (a) if/else lowers to a combinational mux — both branches always
  evaluate; clamp operands, don't just guard results.
  (b) direct Rust calls to a kernel are more permissive than the VM;
  add test_kernel_vm_and_verilog_synchronous to catch shift-bound
  bugs.
- §15 status reporting requires CHANGELOG before "Done"
- §16 (new section) makes CHANGELOG.md mandatory with the entry
  template, the "new agent" test for entry quality, and enforcement.

CHANGELOG.md is the build narrative — *why* widgets look the way
they do, what was tried and discarded, what gotchas we hit.  Per-
widget entries for all 15 widgets in this batch.

widget-roadmap.md updates: 15 widgets struck through with paths to
shipped source, plus a "Follow-ups" section recording the workarounds
that should be tightened (async testbench cycle alignment, vlog
pretty-printer src_send issue, non-zero DFF reset vs iverilog
initial, HDL-length-proxy snapshots, pre-existing core/clippy
issues).

Tier 0 (foundation primitives, 6 widgets):
- core::edge_detector
- core::pulse_stretcher
- cdc::synchronizer_chain (BitSyncChain<W,R,N>)
- cdc::slow_crosser (multi-bit CDC, 4-phase req/ack)
- core::priority_encoder (lsb + msb)
- core::one_hot (binary_to_one_hot + one_hot_to_binary)

Tier 1 (combinational utilities, 3 widgets):
- core::barrel_shifter (5 modes via ShiftOp enum)
- core::popcount
- core::leading_zeros

Tier 2 (sequential building blocks, 6 widgets):
- core::debouncer (composes edge detector + pulse stretcher + DFF)
- core::round_robin_arbiter
- core::strict_priority_arbiter
- core::divider (unsigned shift-subtract)
- core::mac (single-cycle multiply-accumulate)
- core::crc (bit-serial CRC engine)

Each widget ships with: the canonical CLAUDE.md anatomy (rustdoc
schematic + internals + reset semantics), a runnable
examples/<name>.rs, the committed waveform doc/<name>.md, all
applicable test tiers, and (for those that hit them) reference VCD
digests + iverilog round-trip coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the remaining Tier 1/2 gaps and ships the first eight
Tier-3 protocol PHYs.  Lib test count: 275 -> 346 (0 regressions).

Tier 1/2 closeouts:
- core::pwm: saw-tooth + comparator (Roadmap #22, treated as
  Tier-3 entry but trivially small)
- core::comparator: unsigned wide comparator emitting all 5 flags
  in one call (Roadmap #10)
- core::register_file: bus-agnostic N-register array, combinational
  read + registered write, foundation for all peripheral adapters
  (Roadmap #17)

Tier 3 batch:
- core::uart_tx: 8-N-1 transmitter with runtime divisor (#18 TX half)
- core::uart_rx: 8-N-1 receiver with mid-baud sampling (#18 RX half)
- core::spi_master: Mode 0, MSB-first, 4-wire (#19)
- core::spi_slave: Mode 0, MSB-first, oversampled sclk_in (#20)
- core::i2c_master: write-only single-byte v1 with open-drain
  outputs - first widget that exercises tristate end-to-end (#21)
- core::ws2812: single-pixel WS2812/SK6812 driver (#26)
- core::dht22: 40-bit humidity/temperature single-wire sensor (#29)

CHANGELOG updates document per-widget design decisions, surprises
hit (or-patterns are forbidden in kernels; type inference for
.as_bits() returns the outer kernel const generic; FSMs that
"wait for line release" need an explicit two-step high-then-low
wait), and per-widget follow-ups.

CLAUDE.md and widget-roadmap.md updated; recommended-first-eight
list and the strikethrough roadmap are both kept current.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more Tier-3 widgets, both pure compositions:

- core::uart: full-duplex with TX FIFO + RX FIFO, closing the
  deferred sub-piece of #18.  Pure dataflow wiring of the existing
  uart_tx + uart_rx + two sync_fifo subcores.
- core::lin_master: v1 single-byte LIN frame with break, sync,
  PID parity, classic checksum.  Composes uart_tx.  Multi-byte
  data and enhanced checksum deferred.

Lib test count: 346 -> 354 (0 regressions).

Surprise (recorded in CHANGELOG): kernel turbofish is restricted
to {resize, xext, xshl, xshr}; .as_bits::<N>() is rejected.  The
LIN PID-build loop documents the workaround pattern for
Bits<small> -> Bits<larger> widening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more Tier-3 widgets:

- core::midi: MIDI wire layer (#37 v1).  Composes core::uart;
  tags incoming bytes as status (MSB=1) or data, latches the
  most recent status byte for downstream running-status-aware
  parsing.  Full message-level FSM (Note On / SysEx / etc.)
  is the natural follow-on once kernel pattern-syntax extensions
  ship - it consumes the byte stream this widget exposes.

- core::video_timing: H/V counter + sync generator covering
  MDA (#32), CGA (#33), and VGA (#34) by the same parameterized
  widget with per-mode timings supplied at construction.
  Reference timings for MDA/VGA-640x480/VGA-800x600 documented
  in the rustdoc.  Framebuffer + DAC drive deferred per-target
  (mode-specific).

Lib test count: 354 -> 362 (0 regressions).

Surprise (recorded in CHANGELOG): Constant<T> doesn't impl
Default, so #[derive(Default)] on a struct containing Constant
subcores fails.  Use an explicit new() constructor.  Recorded
as a follow-up to core::constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core::half_spi_master: write N bits then read M bits in one
  CS-asserted transaction with configurable turnaround.  First
  widget that genuinely exercises the tristate design end-to-end
  via (sdio_oe, sdio_out) pair.  Same widget covers both 3-wire
  and 4-wire layouts.
- core::audio_pwm: stereo PWM audio output (naive PWM v1).  Two
  PwmGenerator channels share a sample-rate divider and per-channel
  sample registers.  Sigma-delta noise-shaping deferred.

Lib test count: 362 -> 374 (0 regressions).

Surprise: cycle-stimulus off-by-one in the half_spi round-trip
test — recorded in CHANGELOG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds crates/rhdl-fpga/src/core/can_master.rs — a transmit-only CAN
master that drives a digital TX pin into an external transceiver
(TJA1050 / MCP2551 / SN65HVD230).  Frame walk: SOF → ID(11) → RTR/IDE/
r0 → DLC(4) → Data(8*DLC) → CRC(15) → CrcDelim → AckSlot → AckDelim →
EOF(7) → IFS(3).  Full 5-same-bit stuffing in the SOF-through-CRC
region, CRC-15 polynomial 0x4599.  ACK slot driven recessive (no ACK
detection in v1).  Receiver, acceptance filter, error counters,
bus-off, SJA1000 register interface, programmable bit timing, and
CAN-FD all deferred to v2 — tracked in CHANGELOG.

Validation: all 5 tiers, 6 tests, iverilog RTL clean, full lib suite
(380 tests) green.

Two reusable kernel-authoring lessons added to widget-roadmap.md
follow-ups: (a) `as_bits()` defaults to the outer kernel's const
generic — restructure to use source-width position arithmetic plus
the generic Shr<Bits<M>> impl; (b) `Synchronous` derive has a
12-tuple ceiling — bundle related single-bit DFFs into a substruct
when you'd otherwise hit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complementary single-wire protocol PHYs.  Both are time-domain
parameterized via Constant<TimingsStruct>, both fit comfortably under
the 12-tuple Synchronous derive ceiling.

1-Wire master (core::one_wire_master) — DS18B20 / DS2401 / iButton
era.  Three operations: Reset (with presence-pulse latch), WriteByte
(8 bits LSB-first), ReadByte (8 bits LSB-first).  8-state FSM,
open-drain (bus_oe, bus_out) pair for host wrap with tristate::simple.
CRC-8, ROM-search, overdrive autoswitch, parasitic-power, and slave
widget all v2 follow-ups.  9 tests including a value-pinned read-byte
case.

NEC IR receiver (core::ir_nec_rx) — the most-used consumer infrared
format.  Reads pre-demodulated input from a TSOP4838 / VS1838B 38 kHz
receiver module.  6-state edge-driven FSM with per-state tick counter;
NecTimings struct holds 6 thresholds (burst min/max, leading-data
threshold, leading-space max, zero-one threshold, data-space max).
Repeat-code detection via leading-space length classification.  RC5,
RC6, NEC transmitter all v2 follow-ups.  7 tests including a bit-exact
0x12345678 round-trip.

Both pass all 5 validation tiers.  Full lib suite still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SENT receiver (core::sent_rx) — SAE J2716 framing helper.  Detects
sync pulses, emits per-nibble period measurements; the host computes
nibble values from (period / tick_period) - 12.  In-kernel division
deferred — would either need a 28-deep iterative-subtract cascade or
a 16-element threshold cascade, neither needed for the framing-helper
use case.  CRC-4, auto-calibration, pause-pulse detection all v2
follow-ups.  6 tests including a bit-exact period match for nibbles
[0, 5, 10, 15, 3, 8, 12, 7].  Off-by-one on the period measurement
caught early; lesson recorded in the CHANGELOG entry.

CGA digital RGBI (core::cga_rgbi) — IBM CGA video output, test-
pattern variant.  Wraps the shipped VideoTimingCore with canonical
320x200 @ 60 Hz timings (cga_320x200_60hz() constructor).  Pure
combinational kernel maps (pixel_x, active) to a 4-bit RGBI test
pattern (4-pixel bars cycling all 16 colors).  Framebuffer, character
ROM, attribute decode, composite-NTSC artifact path all v2 follow-ups
that compose on top of this widget.  6 tests including a
covers-all-16-colors sweep.

Both pass all 5 validation tiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit closes out Tier 3 of the widget roadmap.

Bus-attached UART (core::bus_uart) — minimal viable subset of the
Intel 16550A register interface.  Wraps the shipped core::uart
(#36) with a 4-register memory-mapped layout: DATA at 0x0 (R/W,
push to TX FIFO / pop from RX FIFO), STATUS at 0x1 (R, tx_full /
rx_empty / rx_valid flags).  Single combined irq, 8N1 only, baud
fixed at construction.  NOT bit-compatible with Linux 8250_core
or QEMU — full register-bit compatibility (DLL/DLM, IIR with
priority encoding, MSR/MCR/FCR, programmable LCR, hardware
handshake) tracked as v2.  7 tests including a bit-exact 0xA5
round-trip from RX wire to DATA register.

NTSC composite encoder (core::ntsc_composite) — monochrome v1.
Wraps VideoTimingCore with NTSC-progressive timings (ntsc_240p()
constructor: 858 cycles × 262 lines).  Emits a 2-bit composite
output (00 = sync tip, 01 = blank/black, 10/11 = picture luma)
suitable for a 2-bit R-2R DAC.  Pic_sample = 00 gated to 01
(black-pedestal gating) during active.  Color subcarrier,
standards-compliant VSYNC, 480i interlace, PAL variant all v2
follow-ups.  7 tests.

Tier 3 widget summary: 24 widgets shipped across 8 commit batches.
All widgets pass the 5-tier validation contract (kernel unit,
iterator simulation, HDL emission, iverilog RTL round-trip, VCD
digest).  Widget-roadmap.md and CHANGELOG.md updated; v2 follow-ups
recorded for every shipped widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Widget marathon: Tier 1/2 closeouts + 24-widget Tier 3 build-out
Lands the four-layer FSM architecture from fsm-architecture.md as a
single PR.  Strictly additive: no existing widgets are touched, no
existing API changes, no widget HDL snapshots perturbed.

Layer 1 — `#[derive(Fsm)]`
  - rhdl-core: new `fsm` module with the FsmState trait,
    FsmVariantDescriptor, and FsmWidget marker.
  - rhdl-macro-core: derive_fsm emits an FsmState impl on any
    Digital-derived enum.
  - Helper attribute family: `#[fsm(initial = "...")]` to override
    the initial variant; `#[fsm_state(label = "...", terminal)]`
    for per-variant decoration.

Layer 1b — `#[derive(FsmWidget)]`
  - rhdl-macro-core: derive_fsm_widget emits an FsmWidget impl on
    a Synchronous-derived widget struct, given
    `#[fsm(state_field = "...", state_enum = ...)]`.  Optional
    `strict` flag escalates analysis warnings to errors.
  - Compile-time validation: state_field must reference a real
    field; state_enum can be supplied as path or string literal.

Layer 2 — Static analysis
  - fsm/analysis.rs: pure-function leaf that consumes a transition
    list + descriptor and emits diagnostics for unreachable
    states, deadlock candidates, self-loop saturation, and
    (reserved for the kernel-language match-guards extension)
    non-deterministic transitions.
  - fsm/extraction.rs: walks RHIF opcodes and recognises the
    canonical match-on-state idiom; produces transitions plus a
    list of arms that couldn't be analysed.  Conservative — skips
    deadlock check on unanalyzable sources.

Layer 3 — State diagrams
  - fsm/diagram.rs: layered BFS layout from the initial variant.
    Three rendering targets:
      * inline SVG for rustdoc (no Graphviz dependency at build),
      * Graphviz dot for piping through external tools,
      * structured JSON for LLM-driven workflows.
  - Initial variant gets a tinted fill; terminal variants get a
    bold green border; self-loops render as small arcs.

Layer 4 — SVA properties
  - fsm/property.rs: FsmProperty types + render_property_sva that
    emits SystemVerilog Assertions wrapped in pragma markers.
  - rhdl-macro-core: `#[fsm_properties(...)]` attribute macro
    accepts invariant / cover / liveness / assume declarations.
    Each entry takes a string-literal expression plus optional
    `name = "..."` and `bound = N` named arguments.  Unifies all
    four property kinds in a single attribute for compositional
    syntax.
  - The cargo subcommand that drives SymbiYosys end-to-end is
    deferred to a follow-up; the metadata surface ships now so
    tooling can be built against it.

Documentation
  - Four mdbook chapters under doc/book/src/fsm/:
    summary, derive, static_analysis, diagrams, formal_verification.
  - Wired into doc/book/src/SUMMARY.md between Synchronous
    Circuits and Simulations.

Tests
  - 23 unit tests in rhdl-core (fsm::analysis, fsm::diagram,
    fsm::extraction, fsm::property).
  - 22 macro-snapshot tests in rhdl-macro-core (derive_fsm,
    derive_fsm_widget, fsm_properties), all blessed.
  - 17 end-to-end integration tests in crates/rhdl/tests/fsm.rs
    covering the full path from #[derive(Fsm)] through
    descriptor lookup, analysis, diagram rendering, and SVA
    emission.
  - All 62 FSM tests pass.  Existing widget HDL snapshots
    untouched.

Note: the rhdl prelude gains FSM exports
(FsmState, FsmWidget, FsmDescriptor, FsmKernelProperties,
FsmProperty, FsmPropertyKind, FsmDiagnostic, plus the Fsm,
FsmWidget, and fsm_properties macro re-exports).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands `kernel-language-extensions.md` §2.2: or-patterns at the
top of a match arm desugar into one arm per alternative.

```rust
match state {
    Light::Red | Light::Yellow => stop(),
    Light::Green => go(),
    Light::Off => unknown(),
}
```

was previously rejected as "Unsupported pattern type"; the macro
now flattens the `|` alternatives into separate arms with the
same body before the rest of the lowering pipeline runs.  The
emitted Verilog is byte-identical to the hand-written multi-arm
form because RHIF `Case`'s `table: Vec<(CaseArgument, Slot)>`
already permits multiple entries pointing at the same Slot.

Three of the macro-layer pattern helpers (`pattern_has_bindings`,
`rewrite_pattern_to_use_dont_care_for_bindings`,
`add_scoped_binding`) already handled `Pat::Or` recursively from
prior groundwork, so this change is purely the dispatcher
flat-map plus a clear diagnostic for nested or-patterns.

Scope: top-level or-patterns only.  Nested or-patterns inside
tuple, struct, or slice patterns (e.g. `(A | B, C)`) are caught
by a new `pattern_has_nested_or` helper and rejected with a
specific diagnostic that points the user at the manual
distribution rewrite (`(A, C) | (B, C)`).  This matches the
restriction Spade and Bluespec ship with.

No IR changes.  No existing widget snapshot is perturbed (no
shipped widget uses or-patterns).  No new failure modes for
patterns that previously compiled — the change is strictly
additive at the macro layer.

Tests
- Macro snapshot test for `Bar::A | Bar::B => {}` expansion.
- Negative test for nested `(Bar::A | Bar::B, Bar::C) => {}`
  asserting the new diagnostic.
- Integration test (`crates/rhdl/tests/match_or.rs`) with three
  scenarios: enum or-patterns (4 variants → 3 groups), an enum
  with three alternatives in one group, and literal-value
  or-patterns on a `b8` scrutinee.  Each runs through both
  direct kernel call and the iverilog round-trip via
  `test_kernel_vm_and_verilog` to verify the emitted Verilog
  matches the Rust simulator cycle-for-cycle.

Documentation
- New "Or-patterns" subsection in `doc/book/src/kernels/match.md`
  with a worked example and the explicit nested-or restriction.

Validation
- Layer 1 (macro snapshot): blessed and committed.
- Layer 2 (kernel-level integration): 5 tests pass, all enum
  variants exercised through both VM and iverilog.
- Layer 3 (widget-snapshot regression): full
  `cargo test --workspace --exclude rhdl-surfer-plugin --lib`
  shows no new failures (the 3 pre-existing `code::count_ones::*`
  timing failures on the parent branch are unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add top-level or-patterns to #[kernel] match arms
Add FSM macro family + analysis + diagrams + SVA-property surface
Closes the FSM-architecture loop between Phase 2 (RHIF transition
extractor, shipped in PR #2) and Phase 3a (diagram renderer, also
shipped in PR #2).  The connector lives in two new helpers:

- rhdl_core::fsm::extract_widget_transitions::<W>() — compiles
  W::Kernel through Stage 1 and runs extract_canonical_transitions
  against the resulting RHIF.  Returns the full ExtractionResult
  (transitions + Unanalyzable diagnostics).
- rhdl_core::fsm::extract_widget_transitions_strict::<W>() — same
  but errors on any Unanalyzable diagnostic; returns sorted
  transitions directly.

Plus two rhdl-fpga doc helpers building on those:

- rhdl_fpga::doc::write_fsm_diagram::<W>(filename) — auto-derives
  transitions, builds the diagram, writes the markdown file.  No
  author-curated FSM_TRANSITIONS const required.
- rhdl_fpga::doc::assert_fsm_transitions_match::<W>(manual) —
  drift-check helper; useful during the deprecation window for
  any widgets still carrying author-curated transition lists.

End-to-end test in rhdl-fpga::doc::tests with a real synchronous
FSM-tagged CycleMachine widget exercises the full pipeline:
extraction recovers Idle→Run→Done→Idle, write_fsm_diagram emits
SVG with all variant names, drift-check passes for correct
manual lists and fails loudly otherwise.

Updates fsm-architecture.md §6.5 + §11 phasing summary to record
this as Phase 3b shipped.  The §11 follow-on Phase 3c (auto-
inject diagram into Descriptor::hdl() rustdoc, removing the
per-widget include_str! line) builds on these helpers.

Closes the gap between fsm-architecture.md Phase 3 acceptance
criteria #1+#2 and the actual shipped state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FSM Phase 3b: kernel→diagram connector (no manual transitions)
…tdoc

Closes the last open piece of the FSM Phase 3 acceptance criteria
from fsm-architecture.md §6.4: "no extra annotations needed by the
widget author" for the rustdoc state diagram.

The new attribute macro #[fsm_doc] (rhdl_macro_core::fsm_doc,
re-exported as rhdl::prelude::fsm_doc) replaces the per-widget
3-line module-level boilerplate

  #![doc = include_str!("../../doc/<name>_fsm.md")]

with a single 1-line attribute on the widget struct:

  #[fsm_doc]
  #[derive(..., FsmWidget)]
  #[fsm(state_field = ..., state_enum = ...)]
  pub struct MyWidget { ... }

The macro expands to a #[doc = include_str!(concat!(env!(...)))]
attribute on the struct, computing the file path from the struct
name (defaulting to <WidgetName>_fsm.md).  The on-disk file is
materialised by calling rhdl_fpga::doc::write_fsm_diagram::<W>
from an example, the same workflow Phase 3b established.

A drift-check helper rhdl_fpga::doc::assert_fsm_diagram_up_to_date
catches forgotten cargo-run-example refreshes at cargo test time.

End-to-end demonstration: rhdl_fpga::doc::demo::AutoDocMachine.
cargo doc emits a struct page with the FSM SVG diagram embedded
via the include_str line the macro generated, no author input
beyond the #[fsm_doc] attribute itself.  Verified by inspecting
target/doc/rhdl_fpga/doc/demo/struct.AutoDocMachine.html.

Tests:
- 4 macro-expansion unit tests in rhdl_macro_core::fsm_doc::tests
- 4 new doc::tests in rhdl_fpga (drift-check pass/fail/missing,
  fsm_doc target-file freshness)
- 432 rhdl-fpga lib tests pass overall (up from 428)

Updates fsm-architecture.md §6.5 and §11 phasing summary; adds
Phase 3d (build.rs-driven auto-emit) as the remaining optional
follow-on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the separate `cargo run --example fsm_doc_demo` step
from the dev cycle.  New helper:

  rhdl_fpga::doc::refresh_and_check_fsm_diagram::<W>(filename)

Combines write + check in one call.  Designed to be used from a
single-line #[test] per FSM-tagged widget:

  #[test]
  fn refresh_my_widget_diagram() {
      rhdl_fpga::doc::refresh_and_check_fsm_diagram::<MyWidget>(
          "MyWidget_fsm.md"
      ).unwrap();
  }

Author workflow collapses to: edit kernel → `cargo test` →
diagram is fresh and `cargo doc` picks it up via #[fsm_doc]'s
include line.  No separate refresh command.

The strict no-refresh assert_fsm_diagram_up_to_date remains
available as a CI canary that catches renderer-level
regressions (a change to the SVG layout would fail it even
when the underlying FSM didn't change).

Updated demo test to use the new helper.  fsm-architecture.md
§6.5 + §11 mark Phase 3d shipped.  A true build.rs-driven
auto-emit (Phase 3e) is honestly noted as not worth building —
Rust's build model makes it awkward, and cargo test already
gives the single-command dev cycle.

11 doc::tests pass overall (10 existing + 1 new auto-refresh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FSM Phase 3c: #[fsm_doc] attribute auto-injects diagram into rustdoc
…ments

The v1 canonical extractor that landed in PR #2 only recognised the
let-binding form (let next = match q.state { … }; d.state = next;).
Per Phase 3b's first widget-corpus survey, this excluded ~95% of
real RHDL widgets, which use the side-effect form (the canonical
RHDL idiom per CLAUDE.md):

    match q.state {
        State::A => { d.state = State::B; }
    }

This PR extends extract_canonical_transitions to handle BOTH forms
by walking the RHIF data-flow graph backward from each arm's result
slot through:

  - OpCode::Splice matching the state-field path → recurse on subst
  - OpCode::Splice with a different path → recurse on orig (state preserved)
  - OpCode::Select (if-else) → union both branches
  - OpCode::Case (nested match) → union all arm results
  - OpCode::Struct with explicit state field → recurse on field value
  - OpCode::Index reading q.state → emit self-loop to source variant
  - OpCode::Assign → forwarded recursion

Each arm tries the value-form walker first, falls back to the
d-struct-form walker if that comes up empty, and surfaces an
Unanalyzable diagnostic only if both fail.  Returns are
deduplicated per-arm so two paths producing the same target
collapse into one transition.

## §11.1 Compiler-Level PR Justification

1. **Guarantee preserved:** the FSM-extractor invariant that
   "every transition the kernel can produce is reported, every
   reported transition is realisable" — the existing let-binding
   form already had this; this PR extends it to side-effect form.

2. **No new loophole:**
   - Wild arms still skipped silently (existing behaviour).
   - Arms whose state assignment is genuinely opaque (computed via
     arithmetic, etc.) still surface Unanalyzable — never silently
     drop a transition.
   - Both walkers run independently; either succeeding is enough.
     Both failing → diagnostic.

3. **Downstream effects:** every widget that adopts #[derive(Fsm)
   + #[derive(FsmWidget)] becomes auto-derivable; the cleanup PR
   for refactor/use-fsm-and-or-patterns can drop FSM_TRANSITIONS
   consts wholesale.  No emitted Verilog changes — extraction is
   advisory metadata only.

4. **Alternatives considered + rejected:**
   - Restructure all 27 widget kernels to let-binding form
     (~2 days each, breaks the canonical RHDL idiom).
   - Keep manual FSM_TRANSITIONS as the long-term path
     (perpetuates two-sources-of-truth + gives up on Phase 3
     acceptance criterion #2).
   - Add a separate "complex extractor" that runs IFF the simple
     one fails (more code, more state, no benefit over union).

5. **Reversibility:** advisory pass only.  If the new walker turns
   out to have false positives in a future widget, that widget can
   fall back to write_fsm_diagram_with_manual_transitions (which
   the cleanup PR is going to add as the escape hatch anyway).
   No NTL or RTL output changes.

## Tests

11 new unit tests in rhdl_core::fsm::extraction::tests covering
the synthetic-RHIF matrix:

  - side_effect_form_three_unconditional_splices
  - side_effect_with_default_then_conditional_override_yields_self_loop_plus_target
  - nested_if_else_in_side_effect_arm_unions_all_branches
  - splice_into_unrelated_field_preserves_state_walker_result
  - back_to_back_splices_into_state_last_write_wins
  - struct_opcode_with_explicit_state_field_resolves
  - struct_opcode_without_state_field_is_unanalyzable
  - d_state_eq_q_state_alone_yields_self_loop
  - opaque_arm_result_yields_unanalyzable_diagnostic
  - side_effect_form_deduplicates_targets_across_branches
  - value_and_d_struct_walkers_unioned_without_duplicates
  - let_binding_form_still_works_after_refactor (regression)

7 new adversarial integration tests in rhdl_fpga::doc::tests
exercising real Synchronous + FsmWidget kernels with distinct
kernel-language idioms:

  - adv_sideeffect_conditional (default + override)
  - adv_nested_if_else (3-way branches in one arm)
  - adv_let_binding (regression on existing form)
  - adv_computed_then_assigned (let-bound select inside arm)
  - adv_arm_with_no_assignment (empty arm preserves state)
  - adv_mixed_arms (mixed conditional + unconditional arms)
  - adv_let_binding_with_self_loop_branch

Test counts:
- rhdl-core fsm:: 35 passed (was 23, +12)
- rhdl-fpga --lib 440 passed (was 432, +8)
- rhdl --test fsm 17 passed (no regression)

Updates fsm-architecture.md §5.5 with the status note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the user/LLM-facing surfaces of the FSM compiler track with tests
that fail loudly if the contract drifts:

- 10 new diagnostic-message tests in fsm::analysis. Assert on rendered
  message() text (not just kind matching) for every FsmDiagnosticKind.
  Required vocabulary, fix hint, source/widget localization, multi-failure
  surfacing, extractor reason passthrough.

- 16 new SVA emission tests in fsm::property. Structural conformance per
  IEEE 1800-2017 §16: parse_property_line decomposes each rendered line;
  is_valid_sv_simple_identifier enforces §5.6. Covers bound=0, bound=u64::MAX,
  bound=None distinction, identifier validity (letter / underscore / dollar),
  pragma markers, line-count exactness, canonical clock label,
  SV-keyword passthrough.

CHANGELOG entry covers both the side-effect-form extractor work and the
new test contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FSM extractor: handle side-effect d.state assignments (Phase 2 enhancement)
When both the value-form and side-effect-form walkers run cleanly but
find no state-overriding op for a match arm, interpret the arm as a
self-loop on the source variant rather than Unanalyzable.  This is the
correct semantic given CLAUDE.md §3's canonical pattern: kernels
construct `d` via `dont_care()`, write `d.<state_field> =
q.<state_field>` once at the top, and only override the state in arms
that transition.  An arm that omits the d.state write therefore means
"hold the state in place" — not "the kernel is malformed."

The implicit-self-loop semantics is applied at union points (Select
branches and Case arms) inside the d-struct walker, plus the top-level
fallback in `extract_canonical_transitions`.  The walker's leaf-return
paths (find_definer-None, unrecognised opcode, Struct-without-state-field)
keep their `Ok(vec![])` return so the value-form walker's analysis can
still win when called on a state-typed slot — restricting the convention
to union points keeps the let-binding form's analysis clean.

`Unanalyzable` is now reserved exclusively for genuinely malformed IR.
Pinned by an inverted negative test
(`arm_with_unmatched_enum_discriminant_yields_unanalyzable`) so a future
loosening that re-broadens the Unanalyzable surface fails loudly.

Test coverage:
- 4 new synthetic-RHIF unit tests in fsm::extraction::tests covering
  the canonical kernel-top-default + guarded-transition shape, the
  can_master arm verbatim (else-branch writes only the bit counter),
  bare arm with no state write at all, and the negative
  malformed-discriminant case.
- 3 pre-existing tests reframed: previously asserted Unanalyzable for
  shapes that are now correctly interpreted as implicit self-loops.
  Old assertions would have masked the can_master regression.
- 2 new adversarial integration tests in rhdl_fpga::doc::tests:
  adv_can_master_guarded_else_writes_other_field (a faithful 3-state
  stand-in for can_master::Id arm: kernel-top default + guarded
  transition + else-branch updating only d.bit_idx) and
  adv_nested_conditional_implicit_self_loops (nested if-else where two
  paths inside one arm independently omit d.state, proving union
  dedup works).

Validation:
- 65 fsm:: tests passing (was 61, +4 new).
- 442 rhdl-fpga lib tests passing — no widget HDL snapshot regression
  (extractor changes are purely additive).
- 120 rhdl-core lib tests passing.

Updates fsm-architecture.md §5.6 status note and CHANGELOG.md per
CLAUDE.md §11.1 + §16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@twitzelbos twitzelbos closed this Apr 29, 2026
@twitzelbos
Copy link
Copy Markdown
Contributor Author

Wrong repo, sorry

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