2f-latch Full latch support — lgraph, Verilog, Pyrope, LEC (latch-only and mixed), simulation; unified with flops and multiple clocks

Where this stands (M8 LANDED 2026-07-24)

M0–M8 are LANDED. Latches and negedge state now work in both formal and simulation. All three false-PROVEN clock-blindness holes are closed, including the same-clock EDGE hole M4 could not reach, and every previously-vacuous latch verdict is now real. See Landed for the compressed record and Traps for what must not be re-derived.

The vehicle is M8 — edge normalization (pass.single_edge, pass/single_edge/): a conditional LGraph→LGraph pass that removes latches and negedge state before the solver ever sees them. M4's in-encoder approach stays RETIRED.

Pending M10 supersedes M8 only inside formal. M8 remains a landed standalone transformation and the independent source-vs-normalized Icarus oracle. The remaining hierarchy/memory work will not extend its phase counter or rewrite definitions in place: M10 gives LEC/BMC/verify a read-only hierarchy-aware phase schedule and encodes one combinational cone per consuming phase. Compile, synthesis, simulation and emitted LGraphs remain on the source representation.

Was redNowHow
latch_verify_hold, latch_verify_low PROVEN (promoted out of the fixme arm; a mutated claim REFUTES at cycle 3) P=1 LatchFlop retype — tolg already baked din = en ? d : q, so the ordinary flop update is end-of-cycle transparency
flop_verify_posneg PROVEN; the blind encoder's own answer (bq == previous aq) now REFUTES phase divider + slot enables at P=2, plus the period-boundary assert guard
7 prp-v2prp2v-latch_*, prp-v2prp2v-flop_reset_matrix all 8 carry REAL verdicts (were equivalence UNCHECKED, rc 0) miter-wide normalization; latch_enable_hold additionally needed the slang reader to stop losing an inferred latch
lhd formal verify without --workdir all three invocation shapes agree, asserted in latch_fail_closed_test.sh Verify_result::unsupported + oversize_refused added to the fork-race wire codec
negedge vs posedge in lec_clock_blindness_test.sh REFUTED — the case flipped from a note to an assertion, with a vacuity guard beside it the third hole; see M4 residue

The transformation is validated against an INDEPENDENT oracle, not against itself. single_edge_four_classes_test.sh runs the SOURCE netlist (real always_latch, real negedge) and the NORMALIZED one (posedge flops + phase divider) side by side under Icarus Verilog, the normalized side clocked P times per source period, sampled per the observation-visibility rule: they agree over 39 periods. Two negative controls prove the harness discriminates — clocking the normalized side at 1× fails, and swapping the two slot predicates fails. That is what the binding cross-model gating rule demands, and it is why the four lhd lec refutations beside it are not vacuous.

What is NOT done (deliberate, each with its reason): known-integer-ratio multi-clock slotting (step 6 — a second clock domain is SKIPPED, so encode.cpp's detected-edge branch and M6's sim model keep handling it); P>2; hierarchy (a stateful Sub fails closed); witness-index decimation back into periods (step 2d ships the honest SKIP instead); and the opposite-phase peephole. Full detail in the step tables.

2026-07-25 audit — 51 confirmed gaps, 8 soundness holes CLOSED

A 75-candidate audit (five finders, one adversarial verifier per claim; 51 CONFIRMED, 10 misscoped, 2 already-working) found that M8 closed the shapes it targeted but that the LEC encoder still had no general model of WHICH CLOCK a state element commits on. Its transition function is N = ITE(reset, initial, ITE(enable, din, Q)) and it attached a commit guard in exactly two special cases — the literal ICG shape <clock-input> & <enables> with the port named clk/clock, and ≥2 clock INPUT nets. Everything else encoded as "commits every step, the clock expression is dead code".

Eight reproducible FALSE PROVENs, all now honest (each was measured before and after):

shapewasnow
posedge (clk ^ sel) vs posedge clkPROVENrefused (exit 7)
ICG on a port named mclk vs ungatedPROVEN — rename it clk and it REFUTEDrefused
divide-by-2 clock vs undividedPROVENrefused
lhd formal verify on a divided clockPROVED a property FALSE in hardwarerefused
negedge flop inside a submodulePROVENrefused (names the def and what is in it)
latch gated by clk vs by clk2PROVEN (lgyosys REFUTES)refused (names both clocks)
memory written on negedge vs posedgePROVENrefused
gated-clock memory write vs ungatedPROVENrefused

And one two-directional error, the worst of the set: an inversion left in the CLOCK CONE (wire nclk = ~clk; always @(posedge nclk)) was invisible to commit_class_of, which kept only resolve_phase().net and took the edge from the posclk pin alone. Via the slang reader that gave a false PROVEN against posedge clk AND a false REFUTED against the identical negedge clk — while the yosys reader was correct, because its proc folds ~clk into CLK_POLARITY=0 first. So the whole negedge story was reader- and spelling-dependent. Fixed by folding .inverted into rising (the Latch branch already did this), teaching resolve_phase the x & 1 width mask, and counting negedges in the trigger from the COMMIT CLASS rather than from the pin. Pinned by two new cases in lec_clock_blindness_test.sh.

Five regressions M8 itself introduced, all fixed: the clock-gated-latch gate absorption dropped the gate's IDENTITY (now the miter refuses when the two sides normalize against different reference clocks); the lec trigger scanned only --lib cell models, never the design's own defs; the period-boundary guard rewired ASSUME conds too (an assume must constrain EVERY step — guarding it left mid-period steps free and produced a false REFUTED); a latch's ASYNC reset was folded inside the slot gate against M7's ruling; and a data-gated latch's slot depended on fast_class() order, violating condition 2. Two over-refusals also fixed: a def is only "needing normalization" if it holds a latch or a negedge flop (not merely two clock nets), and a def the user TRUSTED is a blackbox and is skipped — formal.lec.trust is the documented escape hatch for exactly the shape the pass was pre-empting.

Still open — six SILENT SIM bugs, each with a live 4-tick tracker in inou/prp/tests/sim/ (tagged fixme; every fixture carries CONTROL asserts that pass, so a failure is always attributable): latch_sim_clockgate_high (a latch gated by the clock net never commits — sim and formal verify disagree on this shape), latch_sim_l1_buffer (the L1 coincident-edge case, which the pass refuses for LEC and sim models wrongly), flop_sim_secondary_negedge (the previous-level bit is updated before phase 2, so the negedge guard is a dead false), mem_sim_negedge_write (a memory write emitted as 0 /*UNRESOLVED-CYCLE*/ when a negedge flop is present), hier_sim_negedge_sub (negedge state in a child lands a full period late), flop_sim_peek_secondary (a pre-step output read consumes a second-domain clock edge). Also open from the audit: four reader shapes that build a WRONG GRAPH from clean-compiling source (partial bit-range hold, always_latch under a for-loop, latch-on-an-unpacked-array, blocking write to an internal signal in an edge process), the latch-inference OVER-fire on a fully covered case with no default, and the oracle gaps below.

Two oracle corrections. The 8 prp-v2prp2v latch targets CANNOT FAIL: the harness returns 0 for INCONCLUSIVE and UNKNOWN, so step 3f's stated gate was never implemented. And the iverilog leg — the only INDEPENDENT oracle for the whole transformation — skips silently when iverilog is absent, and CI never installs it. Combined with latch-only designs normalizing to a clockless flop, the independent oracle covers zero latch-only, negedge or multi-clock designs today.

CLOCK GATING — recognized and encoded as a FLOP ENABLE (2026-07-25)

