Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutRoadmapCareersInternshipsFAQContactPrivacyTerms
Skip to lesson

Course contents

  • How to Use This Course
  • Reading Paths
  • Glossary
  • Metrics Reference
  • Formal Cheat Sheet
  • Formal Foundations19
  • Properties and SVA16
  • Property Development and Constraints19
  • Proof Engines and Convergence23
  • Formal Applications20
  • Equivalence Checking14
  • Formal Coverage and Signoff15
  • Methodology and Debug13
  • Interview Prep15
  • Capstone: A Chip-Block Verification Lab24
    • The Brief
    • Stage 1: FIFO and Handshake
    • Stage 2: Registers and Arbiter
    • Stage 3: Pipeline and Equivalence
      • Stage 3 Pipeline Latency Properties
      • Stage 3 Retimed Twin Equivalence
      • Stage 3 ECO and the Intended Difference
    • Stage 4: Induction and Signoff
    • Solutions
    • Appendix: Run It Yourself on an Open-Source Flow
Loading lesson

←Stage 3: Pipeline and Equivalence
  1. Topics
  2. Formal Verification
  3. Capstone: A Chip-Block Verification Lab
  4. Stage 3: Pipeline and Equivalence
  5. Stage 3 Pipeline Latency Properties
On this page

    Part 10 of 10, lesson 9 of 24Part 10 of 10/Capstone: A Chip-Block Verification Lab/Lesson 9 of 24All levels

    Stage 3 Pipeline Latency Properties

    Prove the latency-3 result and the reset rule, find the smallest induction window, and write mutants that the properties must kill.

    Why it matters

    The symptom: results arrive correct but a cycle early or late, so the consumer samples the wrong answer. Everything looks right in isolation and wrong in the system. Latency is a property of the design plus its history, not of one cycle, which is why the property keeps three cycles of input history beside the datapath and checks the result only once the pipe is full.

    Inputs

    • The pipeline module of the listing (pipe3), with its two properties: the result after three clean cycles equals the function of the inputs three cycles old, and the output is zero in the cycle after reset. A cover reaches a non-zero result.

    • The function: y = ((a + b)[3:0] * c[3:0]) + 1, eight bits. Only the low nibbles of a, b and c matter.

    • The model below, which reproduces the recorded behaviour of the design.

    pipe3.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    // formal: prove 4 expect pass slang top pipe3
    `define FORMAL
    // pipe3: three-stage arithmetic pipeline, y = ((a + b)[3:0] * c[3:0]) + 1 (8 bits). Registers: stage 1
    // {s = a + b, c1 = c[3:0]}, stage 2 {p = s[3:0] * c1}, stage 3 {y = p + 1}. Latency 3 cycles. Synchronous
    // active-high reset clears every register. Only a[3:0], b[3:0], c[3:0] influence y.
    // pipe3_retimed: the same function and latency with the registers moved across the multiplier: stage 2
    // {s2 = s[3:0], c2 = c1}, stage 3 {y = s2 * c2 + 1}. BUG selects a variant of the twin.
    // pipe3_eq: a miter of pipe3 against pipe3_retimed with common inputs and reset; BUG goes to the twin.
    module pipe3 #(parameter CHECK = 1) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output reg  [7:0] y,
      output wire [7:0] dbg_p          // stage-2 register, exposed for the miter's helper assertion
    );
      reg [7:0] s;
      reg [3:0] c1;
      reg [7:0] p;
      assign dbg_p = p;
      always @(posedge clk) begin
        if (rst) begin s <= 8'd0; c1 <= 4'd0; p <= 8'd0; y <= 8'd0; end
        else begin
          s  <= a + b;
          c1 <= c[3:0];
          p  <= s[3:0] * c1;
          y  <= p + 8'd1;
        end
      end
    
    `ifdef FORMAL
      // CHECK=0 switches the properties off (used when pipe3 sits inside the miter)
      generate if (CHECK) begin : g_chk
      reg init = 1'b1;
      always @(posedge clk) init <= 1'b0;
      always @(*) if (init) assume (rst);
    
      // hist counts clean cycles since reset (saturates at 3): y is only specified after 3
      reg [1:0] hist;
      always @(posedge clk) begin
        if (rst) hist <= 2'd0;
        else if (hist != 2'd3) hist <= hist + 2'd1;
      end
    
      // input history as plain registers
      reg [3:0] sa1, sb1, sc1, sa2, sb2, sc2, sa3, sb3, sc3;
      always @(posedge clk) begin
        sa1 <= a[3:0]; sb1 <= b[3:0]; sc1 <= c[3:0];
        sa2 <= sa1;    sb2 <= sb1;    sc2 <= sc1;
        sa3 <= sa2;    sb3 <= sb2;    sc3 <= sc2;
      end
      wire [3:0] sum3 = sa3 + sb3;       // low 4 bits of a + b three cycles ago
    
      // functional property with latency 3, checked only after 3 clean cycles
      always @(*) if (!init && hist == 2'd3) a_latency3: assert (y == (sum3 * sc3) + 8'd1);
      // the cycle after a reset the output register is 0
      always @(posedge clk) if (!init && $past(rst)) a_reset_zero: assert (y == 8'd0);
      always @(*) if (!init) c_y_nonzero: cover (hist == 2'd3 && y == 8'd7);
      end endgenerate
    `endif
    endmodule
    
    module pipe3_retimed #(parameter BUG = 0) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output reg  [7:0] y,
      output wire [3:0] dbg_s2,        // stage-2 registers, exposed for the miter's helper assertion
      output wire [3:0] dbg_c2
    );
      reg [7:0] s;
      reg [3:0] c1;
      reg [3:0] s2, c2;
      assign dbg_s2 = s2;
      assign dbg_c2 = c2;
      always @(posedge clk) begin
        if (rst) begin s <= 8'd0; c1 <= 4'd0; s2 <= 4'd0; c2 <= 4'd0; y <= 8'd0; end
        else begin
          s  <= a + b;
          c1 <= c[3:0];
          s2 <= s[3:0];
          c2 <= c1;
          y  <= s2 * ((BUG == 1) ? {1'b0, c2[2:0]} : c2) + 8'd1;
        end
      end
    endmodule
    
    module pipe3_eq #(parameter BUG = 0, parameter HELPER = 1) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output wire       same
    );
      wire [7:0] y1, y2, p_ref;
      wire [3:0] s2_ret, c2_ret;
      pipe3 #(.CHECK(0))     u_ref (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .y(y1), .dbg_p(p_ref));
      pipe3_retimed #(.BUG(BUG)) u_ret (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .y(y2), .dbg_s2(s2_ret), .dbg_c2(c2_ret));
      assign same = (y1 == y2);
    
    `ifdef FORMAL
      reg init = 1'b1;
      always @(posedge clk) init <= 1'b0;
      always @(*) if (init) assume (rst);
      always @(*) if (!init) a_equiv: assert (same);
      // helper assertion across the moved register cut: the reference's stage-2 register equals the
      // twin's stage-2 registers multiplied
      generate if (HELPER) begin : g_helper
        always @(*) if (!init) h_cut: assert (p_ref == s2_ret * c2_ret);
      end endgenerate
      always @(*) if (!init) c_nonzero: cover (y1 == 8'd7 && y2 == 8'd7);
    `endif
    endmodule

    Expected outcome: proved. pipe3 has no valid signal, so the valid-propagation property that a handshaking pipeline would carry is not available here; the latency and reset properties are the whole property set.

    pipe3.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    // formal: cover 5 expect reached slang top pipe3
    `define FORMAL
    // pipe3: three-stage arithmetic pipeline, y = ((a + b)[3:0] * c[3:0]) + 1 (8 bits). Registers: stage 1
    // {s = a + b, c1 = c[3:0]}, stage 2 {p = s[3:0] * c1}, stage 3 {y = p + 1}. Latency 3 cycles. Synchronous
    // active-high reset clears every register. Only a[3:0], b[3:0], c[3:0] influence y.
    // pipe3_retimed: the same function and latency with the registers moved across the multiplier: stage 2
    // {s2 = s[3:0], c2 = c1}, stage 3 {y = s2 * c2 + 1}. BUG selects a variant of the twin.
    // pipe3_eq: a miter of pipe3 against pipe3_retimed with common inputs and reset; BUG goes to the twin.
    module pipe3 #(parameter CHECK = 1) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output reg  [7:0] y,
      output wire [7:0] dbg_p          // stage-2 register, exposed for the miter's helper assertion
    );
      reg [7:0] s;
      reg [3:0] c1;
      reg [7:0] p;
      assign dbg_p = p;
      always @(posedge clk) begin
        if (rst) begin s <= 8'd0; c1 <= 4'd0; p <= 8'd0; y <= 8'd0; end
        else begin
          s  <= a + b;
          c1 <= c[3:0];
          p  <= s[3:0] * c1;
          y  <= p + 8'd1;
        end
      end
    
    `ifdef FORMAL
      // CHECK=0 switches the properties off (used when pipe3 sits inside the miter)
      generate if (CHECK) begin : g_chk
      reg init = 1'b1;
      always @(posedge clk) init <= 1'b0;
      always @(*) if (init) assume (rst);
    
      // hist counts clean cycles since reset (saturates at 3): y is only specified after 3
      reg [1:0] hist;
      always @(posedge clk) begin
        if (rst) hist <= 2'd0;
        else if (hist != 2'd3) hist <= hist + 2'd1;
      end
    
      // input history as plain registers
      reg [3:0] sa1, sb1, sc1, sa2, sb2, sc2, sa3, sb3, sc3;
      always @(posedge clk) begin
        sa1 <= a[3:0]; sb1 <= b[3:0]; sc1 <= c[3:0];
        sa2 <= sa1;    sb2 <= sb1;    sc2 <= sc1;
        sa3 <= sa2;    sb3 <= sb2;    sc3 <= sc2;
      end
      wire [3:0] sum3 = sa3 + sb3;       // low 4 bits of a + b three cycles ago
    
      // functional property with latency 3, checked only after 3 clean cycles
      always @(*) if (!init && hist == 2'd3) a_latency3: assert (y == (sum3 * sc3) + 8'd1);
      // the cycle after a reset the output register is 0
      always @(posedge clk) if (!init && $past(rst)) a_reset_zero: assert (y == 8'd0);
      always @(*) if (!init) c_y_nonzero: cover (hist == 2'd3 && y == 8'd7);
      end endgenerate
    `endif
    endmodule
    
    module pipe3_retimed #(parameter BUG = 0) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output reg  [7:0] y,
      output wire [3:0] dbg_s2,        // stage-2 registers, exposed for the miter's helper assertion
      output wire [3:0] dbg_c2
    );
      reg [7:0] s;
      reg [3:0] c1;
      reg [3:0] s2, c2;
      assign dbg_s2 = s2;
      assign dbg_c2 = c2;
      always @(posedge clk) begin
        if (rst) begin s <= 8'd0; c1 <= 4'd0; s2 <= 4'd0; c2 <= 4'd0; y <= 8'd0; end
        else begin
          s  <= a + b;
          c1 <= c[3:0];
          s2 <= s[3:0];
          c2 <= c1;
          y  <= s2 * ((BUG == 1) ? {1'b0, c2[2:0]} : c2) + 8'd1;
        end
      end
    endmodule
    
    module pipe3_eq #(parameter BUG = 0, parameter HELPER = 1) (
      input  wire       clk,
      input  wire       rst,
      input  wire [7:0] a,
      input  wire [7:0] b,
      input  wire [7:0] c,
      output wire       same
    );
      wire [7:0] y1, y2, p_ref;
      wire [3:0] s2_ret, c2_ret;
      pipe3 #(.CHECK(0))     u_ref (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .y(y1), .dbg_p(p_ref));
      pipe3_retimed #(.BUG(BUG)) u_ret (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .y(y2), .dbg_s2(s2_ret), .dbg_c2(c2_ret));
      assign same = (y1 == y2);
    
    `ifdef FORMAL
      reg init = 1'b1;
      always @(posedge clk) init <= 1'b0;
      always @(*) if (init) assume (rst);
      always @(*) if (!init) a_equiv: assert (same);
      // helper assertion across the moved register cut: the reference's stage-2 register equals the
      // twin's stage-2 registers multiplied
      generate if (HELPER) begin : g_helper
        always @(*) if (!init) h_cut: assert (p_ref == s2_ret * c2_ret);
      end endgenerate
      always @(*) if (!init) c_nonzero: cover (y1 == 8'd7 && y2 == 8'd7);
    `endif
    endmodule
    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    def pipe(inputs, tap="y"):
        s = c1 = p = y = 0                  # every register is reset to 0
        seen = []
        for a, b, c in inputs:
            seen.append({"y": y, "p": p}[tap])          # the output as seen during this cycle
            y, p, s, c1 = p + 1, (s & 15) * c1, (a + b) & 255, c & 15   # all registers update from their old values
        return seen
    def spec(a, b, c):
        return ((a + b) & 15) * (c & 15) + 1
    INPUTS = [(3, 4, 5), (10, 5, 9)] + [(0, 0, 0)] * 6
    assert pipe(INPUTS)[:5] == [0, 1, 1, 36, 136], pipe(INPUTS)[:5]   # the recorded example: results at cycles 4 and 5
    def latency_ok(inputs, tap="y", latency=3):
        seen = pipe(inputs, tap)
        return all(seen[t + latency] == spec(*inputs[t]) for t in range(len(inputs) - latency))
    assert latency_ok(INPUTS)
    assert not latency_ok(INPUTS, tap="p"), "the same check refuses an output taken one stage early"
    assert latency_ok(INPUTS, tap="p", latency=2) is False, "and it does not become right by guessing the latency"
    print("PASS: the model reproduces the recorded results 36 and 136 and refuses an early tap")

    Deliverable

    • The latency and reset properties proved, with the smallest induction window found by sweeping the proof depth. Say what a window shorter than the latency cannot see.

    • The first step at which a non-zero result appears, from the cover, and the bound that follows from it.

    • At least two mutants of your own, one touching the output stage and one touching the reset, each killed by a named property and at a depth you report.

    • The reason no valid-propagation row exists for this design.

    • Rows: pipe3-latency, pipe3-reset-zero, pipe3-cover.

    Pass criterion

    • The good design is proved and the cover reached at the step you wrote down.

    • The inconclusive result below the smallest window is filed as inconclusive, not as a fault.

    • Each of your mutants gives a counterexample, and the package check passes.

    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    # ---- formal lab results ledger, engine v2: every row cites a typed run (kind, depth, outcome[, design]) ----
    STATUS = ("proven", "bounded", "cex", "cover-reached", "inconclusive")
    FROM_RUN = {"proven": {("prove", "proved")},
                "bounded": {("bmc", "bounded")},
                "cex": {("bmc", "counterexample"), ("prove", "counterexample")},
                "cover-reached": {("cover", "reached")},
                "inconclusive": {("prove", "unproven"), ("cover", "not-reached")}}
    NEED = {"sync_fifo": 9, "rr_arbiter": 5, "hs_bridge": 4, "csr_block": 3,
            "pipe3": 5, "pipe3_eq": 5, "mod10_counter": 15, "toggle_demo": 4}
    KEYS = ("property", "design", "status", "bound", "why", "run", "assumes", "cover", "kills", "rev")
    REV = 3
    ALWAYS = "reset-at-step-0"
    TRACKED = "tracked-constant-stable"
    def row_problems(e, runs, tracked):
        n = e.get("property", "?")
        miss = [k for k in KEYS if k not in e]
        if miss:
            return ["missing-field:%s:%s" % (n, ",".join(miss))]
        if e["status"] not in STATUS:
            return ["bad-status:" + n]
        p = []
        if e["rev"] != REV:
            p.append("stale:" + n)
        if e["run"] not in runs:
            return p + ["no-such-run:" + n]
        kind, depth, outcome = runs[e["run"]][:3]
        if len(runs[e["run"]]) > 3 and runs[e["run"]][3] != e["design"]:
            p.append("run-of-another-design:" + n)         # a run typed with its design may be cited only by that design's rows
        if (kind, outcome) not in FROM_RUN[e["status"]]:
            p.append("status-not-from-run:" + n)
        if e["bound"] != depth:
            p.append("bound-not-run-depth:" + n)
        has_cover = bool(str(e["cover"]).strip())
        if e["status"] in ("proven", "bounded") and not (has_cover and e["kills"]):
            p.append("no-evidence:" + n)
        if e["status"] == "cover-reached" and not has_cover:
            p.append("no-evidence:" + n)
        if e["status"] == "cex" and not e["kills"]:
            p.append("no-evidence:" + n)
        if e["status"] == "bounded":
            if e["design"] not in NEED:
                p.append("unknown-design:" + n)
            elif depth < NEED[e["design"]]:
                p.append("short-bound:" + n)
            if len(str(e["why"]).strip()) < 12:
                p.append("unjustified:" + n)
        if ALWAYS not in e["assumes"]:
            p.append("hidden-assume:%s:%s" % (n, ALWAYS))
        if n in tracked and TRACKED not in e["assumes"]:
            p.append("hidden-assume:%s:%s" % (n, TRACKED))
        return p
    def ledger_ok(ledger, runs, required, mutants, register, tracked, final=True):
        names = [e.get("property") for e in ledger]
        bad = ["duplicate:" + n for n in sorted({n for n in names if names.count(n) > 1})]
        bad += ["missing-row:" + r for r in sorted(set(required) - set(names))]
        bad += [q for e in ledger for q in row_problems(e, runs, tracked)]
        good = [e for e in ledger if not row_problems(e, runs, tracked)]      # only a row without complaints counts as evidence
        solid = {e["property"] for e in good if e["status"] in ("proven", "cover-reached")}
        for a, rows in sorted(register.items()):
            if rows is None:
                if final:
                    bad.append("deferred-assume:" + a)      # a stage may defer a discharge; a final package may not
            elif not (rows and all(r in solid for r in rows)):
                bad.append("open-assume:" + a)
        bad += ["unregistered-assume:%s:%s" % (e.get("property"), a) for e in ledger
                for a in e.get("assumes", []) if a not in register]
        killed = {m for e in good if e["status"] == "cex" for m in e["kills"]}
        bad += ["survivor:" + m for m in sorted(set(mutants) - killed)]
        bad += ["unsupported-kill:%s:%s" % (e.get("property"), m) for e in ledger
                if e.get("status") in ("proven", "bounded") for m in e.get("kills", []) if m not in killed]
        return bad
    def mutation_score(ledger, mutants):
        assert mutants, "no mutants registered"
        killed = {m for e in ledger if e.get("status") == "cex" for m in e.get("kills", [])}
        return len(killed & set(mutants)) / len(mutants)
    
    RUNS = {
        "pipe-prove": ("prove", 4, "proved"),
        "pipe-cover": ("cover", 5, "reached"),
    }
    REQUIRED = ["pipe3-latency", "pipe3-reset-zero", "pipe3-cover"]
    REGISTER = {"reset-at-step-0": None}                 # discharged by the pair rows of stage 2
    YOUR_ROWS = []          # fill from your own runs
    bad = ledger_ok(YOUR_ROWS, RUNS, REQUIRED, [], REGISTER, set(), final=False)
    if YOUR_ROWS:
        assert bad == [], bad
        print("PASS: stage 3 pipeline package consistent")
    else:
        assert sorted(bad) == sorted(["missing-row:" + r for r in REQUIRED]), bad
        print("PASS: the empty package is rejected with %d complaints" % len(bad))

    Hint ladder

    • Hint 1: sweep the proof depth from 1 upwards and note where the induction step first passes.

    • Hint 2: the property reads inputs from three cycles back; ask what a step window of two knows about them.

    • Hint 3: for a mutant, change one term of the output stage, then ask which property mentions that term.

    • Hint 4: a reset mutant is noticed only in the cycle after reset; check that the property you expect to catch it samples that cycle.

    Verify and debug

    When the latency proof fails on the good design, check the history-valid counter first: checking before the pipe is full compares against fill values. When the induction stalls, count how far back the property reads and give the step at least that window. Keep the cover step beside the depth so the minimum never becomes folklore.

    Latency mismatch between a reference and a revision: the miter fails everywhere, or passes when aligned by a wrong delay. Symptom is disagreement on every cycle. Cause is comparing without a documented alignment. Fix: state the latency, align with it, and assert it in a separate property, which is what this lesson files before any miter runs.

    “Prove this pipeline meets its latency.” Answer: keep input history for the latency depth, gate the check on a full-pipe counter, prove the reset rule separately, and show the induction needs the full window. Quote the smallest window you measured and the step of the first result.

    Check yourself A bounded check on the pipeline passes but the proof is inconclusive at a short depth. What single fact explains it?

    The property reads three cycles of history and the step window is shorter: the step starts from states with inconsistent history, so it fails while the design is fine. The window must cover what the property reads.

    Check yourself Why does the ledger refuse a bounded row at depth 2 for this design?

    The depth is below the minimum of 5, which comes from the first result at step 4, and it carries no cover reason. A run that short cannot see a result at all.

    Related topics

    • Next: Stage 3 Retimed Twin Equivalence

    • Earlier: Sequential Equivalence

    • After you have filed your rows: Solutions, Pipeline, Twin and ECO

    Previous

    168 / 183

    Next: Stage 3 Retimed Twin Equivalence

    Up next

    Up nextStage 3 Retimed Twin Equivalence

    Sign in to save progress

    Practice this lessonPrevious lessonReport an error

    See also

    • Stage 3 ECO and the Intended Difference