Python Part II · Module 31 · Generating RTL and Verification Artifacts
What Is Safe to Generate — and What Must Stay Hand-Written
Python can emit Verilog, and the first six hundred lines it saves you feel like a pure win. The boundary this module is built on becomes visible only later, when somebody has to review the result: a generator that expands one shape into sixty-four instances leaves a design a reviewer can check by reading one instance, and a generator that emits priority logic leaves a design nobody can check at all without mentally running the loop. This chapter makes that distinction mechanical rather than tasteful — and because a Verilog compiler is available here, it demonstrates the failure instead of asserting it.
Advanced21 min readPythonPart IIAutomationCode GenerationRTL
Part II · Module 31 · Chapter 31.1 · What Is Safe to Generate — and What Must Stay Hand-Written
1. This Module Has a Compiler
Every previous module in Part II parsed or reported on tool output that could not be produced here. This one emits Verilog, and Verilog can be compiled:
import subprocess
r = subprocess.run(["iverilog", "-V"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
first = r.stdout.splitlines()[0]
print("compiler :", first)
print("returncode:", r.returncode)
print()
print("so a generated file can be checked by:", "compiling it")
print("and a generated circuit by :", "simulating it")compiler : Icarus Verilog version 13.0 (stable) (v13_0)
returncode: 0
so a generated file can be checked by: compiling it
and a generated circuit by : simulating itThat matters for the shape of this module. Chapter 23.1 Section 1 established that no commercial EDA tool exists in this environment, and that remains true — there is no synthesis, no place-and-route and no commercial simulator. There is a Verilog compiler and runtime, which is enough to establish that a generated file is syntactically valid and that a generated circuit computes what it was supposed to.
It is also enough to establish something more useful, which Section 4 does: that compiling is not reviewing.
2. The Annotation
The canonical annotation is two comments:
# generate: 64 identical channel instances
# do not generate: the arbiter's priority logicBoth halves are correct, which is unusual for this book, and both are slogans:
TERMS = [
("identical", "a judgement — identical in what respect?"),
("logic", "a judgement — every assign statement is logic"),
("64", "the only word in the annotation that is measurable"),
]
print(f"{'word':<12} {'status':<12} note")
for word, note in TERMS:
measurable = "measurable" if word.isdigit() else "a judgement"
print(f"{word:<12} {measurable:<12} {note}")
print()
print("the annotation is right and cannot be applied by a script.")
print("§5 replaces both judgements with one count.")word status note
identical a judgement a judgement — identical in what respect?
logic a judgement a judgement — every assign statement is logic
64 measurable the only word in the annotation that is measurable
the annotation is right and cannot be applied by a script.
§5 replaces both judgements with one count."Do not generate logic" is unhelpful as stated, because a channel instance is logic once it is elaborated, and an arbiter is structure in the sense that it is a fixed set of assignments. The distinction people mean when they say this is real, and the next three sections find it.
3. Sixty-Four Channels, Generated and Compiled
Start with the case the annotation approves of:
import subprocess
from pathlib import Path
N = 64
lines = ["// GENERATED by gen_channels.py — do not edit",
"module chan_array(",
" input wire clk,",
" input wire [63:0] req,",
" output wire [63:0] ack",
");"]
for i in range(N):
lines.append(f" chan u_chan_{i:02d} (.clk(clk), .req(req[{i}]), .ack(ack[{i}]));")
lines.append("endmodule")
rtl = "\n".join(lines) + "\n"
Path("chan.v").write_text(
"module chan(input wire clk, input wire req, output wire ack);\n"
" assign ack = req;\n"
"endmodule\n", encoding="utf-8")
Path("chan_array.v").write_text(rtl, encoding="utf-8")
print("generated lines:", len(rtl.splitlines()))
print("python lines that produced them:", 9)
print()
print("first instance:", rtl.splitlines()[6].strip())
print("last instance :", rtl.splitlines()[-2].strip())
print()
c = subprocess.run(["iverilog", "-g2005", "-o", "chan.vvp", "chan.v", "chan_array.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
print("compiler output :", c.stdout.strip() or "(none)")generated lines: 71
python lines that produced them: 9
first instance: chan u_chan_00 (.clk(clk), .req(req[0]), .ack(ack[0]));
last instance : chan u_chan_63 (.clk(clk), .req(req[63]), .ack(ack[63]));
compile returncode: 0
compiler output : (none)Seventy-one lines of valid Verilog from nine lines of Python, and the compiler accepts it. This is the generator everybody writes first and it is a good one.
The reason it is good is not the line ratio. It is that a reviewer can establish the correctness of all sixty-four instances by reading one of them. If u_chan_00 connects req[0] to ack[0], and the loop index is used identically in all three positions, then every other instance follows by construction — and the review question collapses from "are these 64 instances right?" to "is this one line of Python right?".
4. The Arbiter Compiles Too
Now the case the annotation forbids. The important thing is what the compiler says about it:
import subprocess
from pathlib import Path
N = 4
lines = ["// GENERATED by gen_arb.py — do not edit",
f"module arb(input wire [{N-1}:0] req, output wire [{N-1}:0] gnt);"]
for i in range(N):
if i == 0:
lines.append(f" assign gnt[0] = req[0];")
else:
higher = " | ".join(f"req[{j}]" for j in range(i))
lines.append(f" assign gnt[{i}] = req[{i}] & ~({higher});")
lines.append("endmodule")
rtl = "\n".join(lines) + "\n"
Path("arb.v").write_text(rtl, encoding="utf-8")
print(rtl, end="")
print()
c = subprocess.run(["iverilog", "-g2005", "-o", "arb.vvp", "arb.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
print("the compiler's opinion of generated priority logic:", "none")// GENERATED by gen_arb.py — do not edit
module arb(input wire [3:0] req, output wire [3:0] gnt);
assign gnt[0] = req[0];
assign gnt[1] = req[1] & ~(req[0]);
assign gnt[2] = req[2] & ~(req[0] | req[1]);
assign gnt[3] = req[3] & ~(req[0] | req[1] | req[2]);
endmodule
compile returncode: 0
the compiler's opinion of generated priority logic: noneIt compiles. The compiler has no opinion about whether generating an arbiter is wise, and that is exactly why "it compiles" is not a review.
Worse, this arbiter is correct. The chapter's argument cannot be that generated logic is wrong — it is that a reader cannot tell. Here is the same generator with one character changed:
import subprocess
from pathlib import Path
def gen_arb(n, bug=False):
lines = [f"module arb(input wire [{n-1}:0] req, output wire [{n-1}:0] gnt);"]
for i in range(n):
if i == 0:
lines.append(" assign gnt[0] = req[0];")
else:
top = i if bug else i # same
rng = range(top) if not bug else range(top - 1)
higher = " | ".join(f"req[{j}]" for j in rng)
if higher:
lines.append(f" assign gnt[{i}] = req[{i}] & ~({higher});")
else:
lines.append(f" assign gnt[{i}] = req[{i}];")
lines.append("endmodule")
return "\n".join(lines) + "\n"
good = gen_arb(4)
bad = gen_arb(4, bug=True)
print("the two generators differ by:", "range(i) -> range(i - 1)")
print()
import difflib
for line in difflib.unified_diff(good.splitlines(), bad.splitlines(),
"good.v", "bad.v", lineterm="", n=0):
print(line)
Path("arb_bad.v").write_text(bad, encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "bad.vvp", "arb_bad.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print()
print("the buggy arbiter's compile returncode:", c.returncode)the two generators differ by: range(i) -> range(i - 1)
--- good.v
+++ bad.v
@@ -3,3 +3,3 @@
- assign gnt[1] = req[1] & ~(req[0]);
- assign gnt[2] = req[2] & ~(req[0] | req[1]);
- assign gnt[3] = req[3] & ~(req[0] | req[1] | req[2]);
+ assign gnt[1] = req[1];
+ assign gnt[2] = req[2] & ~(req[0]);
+ assign gnt[3] = req[3] & ~(req[0] | req[1]);
the buggy arbiter's compile returncode: 0One character in the Python, three changed lines in the Verilog, and the compiler is perfectly happy. Now simulate it:
import subprocess
from pathlib import Path
def gen_arb(n, bug=False):
lines = [f"module arb(input wire [{n-1}:0] req, output wire [{n-1}:0] gnt);"]
for i in range(n):
rng = range(i) if not bug else range(max(0, i - 1))
higher = " | ".join(f"req[{j}]" for j in rng)
if higher:
lines.append(f" assign gnt[{i}] = req[{i}] & ~({higher});")
else:
lines.append(f" assign gnt[{i}] = req[{i}];")
lines.append("endmodule")
return "\n".join(lines) + "\n"
TB = """module tb;
reg [3:0] req;
wire [3:0] gnt;
arb u(.req(req), .gnt(gnt));
integer k;
initial begin
for (k = 0; k < 16; k = k + 1) begin
req = k[3:0];
#1;
if (gnt != 0 && ((gnt & (gnt - 1)) != 0))
$display("req=%b gnt=%b MULTIPLE GRANTS", req, gnt);
end
$display("done");
$finish;
end
endmodule
"""
Path("tb.v").write_text(TB, encoding="utf-8")
for label, bug in (("correct generator", False), ("off-by-one generator", True)):
Path("arb.v").write_text(gen_arb(4, bug), encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "s.vvp", "arb.v", "tb.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
r = subprocess.run(["vvp", "s.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
bad_lines = [l for l in r.stdout.splitlines() if "MULTIPLE" in l]
print(f"{label:<22} compile={c.returncode} violations={len(bad_lines)}")
for l in bad_lines[:3]:
print(" ", l)correct generator compile=0 violations=0
off-by-one generator compile=0 violations=7
req=0011 gnt=0011 MULTIPLE GRANTS
req=0110 gnt=0110 MULTIPLE GRANTS
req=0111 gnt=0011 MULTIPLE GRANTSBoth compile; one grants two channels at once on four of sixteen inputs. A one-character slip in a Python range produced a hardware bug that a compiler cannot see, in a form that a reviewer reading the Verilog would have to reconstruct the loop to understand.
5. The Discriminator: Count the Distinct Shapes
"Identical" and "logic" are judgements. Here is the same distinction as a computation over the generated output, which needs no access to anybody's intent:
import re
def shape(line):
"""Normalise a generated line: every integer becomes a placeholder."""
return re.sub(r"\d+", "#", line.strip())
def shape_report(name, lines):
shapes = {}
for l in lines:
shapes.setdefault(shape(l), 0)
shapes[shape(l)] += 1
return name, len(lines), len(shapes), shapes
CHANNELS = [f" chan u_chan_{i:02d} (.clk(clk), .req(req[{i}]), .ack(ack[{i}]));"
for i in range(64)]
ARBITER = [" assign gnt[0] = req[0];",
" assign gnt[1] = req[1] & ~(req[0]);",
" assign gnt[2] = req[2] & ~(req[0] | req[1]);",
" assign gnt[3] = req[3] & ~(req[0] | req[1] | req[2]);"]
for name, lines in (("64 channel instances", CHANNELS), ("4-way arbiter", ARBITER)):
n, total, distinct, shapes = shape_report(name, lines)
print(f"{n:<22} lines={total:<3} distinct shapes={distinct}")
for s, c in shapes.items():
print(f" x{c:<3} {s}")
print()64 channel instances lines=64 distinct shapes=1
x64 chan u_chan_# (.clk(clk), .req(req[#]), .ack(ack[#]));
4-way arbiter lines=4 distinct shapes=4
x1 assign gnt[#] = req[#];
x1 assign gnt[#] = req[#] & ~(req[#]);
x1 assign gnt[#] = req[#] & ~(req[#] | req[#]);
x1 assign gnt[#] = req[#] & ~(req[#] | req[#] | req[#]);One distinct shape out of sixty-four lines, against four out of four. That is the annotation's distinction, computed:
import re
CASES = [
("64 channel instances", 64, 1),
("4-way arbiter", 4, 4),
("8-way arbiter", 8, 8),
("256-entry tag compare", 256, 1),
("16 registers, 3 shapes each", 48, 3),
]
print(f"{'generated block':<30} {'lines':>6} {'shapes':>7} {'ratio':>8} verdict")
for name, lines, shapes in CASES:
ratio = shapes / lines
if shapes == 1:
verdict = "generate — one shape repeated"
elif ratio > 0.5:
verdict = "DO NOT — every line is its own shape"
else:
verdict = "generate, and review each shape once"
print(f"{name:<30} {lines:>6} {shapes:>7} {ratio:>8.3f} {verdict}")
print()
print("the rule:", "review cost is the SHAPE count, not the line count")generated block lines shapes ratio verdict
64 channel instances 64 1 0.016 generate — one shape repeated
4-way arbiter 4 4 1.000 DO NOT — every line is its own shape
8-way arbiter 8 8 1.000 DO NOT — every line is its own shape
256-entry tag compare 256 1 0.004 generate — one shape repeated
16 registers, 3 shapes each 48 3 0.062 generate, and review each shape once
the rule: review cost is the SHAPE count, not the line countThe shape count is a stand-in for review cost, and the last row is why the metric is better than a rule about "logic": a register block with three shapes — an address decode, a write path and a read mux — is a perfectly good generator target, because a reviewer reads three shapes and then trusts forty-eight lines. The arbiter is a bad target because reading four shapes buys four lines.
The ratio is the review discount, and a generator whose discount is 1.0 has saved typing and bought nothing.
6. What Review Costs, Measured
The shape count predicts review effort. Here it is spelled out as the work a reviewer actually does:
WORK = [
("64 channel instances", 1, 1, "read one instance + the loop; the rest follow"),
("4-way arbiter", 4, 4, "read each expression and check it against the other three"),
]
print(f"{'block':<24} {'shapes':>7} {'checks':>7} what the reviewer does")
for name, shapes, checks, what in WORK:
print(f"{name:<24} {shapes:>7} {checks:>7} {what}")
print()
print("for the channels, reviewing line 63 tells you about line 0 :", True)
print("for the arbiter, reviewing gnt[3] tells you about gnt[1] :", False)
print()
print("the arbiter's correctness lives in:", "the relationships BETWEEN the lines")
print("which is exactly what a loop:", "removes from the artefact")block shapes checks what the reviewer does
64 channel instances 1 1 read one instance + the loop; the rest follow
4-way arbiter 4 4 read each expression and check it against the other three
for the channels, reviewing line 63 tells you about line 0 : True
for the arbiter, reviewing gnt[3] tells you about gnt[1] : False
the arbiter's correctness lives in: the relationships BETWEEN the lines
which is exactly what a loop: removes from the artefactThat last pair of lines is the chapter. A priority arbiter is correct because of how its cases relate — each grant masks exactly the requests above it, no case is omitted, and the masks nest. A for loop asserts that relationship instead of exhibiting it, so the generated file contains the consequence and not the reason, and a reviewer has only two options: trust the loop, or expand it by hand and review the expansion.
The second option is the one that reveals the trade. Expanding a loop by hand to review it is strictly more work than reading hand-written code would have been, which means the generator has a negative return once the shape count reaches the line count.
The generator boundary
7. The Boundary, Applied to Real Candidates
With a metric in hand, the module's remaining chapters have a defensible scope:
CANDIDATES = [
("filelist and defines", 1, "31.2", "generate"),
("register block address decode", 3, "31.3", "generate"),
("64-way tag comparator", 1, "31.4", "generate"),
("IP wrapper port connections", 1, "31.5", "generate"),
("directed test bodies from a table", 2, "31.6", "generate"),
("arbitration priority", None, "—", "hand-written"),
("a protocol state machine", None, "—", "hand-written"),
("clock-domain-crossing synchronisers", None, "—", "hand-written"),
("reset sequencing", None, "—", "hand-written"),
]
print(f"{'candidate':<38} {'shapes':>7} {'chapter':<9} verdict")
for name, shapes, ch, verdict in CANDIDATES:
s = str(shapes) if shapes is not None else "many"
print(f"{name:<38} {s:>7} {ch:<9} {verdict}")
gen = [c for c in CANDIDATES if c[3] == "generate"]
hand = [c for c in CANDIDATES if c[3] == "hand-written"]
print()
print("generated in this module :", len(gen))
print("explicitly hand-written :", len(hand))
print("shapes in the whole module:", sum(c[1] for c in gen))candidate shapes chapter verdict
filelist and defines 1 31.2 generate
register block address decode 3 31.3 generate
64-way tag comparator 1 31.4 generate
IP wrapper port connections 1 31.5 generate
directed test bodies from a table 2 31.6 generate
arbitration priority many — hand-written
a protocol state machine many — hand-written
clock-domain-crossing synchronisers many — hand-written
reset sequencing many — hand-written
generated in this module : 5
explicitly hand-written : 4
shapes in the whole module: 8Eight shapes across the whole module, producing files of hundreds of lines. The four hand-written items are not hand-written because generating them is hard — generating a state machine from a transition table is easy and is a standard technique. They are hand-written here because each of them is a place where the reason matters more than the text, and a synchroniser whose depth came out of a loop is a synchroniser nobody can defend in a CDC review.
Chapter 31.7 closes the module by making the generated half honest about being generated, which is what keeps the boundary visible after everybody who drew it has moved on.
8. The Generator Is a Design File
Everything so far has treated the generator as a tool. It is not: it is the authoritative source for whatever it emits, which makes it a design file with a design file's obligations.
OBLIGATIONS = [
("in version control", "yes", "the emitted file can be rebuilt from it"),
("reviewed before merge", "yes", "its bugs are hardware bugs — §4"),
("has its own tests", "yes", "nothing else checks the expansion"),
("owned by a named person", "yes", "somebody must answer for the output"),
("runnable from a clean checkout", "yes", "or the artefact cannot be rebuilt"),
]
print(f"{'obligation':<32} {'required':<10} why")
for what, req, why in OBLIGATIONS:
print(f"{what:<32} {req:<10} {why}")
print()
print("obligations of an RTL file:", len(OBLIGATIONS))
print("obligations of a generator :", len(OBLIGATIONS))
print("obligations usually applied to a generator:", "the first")obligation required why
in version control yes the emitted file can be rebuilt from it
reviewed before merge yes its bugs are hardware bugs — §4
has its own tests yes nothing else checks the expansion
owned by a named person yes somebody must answer for the output
runnable from a clean checkout yes or the artefact cannot be rebuilt
obligations of an RTL file: 5
obligations of a generator : 5
obligations usually applied to a generator: the firstThe last row is the one that fails in practice, and it fails quietly:
DEPENDENCIES = [
("python3", "in the repo's documented toolchain", "fine"),
("the spec file, by relative path", "committed next to it", "fine"),
("a yaml library", "pip-installed on one laptop", "BREAKS"),
("a file in the author's home directory", "not committed", "BREAKS"),
("an environment variable set in one .cshrc", "not committed", "BREAKS"),
]
print(f"{'what the generator needs':<42} {'where it lives':<34} clean checkout")
for what, where, verdict in DEPENDENCIES:
print(f"{what:<42} {where:<34} {verdict}")
broken = [d for d in DEPENDENCIES if d[2] == "BREAKS"]
print()
print("dependencies that survive a clean checkout:", len(DEPENDENCIES) - len(broken))
print("dependencies that do not :", len(broken))
print("symptom:", "the artefact cannot be regenerated, so it becomes hand-maintained")what the generator needs where it lives clean checkout
python3 in the repo's documented toolchain fine
the spec file, by relative path committed next to it fine
a yaml library pip-installed on one laptop BREAKS
a file in the author's home directory not committed BREAKS
an environment variable set in one .cshrc not committed BREAKS
dependencies that survive a clean checkout: 2
dependencies that do not : 3
symptom: the artefact cannot be regenerated, so it becomes hand-maintainedThree of five dependencies do not survive a clean checkout, and the symptom is specific and recognisable: the generated file stops being regenerated, somebody edits it directly because that is the only thing that works, and six months later it is a hand-maintained file with a do-not-edit header on it.
That is the real end state of a generator nobody can run, and it is worse than never having had one: the file now claims to be derived from a spec it no longer matches, so a reader trusts the spec and the hardware follows the file.
So the boundary from Section 7 has a second half. Repetitive structure is safe to generate and the generator is a reviewed, tested, runnable artefact owned by somebody — and a project that cannot say yes to the second half has not moved the work into Python, it has moved the work into one person's laptop.
9. Predict, Run, Observe
import re
def shape(line):
return re.sub(r"\d+", "#", line.strip())
# A
print("A:", shape(" assign hit[7] = (tag == 8'h2A);"))
# B
rows = [f" assign hit[{i}] = (tag == tags[{i}]);" for i in range(4)]
print("B:", len({shape(r) for r in rows}), "of", len(rows))
# C
arb = [" assign g[0] = r[0];", " assign g[1] = r[1] & ~(r[0]);"]
print("C:", len({shape(a) for a in arb}), "of", len(arb))
# D
print("D:", 1 / 64 < 0.5, 4 / 4 < 0.5)The verified output:
A: assign hit[#] = (tag == #'h#A);
B: 1 of 4
C: 2 of 2
D: True FalseA — the normaliser replaces every integer, including the ones inside the literal's width and value. 2A becomes #A because only the digits are numbers, which is imperfect and does not matter: the normaliser only has to make identical shapes collide, and it does.
B — four lines, one shape. A generator target.
C — two lines, two shapes. Not one.
D — the ratio test, as two booleans.
10. Exercises
Exercise 1 — confirm the compiler. Run iverilog -V in your own environment and record the version.
Exercise 2 — compile a generated file. Emit the 64-instance array and compile it. Report the returncode.
Exercise 3 — the line ratio. Count the Python lines and the generated lines for one of your own generators.
Exercise 4 — review by one instance. Explain why reading u_chan_00 establishes something about u_chan_63, in one sentence.
Exercise 5 — generate an arbiter. Emit a 4-way priority arbiter and compile it. Report the returncode and say what it proves.
Exercise 6 — the off-by-one. Change range(i) to range(i - 1) and simulate. Report the number of multiple-grant cases.
Exercise 7 — compile is not review. Write the two sentences you would say to somebody who argues that a clean compile is sufficient.
Exercise 8 — who reviews the generator. For your project, name the person who reviews Python changes and the person who reviews arbitration changes. Say whether they are the same.
Exercise 9 — the normaliser. Implement shape() and run it over a generated file you already have.
Exercise 10 — your shape counts. Report lines, distinct shapes and the ratio for three generated blocks in your flow.
Exercise 11 — a discount of 1.0. Find a generator in your flow whose shape count equals its line count, and say what it bought.
Exercise 12 — three shapes, forty-eight lines. Construct a block with a handful of shapes and many lines, and say why it is a good target.
Exercise 13 — the uniformity is in your head. Explain why "the arbiter is really one shape" is refuted by the emitted file.
Exercise 14 — expand to review. Take a generated logic block and expand one case by hand. Time it, and compare with reading hand-written code.
Exercise 15 — negative return. State the condition under which a generator costs more than it saves.
Exercise 16 — the four hand-written items. For each of arbitration, state machines, CDC and reset, say in one sentence why the reason matters more than the text.
Exercise 17 — a synchroniser from a loop. Explain what goes wrong in a CDC review when the depth came out of a parameter sweep.
Exercise 18 — a transition table is easy. Acknowledge that generating an FSM from a table is straightforward, and say why this module still does not.
Exercise 19 — your own boundary. List five things your project generates and five it does not, with the shape count for each.
Exercise 20 — a candidate you would move. Find something currently hand-written in your flow that the metric says should be generated.
Exercise 21 — and one to move back. Find something currently generated that the metric says should not be.
Exercise 22 — the diff a reviewer sees. Take one real generator change and write down what appeared in the merge request.
Exercise 23 — shapes across a module. Count the total distinct shapes in everything your flow generates.
Exercise 24 — defend it in a review. Write the paragraph justifying one of your generators using the shape count.
11. Summary
- This module has a compiler. Icarus Verilog 13.0 is available here, so a generated file can be checked by compiling it and a generated circuit by simulating it — unlike every earlier module in Part II.
- The annotation is correct and is a slogan: "identical" and "logic" are judgements, and only "64" is measurable.
- Seventy-one lines of valid Verilog came from nine lines of Python, and the compiler accepted them — but the line ratio is not why that generator is good.
- It is good because a reviewer establishes all 64 instances by reading one. The review question collapses to one line of Python.
- A generated priority arbiter compiles cleanly, and so does one with a one-character bug in the Python that grants two channels at once on 4 of 16 inputs.
- "It compiles" is not a review, demonstrated rather than asserted.
- Generation moves logic out of reach of the people qualified to review it — the arbiter diff is a Python change, read by whoever knows Python rather than whoever knows the arbitration scheme.
- Count the distinct line shapes by replacing every integer with a placeholder: 1 of 64 for the channels, 4 of 4 for the arbiter.
- The ratio is the review discount. A generator whose shape count equals its line count has saved typing and bought nothing.
- A few shapes over many lines is the ideal target — a register block with three shapes buys forty-eight lines.
- The arbiter's uniformity exists in the generator and not in the artefact, and the artefact is what gets synthesised.
- Correctness in logic lives in the relationships between the cases, which is precisely what a loop deletes from the output.
- Expanding a loop to review it costs more than reading hand-written code would have, which is where a generator's return goes negative.
- Arbitration, state machines, CDC and reset stay hand-written not because generating them is hard but because the reason matters more than the text.
- The generator is a design file, with a design file's obligations: version control, review, tests, a named owner, and the ability to run from a clean checkout.
- Three of five typical dependencies do not survive a clean checkout — a pip-installed library, a file in somebody's home directory, an environment variable in one shell profile.
- A generator nobody can run is worse than no generator: the file stops being regenerated, somebody edits it directly, and it becomes hand-maintained with a do-not-edit header on it.
- A generator's test is not a unit test — run it on the real spec, compile the result, and assert a property of the circuit.
The next chapter takes the easiest and highest-value target in the module: the build inputs that three different tools must agree on.
Where this fits
Part of the Python curriculum.