An ICG is the commonest latch structure in real hardware, and it was refused as a second clock domain until now. Modelling it as a gated CLOCK is both unsound here (the encoder has no clock-identity model, so a gated clock and an ungated one look identical unless the ICG name heuristic happens to fire) and wasteful (a per-step commit term on every flop). But a gated clock is a flop enable — the flop commits on the reference edge exactly when the enables hold there — so pass.single_edge now recognizes the pattern and rewrites it into the enable, which the encoder models natively at zero extra cost (user direction, 2026-07-25):

  always_latch  if (!clk) enl <= en;        always @(posedge clk)
  wire gclk = clk & enl;              -->       if (en) f <= d;
  always @(posedge gclk) f <= d;

The ENABLE-LATCH BYPASS is what makes it exact, and it is the easy thing to get wrong. A real ICG latches its enable on the opposite phase purely to suppress glitches: the latch CLOSES exactly when the flop SAMPLES, so the flop sees the value the latch is passing THROUGH, not its previously-held one. Taking the latch's Q is a full cycle late — the classic L1 error — and it is invisible to a symmetric before/after gate because both sides would be wrong identically. Measured: the first cut did exactly that, because the rewrite loop had already retyped the latch to a Flop by the time it reached the gated flop, so the bypass silently did not fire. The resolution now happens during PLANNING, before any mutation. Pinned by single_edge_icg_test.sh, whose iverilog leg agrees over 29 cycles with the gate toggling and whose negative control (use the held Q instead) FAILS.

Two scoping rules keep the blast radius honest. A CONSTANT operand is a width mask, not an enable — reading And(Not(Get_mask(clk)), 1)'s 1 as an enable made an INVERTED clock look like a gated one and silently un-fixed the negedge case. And the fold only applies in a SINGLE-clock design: a gate on a second domain (gclk = clk_b & gate while other flops run on clock) has no reference clock to be relative to, so it stays skipped for pass/lec's multi-clock machinery — which is what equiv/mclk_derived pins.

REAL DESIGNS INSTANTIATE THE GATE, and that is the case that matters. Nobody spells an ICG inline — minion has 32 prim_clk_gate u_cg(.clk_i(clk), .en_i(en), .clk_o(gclk)); instances (they are exactly the 32 defs lhdsuite's lec_trust knob lists). The gate then sits ONE MODULE LEVEL AWAY and the flop's clock_pin is an opaque Sub output that nothing downstream can recognize — so the in-graph fold above never fires and, once the encoder started refusing unmodelled derived clocks, every such flop refused. Handled by INLINING exactly those cells first: an instance that drives a state element's clock_pin AND whose def contains a latch (latch_contract::inline_clock_gate_cells, reusing the landed graph_util::inline_sub_instance). Inlining is unconditionally semantics-preserving and idempotent, so it runs on every side before any analysis, and afterwards the ordinary fold applies. Both halves of the predicate matter: "drives a clock" alone would inline a clock buffer or PLL wrapper for nothing, and "contains a latch" alone would inline half the design.

Two traps that cost time here. (1) The same pin reached through an EDGE and through Node::out_pins() does not compare equal, so a pin-keyed "does this instance drive a clock?" set silently matched nothing — key on the driving NODE instead. (2) inline_sub deliberately leaves the def in the library for its other instantiation sites, so a def_needs scan over every library graph stayed stale after the inline and refused anyway. It now walks the design's own INSTANCE TREE, and intersects that with the caller's allow-list so a def the user TRUSTED stays a blackbox (lec_trust_test — trust is the documented escape hatch for "the encoder cannot model this leaf", and refusing on its contents pre-empts the very mechanism that makes such a design provable).

Resolution FAILS CLOSED when it cannot tell the clock operand from the enable: in the canonical clk & en BOTH are graph inputs, so "is it an input?" cannot decide, and guessing backwards classifies the enable as the clock and drops the gate entirely. A cone with no identifiable clock operand, or more than one, is not an ICG — and since the encoder now refuses an unmodelled derived clock, that is an honest refusal rather than a wrong answer.

M9 — Clock_cell: ONE recognized clock operator LANDED (formal half) 2026-07-26

STATUS 2026-07-26 — steps 0-2 are LANDED; step 3 (sim) and step 4 (shrink lec_trust) are not. The design below is the record; what follows is what it actually does now.

Measured. livehd //... 1446/1447 pass (baseline 1444/1445; the two extra are the new tests), zero failures; dino_lec, dino_lec_bug, dino_synth_lec_flat unchanged. //bench:minion_lec: the 30 encode refusals are GONE — it reaches the solver for the first time and now REFUTES (exit 7 → exit 10) at minion_dcache_cache_op_unit_l2, with 110/140 proven, 32 trusted, and 10 gates recognized symmetrically per side.

Still open, honestly. (1) That refutation: the earliest diverging state cut is id_running_q (ref=0 impl=1 after step s3). Two things are already ruled out — the checked-in Pyrope is NOT stale (regeneration is byte-identical) and the gate's enable cone matches TEXTUALLY on both sides (dft_i is tied to '0 on both), so this is a candidate REAL translation difference of exactly the class minion_lec exists to surface. (2) A gate whose output crosses into a Sub instance's clock PORT is not recognized — materialize_clock_cells scans only clock_pins in the SAME graph, and ctrl_frf_clk (gated in txfmafrac_top, consumed by txfma_f0) is the residual “derived clock” refusal. (3) Steps 3-4 (sim lowering, shrinking lec_trust).

User direction, 2026-07-26. M8 folds a clock gate into a flop enable when the gate is visible in the body. That is not how real designs are written: they instantiate the gate, and a Sub output is an opaque symbol. Measured on //bench:minion_lec (2026-07-26): 30 defs refuse to encode — 22 flop … has a derived clock + 8 memory … has a derived clock — while 110/140 prove and 32 more are trusted. Nothing times out; they never reach the solver.

The intuition this inverts: for a clock gate, blackboxing the proven leaf is precisely what breaks the parent. Every other leaf is fine opaque — its outputs become free symbols the parent compares. But an ICG’s output is a clock, and the encoder has no clock-identity model, so a flop whose clock_pin is an opaque Sub output cannot be given a commit condition. “Prove it, then box it” does not compose for an ICG — which is why prim_clk_gate sitting on minion’s formal.lec.trust list is self-defeating.

What the cell buys, beyond this bug. One recognized operator is the enforcement point for “nothing may be done to a clock” — the clock network becomes auditable structure instead of arbitrary logic. Formal is the first consumer, and that means BOTH drivers: lhd lec and lhd formal verify funnel into the single Encoder in pass/lec/encode.cpp (instantiated from query.cpp’s prove_equal AND prove_properties), so one lowering helper serves both. And it is deliberately the anchor for FUTURE POWER WORK: the shared ICG surviving to mapping is a power feature, not just area — gating-coverage metrics, automatic clock-gate insertion (the REVERSE of the LEC lowering: turn a flop-enable cone into a Clock_cell), and eventually power-gating domains all hang off the same operator. That is why the cell is a representation to KEEP, not a normalization to fold away.

The cell

  out_clk = Clock_cell(clk_ref, en, div=1, invert=false)

v1 is div=1 ONLY — div>1 is an honest error

User ruling 2026-07-26: “ship div=1 first as we do not have test cases for div=2 or div=4 (just flag error as not implemented)”. The syntax admits div; the lowering refuses anything but 1. Three independent reasons, all measured or cited:

Recognition — ONE shared recognizer, THREE entry points

All three must call the same function and each must be pinned by a test. If any one is missed, that design silently falls back to the refusal path and looks like a tool bug.

The enable is a CONE, not a wire — measured on the real gate. prim_clk_gate.sv latches en_i | dft_i.scanmode (scan forces the clock ON), and dft_i is a packed struct that graph flows keep as one FLAT 5-bit bus (struct_port_bundles=false), so the compiled def’s latch din is or(en_i, and(sra(dft_i, k), 1)) behind tolg’s hold mux. The body match must accept ANY combinational cone of the non-clock input ports as the latched value, and bind the cell’s en to that cone re-rooted on the instance’s actual nets. Dropping the scanmode term is UNSOUND, not conservative — gate sites wire a live dft (minion_frontend_thread_buffer.sv:327, txfmafrac_top.sv:354) and minion_top exposes it as a primary input.

Clock identity inside the matched def is STRUCTURAL, never the port name. prim_clk_gate’s own graph holds no flop, so Design_clocks’ roots are empty there and only the name heuristic could identify clk_i — and the heuristic is being retired (Rules below). The anchor that replaces it: the port that is BOTH AND-ed into the output AND gates the latch’s transparency window is the clock; equivalently, at the Sub boundary, clockness flows TOP-DOWN from the instantiation (the net wired to the port is a clock in the parent). Both resolutions agree on every real ICG and neither reads a name.

Lowering — the cell is the REPRESENTATION; each consumer lowers its own view

Canonicalization — required, or structural matching degrades

Rules — BINDING

Implementation order — do NOT skip step 0

Acceptance — what must move

Traps — measured, do not re-derive

Measured on real designs — lhdsuite dino + minion (2026-07-25)

Run with cd ../lhdsuite && bazel test //bench:<core>_lec* --override_module=livehd=../livehd (opt build; minion_lec ~200 s).

targetbeforeafter
dino_lec, dino_lec_bug, dino_lec_incrementalPASSPASS
minion_lec_bugPASSPASS
minion_lec, minion_lec_incrementalFAIL (known)FAIL (known) — 110/140 defs proven, 32 TRUSTED, zero latch or derived-clock refusals, identical to baseline

NO REGRESSION — which was the real risk, since the new fail-closed guard for unmodelled clocks could easily have refused an industrial core full of gated clocks. It did not, because minion's ICGs sit in defs the suite already trusts.

But minion also found two shapes the in-graph work had missed, and both are now fixed (see Clock gating): real designs INSTANTIATE the gate (32 prim_clk_gate instances — exactly the lec_trust list), and prim_phase_pair gates a latch on !clk && lo_en, a clock ANDed with a data enable.

The one thing blocking minion's trust list is HIERARCHY. Measured directly: minion_lec with an EMPTY trust list refuses with def `prim_rf_1r1w_preview_p2` holds 1 latch cell(s), 1 negedge flop(s), and normalizing across a module boundary is not supported yet. At that measurement 32 names were configured; Clock_cell recognition later made prim_clk_gate inert, leaving 31 effective trusted definitions. Those 31 are not a workaround for the encoder's old Latch refusal (that is gone): their remaining blocker is hierarchy. What will close it: M10 schedules instantiated state directly in the formal encoder. It does not normalize definitions, synthesize a phase counter, or port-thread timing state through hierarchy. Tracker: lhd/tests/single_edge_hier_latch_test.sh continues to pin today's honest refusal until the formal pipeline stops depending on that pass.

M10 — hierarchy-aware formal phase scheduler PENDING

No coloring and no graph rewrite. Starting at the selected top, the formal driver resolves clocks through the actual instance bindings and assigns a semantic phase to every instantiated register/latch and every synchronous memory port. For each endpoint it walks data, enable, reset, address and write-data cones backward across non-opaque hierarchy, stopping at constants, primary inputs, state, and opaque boxes. Gates are memoized inside one phase; a gate feeding two phases is deliberately encoded twice.

Single-root v1 schedule

  1. active-LOW clock-role latches close immediately before rise;
  2. posedge flops, posedge memory ports and data-gated latches update simultaneously;
  3. active-HIGH clock-role latches close immediately before fall;
  4. negedge flops and negedge memory ports update simultaneously.

A same-batch state read sees the incoming value; a later batch in the same period sees the earlier committed value. The latch-close microstep and its edge batch share one symbolic input snapshot, while rise and fall get independent snapshots shared between REF and IMPL. LEC observes external outputs after rise and after fall. An unclocked formal assertion is evaluated at the full-period boundary by default.

Data-role is not clock-role. A data-gated latch has Pyrope's ordinary tick semantics and joins the rise/base batch; active-low polarity changes its write test, not its phase. Only a latch whose controlling cone resolves to the root clock gets close-before-edge treatment. This is the live latch_mixed_flop behavior and corrects the old “data gate → last slot” planning text.

Clock_cell remains timing metadata, never an SMT data value or scheduled endpoint. Canonicalize a chain to its reference clock, parity and active-high guard. Sample the guard before the reference rise, reuse it for the affected rise/fall consumers, and combine it as endpoint_enable & clock_guard. Inversion changes the consumer edge; div != 1 remains a named refusal.

Memories are scheduled per synchronous port. The Memory IR must retain read and write edge polarity per port (with a compatibility fallback for the current global value); the Yosys import must stop dropping RD_CLK_POLARITY. Each phase applies only its ports, while writes in the same phase retain the existing simultaneous forwarding/collision policy. This formal correction does not close the separate 2m-multiport_mem Verilog-codegen issue.

Hierarchy composes through phase-aware state boxes. Replace the current one-transition-per-period State_box with shared transition and Moore-output contracts per semantic phase. A child collapses only after those intermediate contracts prove; otherwise it stays expanded. Each instance resolves the child's phases through its own parent clock binding and sampled Clock_cell guard. A body-less trusted blackbox is conservatively allowed to transition on both edges of each declared clock.

Scope and failure policy. v1 orders one root clock. An unrelated-root, flop-only design retains the encoder's existing detected-edge path. If a latch, phase-sensitive memory, or phase box would require a total order between unrelated roots, formal fails closed. Fluid flops, unresolved latch clock/data classification, and clock division also remain named refusals.

Delivery and gates

  1. read-only schedule analysis plus deterministic per-phase diagnostics;
  2. flop/latch phase encoding with edge-specific inputs and output compares;
  3. per-port memory scheduling, including registered reads;
  4. phase-aware sequential boxes and hierarchical collapse;
  5. remove mandatory pass.single_edge preprocessing from LEC and verify;
  6. measure minion and remove trust entries only for definitions that prove.

Acceptance covers posedge→negedge pipelines, both clock-latch polarities, the simultaneous data-latch/flop case, one Clock_cell feeding both phases with one rise-sampled guard, hierarchical rise-to-fall communication, mixed-edge memory ports, resets, and the unrelated-clock fallback. Bounds and reset counts remain source periods; cache keys include the schedule signature; witnesses name (cycle,rise|fall) and replay refuses explicitly until it can reproduce edge-specific stimulus.

Scope ruling (user, 2026-07-20) — BINDING: SIMPLE LATCHES, NO TIME BORROWING

Time borrowing is a backend / physical-design technique. LiveHD's scope ends at the ABC call, and ABC's output netlist has no clock borrowing either. This ruling collapses the expensive half of the problem. Under it a latch is exactly a flop-with-enable that commits at the CLOSING edge of its transparent window:

That is Conformal's set flatten model -latch_fold / the IBM LEC normalization (US7624363). It is an ABSTRACTION WITH A PRECONDITION, not a semantics — and the scope ruling is that precondition, so it must be CHECKED, never assumed (M3 does, in graph/latch_contract.cpp). What it buys: no transparency mux in the sim scheduler, no settle/fixpoint loop, no MDL phase abstraction, no counterexample lifting — nothing research-grade on the critical path.

The abstraction changes OBSERVABILITY, and that must be stated: while a window is OPEN a real latch reads through (Q follows D combinationally) but the commit-class model shows the previous commit. The two agree exactly at closed-window, end-of-cycle observations. Three consequences run through everything below: (1) any latch encoding in LEC is sound only as a both-sides normalization — never encode one side with transparency and the other without; (2) sim asserts and differential oracles may observe a latch Q only under the observation-visibility rule; (3) the shared observation point for sim / BMC / Icarus is end-of-cycle, all edges settled — pinned executable by inou/prp/tests/fixme/flop_verify_posneg.prp (under the alternative "between rise and fall" convention that fixture's correct answer would be indistinguishable from today's clock-blind encoding).

The abstraction is for SIM and LEC ONLY. Verilog emission must still emit a real always_latch — the netlist handed to ABC has to contain a real latch. Terminology trap: an AIGER/ABC "latch" is an edge-triggered unit-delay register on an implicit global clock, NOT a level-sensitive latch; ABC's BLIF reader silently discards the .latch control tokens, so a real latch reaching ABC is a silent mismodel, not an error. Everything on this page means level-sensitive.

Rules — BINDING

Cross-model gating is mandatory. "PROVEN before AND after the transformation" cannot see a transformation that is WRONG but applied identically to both sides — two identically-wrong machines prove equal, so a symmetric gate is vacuous by construction. Every latch/clock transformation must be gated against an INDEPENDENT checker: lgyosys for latch polarity, iverilog for anything clock/edge-shaped, never our own encoder on both sides. This one mechanism catches mux placement, posclk double-negation, a flavour-blind ICG fold, and an unstable phase gate.

There is NO in-repo formal oracle for flop EDGES. lgcheck's cascade ends in a bounded miter (LGCHECK_BMC_STEPS default 5, inou/yosys/lgcheck:399) that decides latch designs and genuinely discriminates enable polarity — pinned by lhd/tests/lec_latch_polarity_test.sh. But it is exactly as edge- and gate-blind for flops as our own encoder was (reproduced: PROVEN on negedge-vs-posedge and gated-vs-ungated). So an equiv pair of mixed-edge designs "proving" via lgcheck is vacuous with respect to the edges; only event-driven simulation discriminates them.

Verdict discipline (inherited from 2f-lec): PASS ⇒ definitively equivalent; FAIL ⇒ definitively different; anything else is inconclusive. An abstraction may only cause extra work, never a wrong verdict. Trackers that demand a refutation must grep a verbatim REFUTED — an encoder that merely degrades a false-PROVEN to UNKNOWN has not fixed the bug.

Observation-visibility rule (used by every sim/BMC fixture): an observation of a latch Q — a settled-region assert, a differential-oracle sample, or a sequential element in Q's fanin cone at its firing boundary — is valid iff (a) the latch was OPAQUE in the tick immediately preceding the observation, (b) at least one closing edge has completed since power-on, and (c) the data was STABLE across the closing edge being observed. Under (a)-(c) the real-transparency semantics (Icarus), the commit-at-closing-edge model, and a flop-with-enable encoding all agree, so fixtures pin the RULING, not an implementation choice. Divergence is not confined to transparent ticks — it PERSISTS through any state element that sampled a latch while open (taint).

Traps and ruled-out approaches — do not re-derive

M8 — edge normalization LANDED; FORMAL WORKAROUND

One LGraph→LGraph pass — pass.single_edge (name open) — that removes latches and negedge state, and is conditional: it does not run at all unless the design has them. Scope, settled by the user 2026-07-24: verification and simulation only, never on the synthesis path (slot enables and a divider cost QoR) — but it must stand as a genuinely semantics-preserving transformation, not a formal-only hack.

Trigger. Scan for any Latch cell, any Flop with posclk known-false, or ≥2 clock input nets. None present ⇒ the pass is skipped entirely — not run-as-a-no-op. A plain posedge single-clock design never enters this code path, is never re-emitted, and cannot change verdict. That is the whole blast-radius argument, and it is why this can land without re-baselining the corpus.

When the trigger fires, every state element becomes a plain Flop on a single posedge clock, with the original timing carried by a synthesized phase counter (a divider off the design's own clock) plus per-flop slot enables. Commit-class → slot is a table, not an analysis — and the table is the output of M3's landed commit_class_of():

source commit classcommits onslot
Flop, posclk unset/trueclk rise0
Flop, posclk known-falseclk fall1
Latch, gate = clk, active-lowgate rise0
Latch, gate = clk, active-highgate fall1
Latch, gate = data (not a clock net)ordinary module tick0 (rise/base batch)
Flop on clk_x, known ratio revery r-th rise≡ 0 mod r·P

Why it closes both holes

negedge vs posedge. The blocker is that both flops commit exactly once per step and differ only in WHERE in the step — which a step-granular relational encoding cannot express. Slotting converts an intra-step ordering problem into a cross-step one: at slot 1 the negedge flop's din reads a Q that already committed at slot 0. Ordinary, and the existing Flop encoding handles it unchanged.

Latches — dissolved, not fixed. Emit Flop cells only and the free-per-side-constant problem in Traps cannot arise. Verified 2026-07-24: latch_hold_basic hand-lowered to a flop-with-enable, keeping the same lq rename that breaks the real pair, comes back PROVEN — the existing tier-2 pairing plus the inductive self-certifying relation handle it with no new machinery.

The deferred clock-waveform model is mostly subsumed. The reverted attempt was hard because commits were gated on a free, data-dependent clock. Here the schedule is static: at unroll step j you know exactly which slot commits, so the prologue reduces to "reset_cycles = N means N·P steps". Only sub-item (a) of the old deferred pair survives — sync-vs-async reset placement, carried into step 5.

Cost — it is not 2×

P slots multiply BMC depth, but not work, on one condition: initialize the phase counter concretely. Then at unroll step j the solver knows phase == j mod P statically, every slot predicate const-folds, and step j encodes only slot-k's commits plus their cones. Summed over P steps that is ≈ one fat two-phase step. (Risk: under phase=after_reset the encoder deliberately discards flop initial values at query.cpp:2942-2951 — see Risks 2.)

Two cheap wins: the pass never runs on a clean design, so P>1 is paid only by designs that genuinely mix edges or hold latches; and the opposite-phase peephole — a master/slave latch pair is a posedge flop — can retire the mixed structures outright so the trigger goes quiet and P returns to 1.

NOT needed, dropped after reading the code: "make induction period-aware (k mod P)". There is no k: verify's V3 rung is hard-coded to two frames (query.cpp:6605) and lec's ind engine is a single-step flop-cut miter (:3828-3831), not an unroll. With step 3's guard the property is 1-inductive with zero solver change. Plumbing P through Lec_options and the fork-race codecs goes with it.

Conditions that must hold

  1. The trigger must be evaluated over the MITER, not per side. If one side holds a latch and the other is its already-lowered round-trip, the pass fires on one side only and the two are compared in different time bases — a guaranteed false REFUTED, and exactly the shape of the prp-v2prp2v-latch_* corpus. If either side needs normalization, both are normalized.
  2. Slot assignment derived structurally from the commit class — never from node or traversal order. In LEC the pass must run inside the pipeline on both sides with identical options, never as a manual pre-step.
  3. Obligations and miter comparisons gated to the period boundary (phase == P-1), or mid-period states are compared and equivalent designs refute.
  4. Latch classification must survive the lowering. Gate-driven-by-clock → slot from polarity; gate-driven-by-data (the latch_verify_hold shape, en = (c==0) or (c==4)) → ordinary flop-with-enable in the domain's last slot. Collapse those two classes and latch_verify_low's polarity canary goes blind.
  5. Coincident commit edges must share ONE slot — a soundness precondition. Slotting does not fix M3's rule 4 (coincident-edge latch → buffer, the US7624363 L1 case); it reproduces it. A transparent-low latch on clk commits on the RISE, and a posedge flop on clk also commits on the RISE — put them in different slots and the flop reads the latch's previous commit, a persistent full-cycle error. So equal (net, edge) ⇒ equal slot, and the L1→buffer rewrite (or an explicit rejection) lands before the lowering. This is not the opposite-phase peephole: that collapses a master/slave PAIR into one flop and is optional; this turns a SINGLE latch into a buffer and is mandatory.
  6. Known integer ratios only. A genuinely free second clock — a clock port the testbench drives as data, which M6 models in sim and encode.cpp:2245-2269 models in LEC — has no ratio and must be DECLINED, not slotted.
  7. Witness / VCD must map back. lecfail replay, formal_report.json cycle indices and VCD timestamps all count sub-steps after normalization and must be reported in periods.

How the transformation itself is validated

The transformation is not cycle-preserving (one source cycle becomes P steps), so no cycle-accurate equivalence checker can validate it — neither lhd lec nor lgcheck. Validation is trace-level against an independent model:

Implementation steps

STATUS 2026-07-24 — the tables below are the LANDED record. The step text is kept verbatim (its file:line references are still the map), with the outcome of each step here. Sizes S/M/L were relative estimates.

StepOutcome
0aLANDED. unsupported AND oversize_refused added to both halves of the Verify_result codec as a best-effort tail. Root cause confirmed: --workdir activates the verdict cache, which runs the bmc-first|ind-first portfolio SEQUENTIALLY IN-PROCESS; without it the two race as FORKED children and the flag died crossing the pipe. Gate: latch_fail_closed_test.sh now asserts the three invocation shapes AGREE (the invariant that survives every later milestone, rather than "rc 7", which M8 then made wrong).
0bLANDED. Commit_class gains Net_role {Clock, Data}, implicit_clock, and net_key()/key() (a STRUCTURAL identity, never a nid). New Design_clocks holds the per-graph clock-root set so classifying N nodes stays O(N). Gate: graph/latch_contract_test.cpp, 7 cases including the name-heuristic boundaries.
0cLANDED. needs_single_edge() next to commit_class_of, reusing it — no fifth private scan.
0dLANDED, and scoped honestly. A Latch no longer feeds posclk into f.posedge (which had put an active-low latch in the NEGEDGE sub-tick); the polarity now rides a separate neg_enable that inverts the write test, using is_known_false() so an X enable fails closed on both polarities. Unreachable today and that is recorded, not hidden: yosys's proc -ifx folds an active-low gate into the EN cone and never emits EN_POLARITY=0, and lhd sim cannot take a yosys-read design with a Pyrope test block at all. Same status as M7's importer guards — the landmine is removed and the code is right if a yosys change ever makes it reachable.
0eLANDED (doc). Recorded below; the trigger reuses commit_class_of rather than adding a fifth copy.
1aLANDED. pass/single_edge/, registered in lhd/BUILD (the real trap — pass/sample is fully written and silently does not exist because of exactly this), core/log.cpp channel, kSetPasses.
1bLANDED as the explicit-rejection arm. Rule 4 lives in the PASS, not in latch_contract::check(): a coincident-edge latch/flop pair is a normalization precondition, not a compile-time contract violation, and putting it in check() would fail the COMPILE of designs the synthesis path handles fine. Keyed on the commit CLASS (net AND edge), never on the slot — the first cut keyed on the slot and immediately rejected latch_mixed_flop, a live fixture whose data-gated latch feeds a flop, which is the ordinary flop-with-enable abstraction the whole task is scoped to. Gate: single_edge_scope_test.sh asserts both the rejection and its narrowness.
1cLANDED, and the yosys shape is HANDLED rather than refused. The step text says to refuse a raw-D/EN latch; measured, the refusal was unnecessary. LiveHD's Latch cell semantics is already q_next = enable_test ? din : q, identical to a flop-with-enable, so the retype is exact whether or not din carries tolg's hold mux. The real hazard is narrower and different: posclk known-false means ACTIVE-LOW ENABLE on a latch and NEGEDGE on a flop, so carrying the pin across the retype inverts the design silently. Fixed by moving the polarity into the enable cone (enable := !enable) and dropping the pin. Two contradictory shapes are still refused.
1dLANDED, with the boundary CORRECTED to phase == 0. The step text says phase == P-1; that is wrong by one sub-step. A step READS state and COMMITS at its end, so the state visible at the phase-k step is what committed through slot k−1 — the period is fully settled at the phase-0 step that STARTS the next period. Traced by hand on flop_verify_posneg: at a phase-1 step a has committed and b has not.
1eLANDED at P=2 (P>2 refused by name). Divider + slot enables, plus one thing the plan did not anticipate: SYNC RESET must move INSIDE the slot gate. The encoder nests reset outside the commit for every flop, i.e. async — harmless while one step is one commit, wrong once a flop owns only some steps. flop_reset_matrix caught it: the ref spells a sync reset with reset_pin and its OWN round-trip spells the identical reset in the data path, so at P=2 the two disagreed at the first checked step. Fixed by normalizing toward the data-path form (din := rst ? initial : din, enable |= rst so reset still wins over the enable, then drop reset_pin/negreset/initial) — dropping initial matters, since keeping it leaves a concrete power-on value the other side lacks and the state pairing reports "kind/init mismatch", i.e. a free per-side constant.
1fLANDED. lhd pass single_edge --top M lg:IN --emit-dir lg:OUT, plus lhd help/describe entries.
1gLANDED in BOTH verify_command and lec_command, miter-wide. lec PROBES both sides (dry run), takes max(P_ref, P_impl), and applies THAT to both — a side with nothing of its own to lower still gets the divider and slot 0. Without the force-to-max step an all-posedge ref (P=1) against a negedge impl (P=2) is compared in two different time bases.
1hLANDED and asserted. single_edge_scope_test.sh pins that lhd compile still emits a real always_latch and a real negedge, that the recipe never names the pass, and that a skipped design emits BYTE-IDENTICAL Verilog.
2aNOT DONE — deliberately, and measured. Risk 4 asked whether to gate the LEC miter compare to the period boundary. Both sides are normalized with the same structural slot table, so mid-period states already agree and gating would only REMOVE compare points — a false-PROVEN surface. Measured: an equivalent mixed pos/neg pair PROVES ungated (bound 12, 12 output checks) while a negedge-vs-posedge difference REFUTES. Ungated is strictly stronger, so it stays ungated.
2bbound scaling LANDED (o.bound *= P in both commands, so a fixture's :verify_bound: still means design cycles). reset_hold was NOT scaled and did not need to be: 1e's sync-reset fold puts the reset inside the slot gate, so a slot-k flop takes its reset value at the first phase-k prologue step, which the existing 2-step hold already contains at P=2. A pipeline deeper than the hold would need the scaling — see 2b's original note.
2cNOT NEEDED at P=2, and here is why. The concern was that kIndFirstBound = 1 clamps the ind-first child to a 1-step window that might contain no period boundary, making every guarded assert vacuously proven. With the boundary corrected to phase == 0 (see 1d), step 0 IS a boundary, so the window always contains one. It comes back if P ever exceeds 2 or the boundary moves.
2dthe guard LANDED; decimation NOT done. BOTH replay generators now emit a named honest SKIP when P>1 instead of a testbench that cannot reproduce: they re-emit the un-normalized source and drive the trace at an index that now counts sub-steps, and a replay that runs clean reads as "the counterexample was spurious".
3aLANDED. "Skipped, not run-as-a-no-op" is now falsifiable: byte-identical Verilog from the pass's input and output libraries, AND the over-fire direction asserted.
3bLANDED — and it is the iverilog leg, not a sim-vs-sim leg. The plan expected to build a two-DUT --emit-dir sim: harness. Better turned out to be available: --emit-dir verilog: works on BOTH graphs, so source and normalized are compared directly under Icarus in one testbench. That needed a real cgen fix (below) and gives the independent oracle the gating rule requires without a new Python harness.
3cLANDED, and the plan's DIAGNOSIS WAS WRONG. prp-v2prp2v-latch_enable_hold was filed as a trigger-disagreement fixture. Measured cause: the SLANG READER lost the latch. always @(*) if (en) q = d; — a blocking write on an incomplete path, the textbook inferred latch — was classified as pure comb and emitted q = en ? d : X. Fixed with a definite-assignment analysis biased toward comb (an unmodelled statement kind reports every write beneath it as definite, because inserting state the source never had is a NEW miscompile while missing a latch is the status quo). if/else, a default-then-override, and a case with default stay comb; an if with no else and a case with no default become latches.
3dLANDED. The three cases that asserted the encoder refusal as CORRECT are inverted, and replaced by what survives: a real non-vacuous verdict, invocation-shape agreement, and a lec pair that PROVES equivalent / REFUTES a polarity flip.
3eLANDED. All three verify fixtures untagged; lec_clock_blindness_test.sh case 3 flipped from note to expect_refuted and gained a vacuity guard; the stale "Tagged fixme" header is gone.
3fLANDED. All 8 print success with real verdicts.
3gLANDED, in PYROPE not Verilog. The four-class design needs a clock-GATED latch whose gate net is the net the flops run on — Pyrope reaches it with explicit clock_pin=ref clk. Driving it through the yosys reader instead also produces the four classes but names registers $procdff$32/n12 with numbering that SHIFTS between two variants of the same design, so state pairing cannot match them and every LEC returns a per-side free-constant CEX — a reader property, not a slot-table one. Verified emission: transparent-LOW lands in slot 0 WITH the posedge flop and transparent-HIGH in slot 1 with the negedge flop.
3hLANDED as a SKIP, which is a correction to the plan. The trigger text includes "≥2 clock input nets", and the first cut therefore REFUSED a two-clock design — which immediately broke lec_clock_blindness_test's two-clock-vs-one-clock case, degrading a CORRECT REFUTED into an unsupported exit. The rule now is: the pass only OWES a lowering for what the engines get WRONG (a Latch the encoder refuses, a negedge edge it is blind to). Anything else it merely could have handled is SKIPPED. A second clock domain that ALSO holds a latch cannot be skipped and fails closed by name.
Two cgen fixes were REQUIRED, not incidental. (1) cgen_verilog's flop enable used get_wire_or_const, so a COMPUTED single-fanout enable came out as a bare undeclared name — the exact bug M1 fixed for the latch's din/enable and this block's clock_pin, previously unreachable because every emitter gave a flop enable fanout ≥2. pass.single_edge synthesizes enable & (phase == slot) with exactly one consumer, and iverilog refused to elaborate it ("Unable to bind wire/reg/memory"); yosys would have invented an implicit wire reading X, which is worse. (2) A CLOCK-gated latch's gate must be ABSORBED by the slot, not AND-ed with it: after normalization one clock tick is one SUB-step, so a cone that reads the clock as data is evaluated P times per source period. Measured with iverilog: the latch outputs stayed X. The enable becomes the slot predicate alone and din becomes the TRANSPARENT arm of the hold mux. A DATA gate is left completely alone.

Sizes S/M/L are relative. Every "GATE" is the observable test that proves the step done. The step text below was written 2026-07-24 against the pre-M8 tree.

(0) Independent — start now, no deps

#StepSizeGate
0a Fix the serialize_verify refusal-flag loss. query.cpp:516-563 writes verdict/detail/counters/props and never Verify_result::unsupported (query.hpp:575), unlike the sibling codec at :347. Reproduced: lhd formal verify …/latch_verify_hold.prp --top lhold_tb --set formal.bound=12rc 0, status pass; add --workdir → rc 7; add --set formal.cache=false → rc 0 again. The harness passes --workdir, which is the only reason the fixtures are red. S rc 7 with no --workdir and with formal.cache=false; latch_fail_closed_test.sh asserts all three invocation shapes
0b Extend Commit_class with net ROLE + implicit-clock resolution. It is {Pin_class net; bool rising;} today (latch_contract.hpp:36-39) — no role, so latch_verify_hold's en = (c==0) or (c==4) resolves to the Or node's output pin and is structurally indistinguishable from a clock-gated latch. Also commit_class_of returns nullopt for an implicitly-clocked Flop. Ratio is NOT needed yet — defer to step 6. M graph/ unit test: latch_verify_hold's cone → role=DATA; master/slave if clk/if !clk → role=CLOCK with opposite rising; reg x = 0 with no clock_pin → a class, not nullopt
0c needs_single_edge() trigger in //graph. Next to commit_class_of. Note check() returns early at latch_contract.cpp:268-270 when there are no latches, so the Flop/Fflop branch has zero coverage today. Do NOT write a fifth private scan — see 0e. S true for the 7 equiv/latch_* + flop_reset_matrix; false for every other tests/equiv/*.prp
0d cgen_sim mis-models an active-low $dlatch. cgen_sim.cpp:1429 admits Latch into the flops vector and :1465-1468 reads posclk with the FLOP meaning and no op discrimination (the "only picks the VCD slot" comment is stale — f.posedge now drives the two-phase partition), and the enable is never inverted. latch_contract.cpp and cgen_verilog.cpp get it right. This matters because validation makes lhd sim the oracle. M a yosys-read active-low $dlatch VCD matches iverilog on the same Verilog — or write down that the sim oracle is scoped to tolg-emittable shapes
0e Record that there are FOUR commit-class implementations. commit_class_of has zero external consumers (check() has exactly one caller, lhd_kernel_compile.cpp:1295); cgen_sim.cpp, encode.cpp and semdiff.cpp each keep a private copy. M3's "one notion in the tree" claim is aspirational. M8 is its first real consumer. S doc-only; step 0c must not add a fifth

(1) The pass

#StepSizeDepsGate
1aSkeleton + registration + BUILD. static Pass_plugin + setup() (copy pass/partition/pass_partition.cpp:34-49), alwayslink = True, add the dep to lhd/BUILD, add the channel to core/log.cpp and the pass to kSetPasses. The lhd/BUILD dep is the real trap: pass/sample is fully written and correctly registered yet appears nowhere in lhd/BUILD, so pass.sample silently does not exist.S0clhd list options 'pass\.single_edge\..*' prints labels from the shipped binary
1bRule 4 (coincident-edge L1→buffer) — condition 5 above, now a precondition rather than "a small addition once M4 lands".M0blatch_contract_check_test.sh gains a transparent-low-latch → posedge-flop case, rejected with a distinct diagnostic (or rewritten to a buffer, with iverilog showing the full-cycle error gone)
1cLatch→Flop retype at P=1. For latch_verify_hold/latch_verify_low this is the whole transformation: both are data-gated latches in otherwise all-posedge designs ⇒ one commit class ⇒ P=1 ⇒ set_type_op(node, Ntype_op::Flop) and drop posclk. tolg already baked din = cond ? d : q. The yosys shape must be refused, not retyped (raw D + raw EN + const-0 posclk, no hold mux).S1a, 1bboth latch verify fixtures PROVEN; a yosys-sourced active-low latch is refused, not silently retyped
1dPeriod-boundary guard on design asserts, inside the pass. Rewire each fproperty Sub's condboundary implies cond. Keeps all period knowledge in one pass instead of smearing P through Lec_options, both codecs and the verify cache key. Prerequisite of 1e, not a follow-on: traced by hand at P=2, a phase-1 step has cyc/a committed but not b, so bq == aq is outright false (bq=11, aq=27 at step 3) and plain BMC refutes well inside the bound.S1aremoving the guard makes flop_verify_posneg REFUTE at the first phase-1 step
1ePhase divider + slot enables (P=2). Node idiom is settled — see abc_map.cpp:2025-2051 for create_typed_node/set_bits/connect_sink/create_const. Fail closed on Sub, Memory, and any surviving Latch/Fflop: a partial lowering reproduces the step-1 CEX with no diagnostic anywhere.M1a, 1b, 1dflop_verify_posneg PROVEN at the default bound; a surviving Latch/Fflop is a named diagnostic
1flhd pass single_edge subcommand. Modelled on partition (lhd_kernel_passes.cpp:619-641, incl. the --emit-dir lg:OUT must-differ refusal). This is the artifact the validation harness consumes; not on the critical path for the three fixtures.M1alhd pass single_edge --top M lg:IN --emit-dir lg:OUT writes a library; lhd_help_test.sh green in pretty and jsonl
1gInject into verify_command AND lec_command, miter-wide. Run the sweep after the --lib lg: cell-model graphs load (lhd_kernel_formal.cpp:2251), or a latch in a cell model stays an opaque blackbox. Must be before canonical_digest at :2480-2481, since a //graph-resident pass is outside //lhd:formal_salt. Cannot live in graph_pipeline_and_emits: the lg: and yosys branches never call it, and the blindness/polarity tests are lg: on both sides.M1c, 1ethe lg:-vs-lg: negedge case shows two recipe_steps entries; deleting the miter-wide union reproduces a false REFUTED
1hGuard: NOT on the compile/synthesis path. Do not add to recipe_graph_passes and do not call from graph_pipeline_and_emits — that is the shared graph half of both lhd compile and lhd sim.S1adiff -r on --emit-dir verilog: for all 7 latch fixtures before/after; always_latch and negedge still present

(2) Solver-side

#StepSizeDepsGate
2aPeriod-boundary gating of the LEC miter compare. One conjunct at query.cpp:3333. This is what makes a cross-side slot disagreement harmless. Must not gate to ZERO compare points or try_bounded_proven refuses the PASS.S1glatch_hold_basic latch-vs-lowered PROVEN with output_checks > 0 in the detail string
2bScale formal.bound and reset_hold by P. bound counts STEPS (query.cpp:2668, :5613), so bound=6 buys 3 design cycles at P=2. Worse, reset_hold picks up a pipeline-depth flush (:2853-2858) that is a structural count in PERIODS — a depth-d pipeline needs d·P steps but the metric returns d, so the prologue ends before reset lands.M1gdepth-3 pipeline with a negedge stage PROVEN vs its round-trip; un-scaling reset_hold reproduces a REFUTED
2cGuard-antecedent vacuity detection. res.vacuous covers only a contradictory assume set and output_checks <= 0. The auto race makes the gap reachable: kIndFirstBound = 1 (query.cpp:5097) clamps the ind-first child to bound 1, and at P=2 that window can contain no period boundary at all — every guarded assert is vacuously bounded-proven, becomes a Houdini candidate, is promoted unbounded, and cancels its sibling. Silent vacuous PASS.M1d, 2ba guarded assert whose guard is unreachable in the window reports Unknown, not Proven, and is never Houdini-promoted
2dReport cycle indices in PERIODS; guard the replay generators. Both replay generators re-emit the un-normalized source and embed the assert at the raw index. Ship the guard first (both already have an honest-skip precedent): refuse to emit a replay when P>1. Full decimation later.S / M1gwith P>1 a REFUTED emits a named "replay unavailable" skip instead of a testbench that does not reproduce; the human witness prints periods

Explicitly NOT subtasks — keep these as invariants. The ICG fold encode.cpp:2318-2356 stays: an ICG design has one clock input, all-posedge flops and no latch, so it never fires the trigger and is never normalized — delete the fold and gated-vs-ungated goes back to falsely PROVEN. The multi-clock edge branch :2245-2269 stays for the free/aperiodic clock. The eight Flop-only collections in query.cpp need no edit — they become correct by construction, which is a dependency of the pass (hence 1e's fail-closed), not work. pass/abc and cgen_verilog stay untouched on the source graph.

(3) Tests

#StepSizeDepsGate
3aNo-op proof. diff -r on --emit-dir lg: with the pass available vs forced-off, on a posedge-only design, plus an observable skipped stat. Today "skipped, not run-as-a-no-op" is unfalsifiable.S0c, 1adiff -r clean AND the pass reports skipped; forcing it to run fails the test
3bSource-vs-normalized differential sim harness — see Validation. Buildable before the pass exists, against the hand-lowered graph.M1c or 1eperiod-boundary traces match over ≥200 cycles; forcing the normalized side to P=1 fails. Stimulus must respect the visibility rule or the iverilog leg false-fails on every latch fixture
3cTrigger-disagreement miter fixture (condition 1). The live instance already exists: prp-v2prp2v-latch_enable_hold — ref is reg …:[latch=true], impl round-trips to a pure comb with no state element.M0c, 1glatch-vs-lowered and latch-vs-latch return the same verdict; per-side trigger evaluation makes it REFUTE
3dRe-baseline latch_fail_closed_test.sh. Three cases assert the encoder refusal as CORRECT and go red the moment 1c lands. Invert them in the same commit; keep the surviving fail-closed coverage.S1cthree cases assert PROVEN; gated-clock-unsupported still fires
3ePromote the three fixme verify fixtures out of the else ["fixme"] arm of prp-tbd-%s; flip lec_clock_blindness_test.sh case 3 from note to expect_refuted; fix its stale "Tagged fixme" header comment.S1c, 1d, 1ethe three targets green with no tag filter; blindness test asserts three REFUTEDs
3fReal verdicts for the 7 prp-v2prp2v-latch_* + flop_reset_matrix — all 8 pass vacuously today via the refusal branch.M1c, 1eall 8 print success; v2prp2v_test.py errors if a latch_* base reaches the refusal branch
3gAll-four-classes fixture (posedge + negedge + transparent-high + transparent-low in one module). No such design exists; without it a slot table that collapses two classes passes everything.M1everify twin proves both relational asserts; collapsing transparent-low onto transparent-high makes it REFUTE
3hFree/aperiodic second clock must be DECLINED. sim/multiclock_two_domain.prp drives clkb aperiodically and pins qb == 28 (coincident edges sample PRE-tick). Slotting it silently changes meaning.Mstep 6the pass reports skipped: no known ratio; forcing P=2 makes qb == 28 fail

Critical path and parallelism

0b (Commit_class role) → 0c (trigger) → 1a (skeleton+BUILD)
   → 1b (rule 4)  ─┬→ 1c (Latch→Flop retype, P=1) ───────────┐
                   └→ 1d (assert guard) → 1e (divider, P=2) ─┤
                                                             ↓
                            1g (inject into verify_command)
                                                             ↓
        latch_verify_hold ✅   latch_verify_low ✅   flop_verify_posneg ✅

Risks — cheapest experiment that settles each

  1. Does the phase counter actually const-fold? The whole cost argument depends on it, and under phase=after_reset the encoder deliberately discards flop initial values (query.cpp:2942-2951, verify twin :5665-5669). A free phase does not just cost speed — 1d's guard would then check at an arbitrary offset, i.e. silently vacuous. Experiment: encode flop_verify_posneg normalized and assert via an explicit solver query that phase == j mod P at every checked j, on a design with no reset input. If free, a documented keep-init exception for the normalizer-owned key is required.
  2. Does lhd sim survive as the oracle at all? Measured on latch_hold_basic from the same lg:: iverilog gives cyc1 q=11, lhd sim gives cyc1 q=0. They agree only at closed ticks — cgen_sim models a latch as an end-of-cycle flop-with-enable while always_latch is truly transparent. Experiment: run 3b's iverilog leg on the un-normalized latch fixtures with visibility-rule sampling. If it cannot be made green there, Validation needs re-scoping before any normalized comparison means anything.
  3. Primary inputs are minted fresh per sub-step (query.cpp:3233, :5970), so at P=2 each source cycle gets two independent input values — a strict superset of source behaviour. Sound for proofs, but it yields CEXes with no source counterpart and weakens env assumes. Experiment: normalize by hand, add a hold-across-period assumption, diff the CEX set.
  4. Gate the LEC miter compare, or not? Condition 3 says gate; the counter-argument is that with identically-normalized sides mid-period equality already holds, so gating monotonically removes compare points and opens a false-PROVEN surface. Experiment: hand-lower latch_hold_basic two ways (data-gated slot vs clock-gated slot) and LEC them ungated. REFUTES ⇒ gating mandatory; PROVES ⇒ skip it.
  5. Reset placement per slot — the one deferred item NOT subsumed. encode.cpp:2282-2289 nests reset outside commits unconditionally, i.e. async for every flop. Harmless for a primary reset held through the prologue; wrong for a design-computed soft reset, which then clears at a slot it does not own. M7's landed ruling answers the latch half: a latch's reset is inherently ASYNCHRONOUS, so a lowered latch's reset goes OUTSIDE the slot gate. Experiment: a negedge flop cleared by a non-primary soft reset, PROVEN against its normalized form and REFUTED against a one-slot-early variant, with iverilog agreeing.
  6. Hierarchy: port-thread the phase counter, or one divider per def? Unresolved. All three gate fixtures are flat, so fail closed on Sub for now. Experiment: latch_icg has an instance — try both.
  7. Naming stability of the synthesized phase register. It must survive canon_flop_name (encode.cpp:52-77) identically on both sides so it lands in shared_inputs instead of the per-side fallback. Never derive it from a NID. Experiment: LEC_DUMP_FLOPS lists the phase key once per side with identical spelling; rename it on one side and confirm the miter REFUTES.

M4 residue

Two of the three clock-blindness holes CLOSED 2026-07-20 (d572ad005), and that code ships — do not touch it:

Gating is applied only where "one step = one commit" is already false. A plain flop whose clock_pin resolves straight to a clock input gets NO gating term and encodes byte-for-byte as before — note the ICG branch DOES fire in a design with just one clock input, via the name fallback. lec_clock_blindness_test.sh is live.

The third hole is CLOSED (2026-07-24, M8). negedge vs posedge on one clock now REFUTES, and lec_clock_blindness_test.sh case 3 is an assertion rather than a note. It was NOT closed by teaching the encoder about edges — that is provably impossible at step granularity — but by slotting, which turns the intra-step ordering difference into an ordinary cross-step one. The retired approaches (two-phase encoding of each BMC step; a tier-1 auto-promotion hook for latch pairs) stay recorded under Traps so they are not re-derived.

M4's own residue — sync-vs-async reset placement — is also CLOSED, and it turned out to be load-bearing rather than a footnote: flop_reset_matrix FALSE-REFUTED against its own round-trip at P=2 until the pass started folding a SYNC reset into the data path (inside the slot gate) and leaving only an ASYNC one on reset_pin. See step 1e.

Gate (met): all three false-PROVEN reproducers return verbatim REFUTED with a vacuity guard beside each; the three BMC twins are green and each mutates to a REFUTED; latch work is gated against lgyosys (lec_latch_polarity_test.sh) and edge work against iverilog (single_edge_four_classes_test.sh, 39 periods plus two negative controls); the no-op proof (3a) holds. The minion trigger scan is the one gate item NOT run — the pass is conditional and skips a design with no latch, no negedge state and one clock net, so the scan is what decides whether a re-baseline is even needed. Run lhd pass single_edge --top <top> lg:<minion> and check for single-edge-applied.

Landed (M0–M8)

Compressed record. The rulings and traps these produced are in Scope, Rules and Traps — this section is provenance only.

Tests (inventory re-verified 2026-07-24, post-M8)

NEW at M8 — the three that carry the whole argument:

LIVE — green today, guard against regression:

GREEN since M8 — the three :type: verify fixtures (they still live in inou/prp/tests/fixme/ only because moving them would renumber every prp-tbd-* target; the fixme TAG is what mattered and it is gone):

Dual-fixture conventions (every sim+BMC twin follows these; they exist because of verified harness traps, not style):

The last "still to write" item is now WRITTEN: inou/prp/tests/verify/latch_negedge_enable.prp is the formal twin of latch_sim_negedge_enable — a transparent latch QUALIFYING a negedge flop's enable, the register-file write-strobe shape, and the one design where both M8 holes interact. After normalization the two sit in DIFFERENT slots of one period, so slot 1's din reads a latch Q that already committed in slot 0. Four obligations PROVEN; each mutates to a verbatim REFUTED, and the c==3 mutant is exactly the value a WRONG slot assignment would produce (13 instead of 12), so the fixture is sensitive to the slot and not just to arithmetic.

One design choice in it is worth reading before writing a similar fixture: the latch there is gated by the CLOCK, not by data. A negedge flop samples its enable AT THE FALL, and a DATA-gated latch is generally still OPEN then — so the flop would sample the latch READING THROUGH, an observation the visibility rule forbids and a point where the commit-at-closing-edge model and real hardware genuinely disagree. A transparent-LOW latch is closed for the whole high half, so the fall lies inside its opaque window. The sim twin gets away with a data gate only because it samples q at end-of-tick and never through the enable. Everything else in the 3a–3h list is landed; see the status table.

NOT caused by this work, but red in the same suite (recorded so nobody attributes them to M8): lhd_sim_args_test, lhd_sim_clock_reset_test and lhd_sim_located_assert_test fail on driver-emission details in inou/prp/prp_sim.cpp (__clk_ratio, a literal operand rendered as Slop instead of a long, and the C++-keyword rejection message). None of the three involves a latch, a negedge or a second clock; prp_sim.cpp was last touched by ef0f79b9d / 17b29f574. One was fixed here because it blocked a latch test: an if-expression used as a VALUE in a test block (acc.d = if clock == 0 { 5 } else { 99 }) fell through to the binary-chain walker and died with "unsupported expression form in test: { 5 }", which took the ICG case of latch_fail_closed_test.sh down with it.

References