awk Part IV · Module 14 · AWK Debugging, Review, and Engineering Mastery
Interview Problems: Intermediate
These are the problems where awk starts needing memory: group and aggregate, join two files, carry a section heading down its rows, track a running worst case. The one-liners are longer but still short, and the follow-ups stop being about syntax — they ask what happens when the key file is empty, when a section marker is missing, when the aggregation you chose cancels out the thing you were looking for. Three of the eight follow-ups here produce a confidently wrong answer that no total and no record count detects.
Intermediate22 min readawkPart IVInterviewArraysState
Part IV · Module 14 · Chapter 14.8 · Interview Problems: Intermediate
1. The Fixtures
cat > paths.rpt <<'EOF'
clk_core setup u_alu/sum_reg -0.018
clk_core hold u_alu/out_reg 0.067
clk_ddr setup u_mem/dq_reg -0.112
clk_core setup u_ctrl/st_reg -0.041
clk_ddr hold u_mem/addr_reg 0.005
clk_io setup u_io/pad_reg 0.238
EOF
cat > owners.txt <<'EOF'
clk_core alice
clk_ddr bob
EOF
printf ' %s paths across %s clocks, %s owners listed\n' \
"$(awk 'END { print NR }' paths.rpt)" \
"$(awk '{ c[$1] } END { n = 0; for (k in c) n++; print n }' paths.rpt)" \
"$(awk 'END { print NR }' owners.txt)" 6 paths across 3 clocks, 2 owners listedSix paths, four fields each: clock, check type, path name, slack. Three clocks and only two owners — the mismatch is deliberate.
2. Problem 1 — Group and Aggregate
"Total the slack per clock domain."
awk '{ s[$1] += $4; n[$1]++ }
END { k = split("clk_core clk_ddr clk_io", o, " ")
for (i = 1; i <= k; i++) printf " %-9s n=%d sum=%.3f\n", o[i], n[o[i]], s[o[i]] }' paths.rpt clk_core n=3 sum=0.008
clk_ddr n=2 sum=-0.107
clk_io n=1 sum=0.238The program is right. The question is wrong, and saying so is the answer the interview wants.
Follow-up: is a total the figure a timing engineer needs?
awk '{ n[$1]++; if (!($1 in w) || $4 + 0 < w[$1]) w[$1] = $4 + 0 }
END { k = split("clk_core clk_ddr clk_io", o, " ")
for (i = 1; i <= k; i++) printf " %-9s n=%d worst=%.3f\n", o[i], n[o[i]], w[o[i]] }' paths.rpt clk_core n=3 worst=-0.041
clk_ddr n=2 worst=-0.112
clk_io n=1 worst=0.2383. Problem 2 — Join Two Files
"Add the owner of each clock domain to every path."
awk 'NR == FNR { own[$1] = $2; next }
{ printf " %-9s %-6s %-16s owner=%s\n", $1, $2, $3, ($1 in own ? own[$1] : "<UNASSIGNED>") }' owners.txt paths.rpt clk_core setup u_alu/sum_reg owner=alice
clk_core hold u_alu/out_reg owner=alice
clk_ddr setup u_mem/dq_reg owner=bob
clk_core setup u_ctrl/st_reg owner=alice
clk_ddr hold u_mem/addr_reg owner=bob
clk_io setup u_io/pad_reg owner=<UNASSIGNED>NR == FNR is true only while the first file is being read, because FNR restarts per file and NR does not. next stops the second rule from also running on key records.
Note <UNASSIGNED> rather than an empty field: clk_io has no owner, and Chapter 14.4 section 4 measured what an unnamed absent value does to a grouping — it files under the empty string and prints as nothing.
Follow-up: what happens if the key file is empty?
: > empty.txt
awk 'NR == FNR { own[$1] = $2; next }
{ n++ } END { k = 0; for (x in own) k++; printf " NR == FNR : keys loaded=%d data records seen=%d\n", k, n+0 }' empty.txt paths.rpt
awk 'FILENAME == ARGV[1] { own[$1] = $2; next }
{ n++ } END { k = 0; for (x in own) k++; printf " FILENAME test: keys loaded=%d data records seen=%d\n", k, n+0 }' empty.txt paths.rpt NR == FNR : keys loaded=3 data records seen=0
FILENAME test: keys loaded=0 data records seen=64. Problem 3 — The Worst N
"Show the three worst paths."
awk '{ printf "%s %s\n", $4, $3 }' paths.rpt | sort -n | head -3 | sed 's/^/ pipe to sort: /' pipe to sort: -0.112 u_mem/dq_reg
pipe to sort: -0.041 u_ctrl/st_reg
pipe to sort: -0.018 u_alu/sum_regPiping to sort -n is the right first answer and worth saying out loud: awk has no built-in sort in POSIX, sort is written for exactly this, and Chapter 14.5 measured that doing the work inside awk is 1.14× faster — not a reason to write a sort.
Follow-up: do it without sort.
awk '{ v[NR] = $4 + 0; p[NR] = $3 }
END { for (i = 1; i <= NR; i++) for (j = i + 1; j <= NR; j++)
if (v[j] < v[i]) { t = v[i]; v[i] = v[j]; v[j] = t; u = p[i]; p[i] = p[j]; p[j] = u }
for (i = 1; i <= 3; i++) printf " in-awk: %8.3f %s\n", v[i], p[i] }' paths.rpt in-awk: -0.112 u_mem/dq_reg
in-awk: -0.041 u_ctrl/st_reg
in-awk: -0.018 u_alu/sum_regIdentical to the pipeline. Three points the follow-up is listening for:
- The arrays are keyed on
NR, which gives a dense integer index and therefore afor (i = 1; i <= NR; i++)that visits records in input order. Keying on the path name would needfor (k in v), which has no defined order. - Both arrays are swapped together. Sorting the values and not the labels is the classic version of this bug, and it produces a correctly sorted column of numbers beside the wrong names.
$4 + 0on the way in. Storing the field as text and comparing later gives the lexical comparison from Chapter 14.7 section 7.
An O(n²) sort is fine at report scale and sort -n is better at log scale; the answer that names the trade-off is stronger than either program.
5. Problem 4 — Preserve Input Order
"Count paths per clock, in the order the clocks first appear."
awk '{ if (!($1 in seen)) { seen[$1]; ord[++k] = $1 }; n[$1]++ }
END { for (i = 1; i <= k; i++) printf " %-9s %d\n", ord[i], n[ord[i]] }' paths.rpt clk_core 3
clk_ddr 2
clk_io 1Follow-up: what does for (g in n) give instead?
awk '{ n[$1]++ } END { for (g in n) printf " for-in: %-9s %d\n", g, n[g] }' paths.rpt for-in: clk_io 1
for-in: clk_ddr 2
for-in: clk_core 3The exact reverse of input order — on this fixture. That is the trap: a three-key array produced an order that looks deliberate, and a candidate who runs it once concludes that for-in reverses.
It does not reverse. It has no defined order at all, and Chapter 7.7 measured one program giving three different orders for three different key prefixes, one of which looked sorted. An ordering that appears stable across a few runs on one data set is not a guarantee of anything.
Note if (!($1 in seen)) { seen[$1]; ... } rather than if (seen[$1] == ""). Chapter 6.1 measured that the second form creates the element it is testing — and here that would make seen grow with every distinct key anyway, so the bug would be invisible. Using in means the test is a test.
6. Problem 5 — Two-Level Grouping
"Count paths by clock and check type together."
awk '{ c[$1, $2]++ }
END { k = split("clk_core clk_ddr clk_io", g, " "); m = split("setup hold", t, " ")
for (i = 1; i <= k; i++) for (j = 1; j <= m; j++)
if ((g[i], t[j]) in c) printf " %-9s %-6s %d\n", g[i], t[j], c[g[i], t[j]] }' paths.rpt clk_core setup 2
clk_core hold 1
clk_ddr setup 1
clk_ddr hold 1
clk_io setup 1Five of the six possible combinations exist; clk_io hold does not, and the in test is what keeps it out of the output rather than printing a zero row. Note the membership test for a multi-subscript key is written (g[i], t[j]) in c, with the parentheses.
Follow-up: what is c[$1, $2] actually keyed on?
awk 'BEGIN { printf " length(SUBSEP)=%d\n", length(SUBSEP) }'
printf '%s' "$(awk 'BEGIN { printf "%s", SUBSEP }')" | od -An -b | tr -d ' \n'; echo " <- SUBSEP in octal"
awk 'BEGIN { c["a", "b"] = 1; for (k in c) printf " the key is [%s] with length %d\n", k, length(k) }' | cat -v
awk 'BEGIN { c["a", "b"] = 1; c["a" SUBSEP "b"] = 2; n = 0; for (k in c) n++; printf " distinct keys after both assignments: %d\n", n }'
awk '{ c[$1, $2]++ } END { for (k in c) { n = split(k, parts, SUBSEP); printf " split on SUBSEP gives %d part(s): [%s][%s]\n", n, parts[1], parts[2]; break } }' paths.rpt length(SUBSEP)=1
034 <- SUBSEP in octal
the key is [a^\b] with length 3
distinct keys after both assignments: 1
split on SUBSEP gives 2 part(s): [clk_io][setup]awk has only one-dimensional arrays. c[$1, $2] concatenates the subscripts with SUBSEP, which is octal 034 — ASCII file separator, one character. The key c["a","b"] is literally the three-character string a, \034, b, which cat -v renders as a^\b.
The consequence is the last two lines. c["a", "b"] and c["a" SUBSEP "b"] are the same key — one distinct key after both assignments — so a field value that itself contains \034 collides with a different pair of values. That is remote on a text report and exactly why \034 was chosen; Chapter 12.4 used it as a field separator for a delimited interface for the same reason.
split(k, parts, SUBSEP) recovers the components, which is how a for (k in c) loop over a two-level array gets at its own subscripts.
7. Problem 6 — Multi-Line Records
"Each path is a block of lines separated by a blank line. Read one block at a time."
cat > para.rpt <<'EOF'
Startpoint: u_alu/sum_reg
Endpoint: u_alu/out_reg
slack -0.018
Startpoint: u_mem/dq_reg
Endpoint: u_mem/q_reg
slack -0.112
EOF
awk 'BEGIN { RS = "" } { printf " paragraph %d: NF=%d first=[%s]\n", NR, NF, $1 }' para.rpt paragraph 1: NF=6 first=[Startpoint:]
paragraph 2: NF=6 first=[Startpoint:]RS = "" is paragraph mode: records are separated by blank lines, runs of blanks collapse, and leading and trailing blanks are ignored.
Follow-up: why is NF six when the paragraph has three lines?
awk 'BEGIN { RS = ""; FS = "\n" } { printf " with FS=newline: NR=%d NF=%d $3=[%s]\n", NR, NF, $3 }' para.rpt with FS=newline: NR=1 NF=3 $3=[slack -0.018]
with FS=newline: NR=2 NF=3 $3=[slack -0.112]In paragraph mode, newline is a field separator in addition to whatever FS is. Each of the three lines contributes two whitespace-separated words, giving NF=6.
Setting FS = "\n" makes whole lines the fields, so NF becomes the line count and $3 is the entire third line. That is the idiom for block-structured reports.
Chapter 10.4 measured the limit that makes this a trap rather than a tool: a blank line inside a logical record defeats paragraph mode entirely. On a timing report where each path contains a presentational blank line before its delay table, the same program found seven paragraphs instead of three, produced zero correct rows, and exited 0 — which is why the whole of Module 10 is built on carried state instead. Problem 7 is that technique and its own failure mode.
8. Problem 7 — Carry a Section Heading
"The report is grouped under headings. Attach the heading to each row."
cat > sect.rpt <<'EOF'
Group: clk_core
u_alu/sum_reg -0.018
u_ctrl/st_reg -0.041
Group: clk_ddr
u_mem/dq_reg -0.112
EOF
awk '/^Group:/ { g = $2; next }
NF == 2 { printf " %-9s %-16s %s\n", g, $1, $2 }' sect.rpt clk_core u_alu/sum_reg -0.018
clk_core u_ctrl/st_reg -0.041
clk_ddr u_mem/dq_reg -0.112Follow-up: the tool drops a heading. What does the program report?
awk '!/^Group: clk_ddr/' sect.rpt > sect2.rpt
awk '/^Group:/ { g = $2; next }
NF == 2 { printf " stale: %-9s %-16s %s\n", g, $1, $2 }' sect2.rpt
awk '/^Group:/ { g = $2; next }
/^$/ { g = ""; next }
NF == 2 { printf " guarded: %-9s %-16s %s\n", (g == "" ? "<NO GROUP>" : g), $1, $2 }' sect2.rpt stale: clk_core u_alu/sum_reg -0.018
stale: clk_core u_ctrl/st_reg -0.041
stale: clk_core u_mem/dq_reg -0.112
guarded: clk_core u_alu/sum_reg -0.018
guarded: clk_core u_ctrl/st_reg -0.041
guarded: clk_core u_mem/dq_reg -0.1129. Problem 8 — Transpose
"Turn the rows of a small table into columns."
awk '{ for (i = 1; i <= NF; i++) m[i, NR] = $i; if (NF > w) w = NF }
END { for (i = 1; i <= w; i++) { line = ""
for (j = 1; j <= NR; j++) line = line (j > 1 ? " " : "") m[i, j]
printf " %s\n", line } }' owners.txt clk_core clk_ddr
alice bobTwo rows of two become two columns of two. The problem is a two-level-array exercise wearing a different hat, and the three things it is checking are visible in the program:
m[i, NR]stores by (column, row), so the output loop reads across what were rows.wtracks the widest row, because a ragged table has no singleNF— using the last record'sNFsilently truncates.NRinENDis the total record count, which is what makes the inner loop bound correct without a second counter.
The honest caveat is memory: this holds the entire table in an array, which Chapter 14.5 measured as cheap in time (400,000 keys cost 1.5× the time of 97) but linear in space. A transpose of a large file is a sort-and-reshape job, not an awk one.
10. Common Mistakes
Summing a margin. clk_core reported +0.008 across three paths including two violations.
Reporting an extremum with no count. A misfiled value that is not its group's extremum leaves no trace.
An unseeded running minimum. It stays at the empty string and reports 0.000, which in a timing report means exactly met.
NR == FNR with a possibly-empty key file. It loaded three keys from the data file and processed nothing.
Checking the key count to catch that. The count was 3, not 0 — the test is satisfied by the wrong data.
Sorting values without their labels. A correctly sorted number column beside the wrong names.
Storing a numeric field as text and comparing later. The comparison becomes lexical.
Concluding for-in reverses. It returned exact reverse order here and has no defined order at all.
seen[$1] == "" as a membership test. It creates the element; in does not.
Forgetting that c[i, j] is one-dimensional. The key is i SUBSEP j, so a value containing \034 collides.
Paragraph mode on records that contain blank lines. Chapter 10.4 measured seven paragraphs where there were three paths, zero correct rows, exit 0.
Clearing carried state on a blank line. On a file with no blank lines the guard never fires and the output is identical.
Using the last record's NF to transpose. A ragged table truncates silently.
11. Exercises
Exercise 1 — Sum against worst, side by side
awk '{ n[$1]++; s[$1] += $4; if (!($1 in w) || $4 + 0 < w[$1]) w[$1] = $4 + 0
if ($4 + 0 < 0) v[$1]++ }
END { k = split("clk_core clk_ddr clk_io", o, " ")
for (i = 1; i <= k; i++) printf " %-9s n=%d violations=%d sum=%+.3f worst=%+.3f\n",
o[i], n[o[i]], v[o[i]] + 0, s[o[i]], w[o[i]] }' paths.rpt clk_core n=3 violations=2 sum=+0.008 worst=-0.041
clk_ddr n=2 violations=1 sum=-0.107 worst=-0.112
clk_io n=1 violations=0 sum=+0.238 worst=+0.238The sum column is positive for the group with the most violations. The violations count is the figure that orders the groups the way an engineer would.
Exercise 2 — The empty key file: two of three detectors fail
: > empty.txt
awk 'NR == FNR { own[$1] = $2; next } { n++ }
END { k = 0; for (x in own) k++
printf " key count : keys=%d records=%d -> %s\n", k, n+0, (k > 0 ? "PASS (wrongly)" : "FAIL") }' empty.txt paths.rpt
awk 'NR == FNR { own[$1] = $2; loaded = 1; next } { n++ }
END { printf " loaded flag: records=%d -> %s\n", n+0, (loaded ? "PASS (wrongly)" : "FAIL (correctly)") }' empty.txt paths.rpt
awk 'FILENAME == ARGV[1] { own[$1] = $2; next } { n++ }
END { k = 0; for (x in own) k++
printf " FILENAME : keys=%d records=%d\n", k, n+0 }' empty.txt paths.rpt key count : keys=3 records=0 -> PASS (wrongly)
loaded flag: records=0 -> PASS (wrongly)
FILENAME : keys=0 records=6Two of the three detectors pass on the condition they exist to catch.
The key count passes because the keys came out of the data file. The loaded flag passes for the same reason one step removed: the NR == FNR rule did execute — on the data file — so loaded was set. Only FILENAME == ARGV[1] reports the truth, because it asks which file is being read instead of inferring it from a counter.
Exercise 3 — Why the loaded flag is not a detector
awk 'NR == FNR { own[$1] = $2; loaded = 1; next } { n++ }
END { printf " loaded=[%s] keys=%d data records=%d NR=%d\n", loaded, length(own), n+0, NR }' empty.txt paths.rpt
awk 'NR == FNR { hits++; next } { n++ }
END { printf " rule-1 fired %d time(s), rule-2 fired %d time(s)\n", hits+0, n+0 }' empty.txt paths.rpt loaded=[1] keys=3 data records=0 NR=6
rule-1 fired 6 time(s), rule-2 fired 0 time(s)The second line settles it: rule 1 fired six times and rule 2 fired none. Every record of paths.rpt was taken by the key-loading rule, so NR reaches 6, loaded is 1, three keys exist, and the rule that was supposed to process the data never ran at all.
A loaded flag detects "the key rule executed". That is true here, and it is true for exactly the wrong reason. The flag and the fault have the same cause, which is the general shape of a detector that cannot work: it is computed downstream of the premise it is meant to test.
This is the fourth time in Part IV that a check passed on the condition it was written to catch — Chapter 12.3's checklog.sh, Chapter 13.2's exit $status, Chapter 14.4's NF == 3 guard, and this. The remedy each time is the same: measure the premise directly, not a consequence of it. FILENAME is the premise.
Exercise 4 — Which file-level check catches a missing section marker
for f in sect.rpt sect2.rpt; do
printf " %-11s " "$f"
awk '/^Group:/ { g++ } NF == 2 { d++ }
END { printf "markers=%d NF==2 rows=%d -> %s\n", g+0, d+0,
(g >= 1 && d >= g ? "plausible" : "IMPOSSIBLE") }' "$f"
done
printf 'clk_core\nclk_ddr\n' > groups.txt
for f in sect.rpt sect2.rpt; do
printf " %-11s " "$f"
awk 'NR == FNR { want[$1]; nw++; next }
/^Group:/ { saw[$2] }
END { m = 0; for (k in want) if (!(k in saw)) m++
printf "expected %d, saw %d marker(s), %d MISSING\n", nw, length(saw), m }' groups.txt "$f"
done
for f in sect.rpt sect2.rpt; do
printf " %-11s " "$f"
awk '/^Group:/ { g = $2; next } NF == 2 { c[g]++ }
END { s = ""; k = split("clk_core clk_ddr", o, " ")
for (i = 1; i <= k; i++) s = s sprintf(" %s=%d", o[i], c[o[i]] + 0)
printf "rows per group:%s\n", s }' "$f"
done sect.rpt markers=2 NF==2 rows=5 -> plausible
sect2.rpt markers=1 NF==2 rows=4 -> plausible
sect.rpt expected 2, saw 2 marker(s), 0 MISSING
sect2.rpt expected 2, saw 1 marker(s), 1 MISSING
sect.rpt rows per group: clk_core=2 clk_ddr=1
sect2.rpt rows per group: clk_core=3 clk_ddr=0Three checks, two of which work.
The naive precondition passes both files. markers >= 1 && rows >= markers is satisfied by markers=1 rows=4 as readily as by markers=2 rows=5, so it detects nothing.
Its row count is also wrong by two on both files: sect.rpt has three data rows and the check reports 5. Group: clk_core is itself an NF == 2 record and this program has no next, so every heading is counted as a data row as well. The check is not merely weak — its denominator is inflated, which is the Chapter 14.4 lesson about a guard whose own arithmetic is wrong.
The expected-section list catches it (1 MISSING), because the list comes from outside the file — Chapter 9.5's rule that the population comes from the list, not from what was found.
The per-group row count catches it more cheaply: clk_core=3 clk_ddr=0. A heading that appears in the output with zero rows under it is the signature, and it needs no external list.
Exercise 5 — Engineering application
# groupcheck.awk — attach a carried section heading to each row, and refuse
# rather than guess when the section structure is not intact.
#
# Usage: awk -f groupcheck.awk groups.txt report.rpt
#
# groups.txt lists the sections the report is expected to contain, one per
# line. Problem 7 measured why this file is necessary: a dropped heading
# leaves no trace in the report, so no per-record rule can detect it, and
# clearing the carried variable on a blank line does nothing when the file
# has no blank lines.
#
# The first rule tests FILENAME, not NR == FNR, because Exercise 2 measured
# that an empty first file makes NR == FNR consume the second one.
#
# Exit: 0 intact, 1 rows appeared before any heading, 2 an expected section
# has no heading in the report.
FILENAME == ARGV[1] { want[$1]; nwant++; next }
/^Group:/ { g = $2; saw[$2]; next }
NF == 2 && g != "" { rows[g]++; printf " %-9s %-16s %s\n", g, $1, $2; next }
NF == 2 { orphan++; printf " <NO GROUP> %-16s %s\n", $1, $2 }
END {
missing = 0
for (k in want) if (!(k in saw)) missing++
empty = 0
for (k in want) if ((k in saw) && !(k in rows)) empty++
printf " --\n sections expected=%d seen=%d missing=%d empty-sections=%d orphan-rows=%d\n",
nwant + 0, length(saw), missing, empty, orphan + 0
if (missing) { printf " REFUSING: %d expected section(s) have no heading in the report\n", missing; exit 2 }
if (orphan) { printf " rows appeared before any heading\n"; exit 1 }
exit 0
}awk -f groupcheck.awk groups.txt sect.rpt > out.txt 2> err.txt; printf " sect.rpt rc=%s\n" "$?"; sed 's/^/ /' out.txt
awk -f groupcheck.awk groups.txt sect2.rpt > out.txt 2> err.txt; printf " sect2.rpt rc=%s\n" "$?"; sed 's/^/ /' out.txt sect.rpt rc=0
clk_core u_alu/sum_reg -0.018
clk_core u_ctrl/st_reg -0.041
clk_ddr u_mem/dq_reg -0.112
--
sections expected=2 seen=2 missing=0 empty-sections=0 orphan-rows=0
sect2.rpt rc=2
clk_core u_alu/sum_reg -0.018
clk_core u_ctrl/st_reg -0.041
clk_core u_mem/dq_reg -0.112
--
sections expected=2 seen=1 missing=1 empty-sections=0 orphan-rows=0
REFUSING: 1 expected section(s) have no heading in the reportrc=0 on the intact report and rc=2 on the damaged one, with the count substituted into the message.
Note that the damaged report's three rows are still printed, including the third one still wrongly attributed to clk_core. That is deliberate: the rows are evidence and the verdict is separate. Suppressing the output would leave a reader unable to see what the misattribution was, and Chapter 14.6 stage 2 is the whole argument for printing the records.
Note also that stdout and stderr go to separate files. Capturing > out.txt 2>&1 merges two differently-buffered streams into one file, and a merged transcript cannot be read as an ordering — Chapter 14.7 Exercise 6 is where that cost a diagnosis.
Exercise 6 — Challenge: prove each exit path can fire
Three exit codes are documented. A code that no input can produce is dead, and a code that every input produces is uninformative. Construct an input for each.
printf ' u_orphan/reg -0.300\nGroup: clk_core\n u_alu/sum_reg -0.018\nGroup: clk_ddr\n u_mem/dq_reg -0.112\n' > sect3.rpt
for f in sect.rpt sect2.rpt sect3.rpt; do
awk -f groupcheck.awk groups.txt "$f" > out.txt 2> err.txt
printf " %-11s rc=%s verdict=[%s]\n" "$f" "$?" "$(tail -1 out.txt | sed 's/^ *//')"
done sect.rpt rc=0 verdict=[sections expected=2 seen=2 missing=0 empty-sections=0 orphan-rows=0]
sect2.rpt rc=2 verdict=[REFUSING: 1 expected section(s) have no heading in the report]
sect3.rpt rc=1 verdict=[rows appeared before any heading]Three for three. sect3.rpt puts a data row before the first heading, which fires the orphan branch and exit 1; the intact and the heading-dropped files give 0 and 2. No branch is dead.
Now the fourth condition, which the script measures and does not act on:
printf 'Group: clk_core\n u_alu/sum_reg -0.018\nGroup: clk_ddr\n' > sect4.rpt
awk -f groupcheck.awk groups.txt sect4.rpt > out.txt 2> err.txt
printf " sect4.rpt rc=%s\n" "$?"; sed 's/^/ /' out.txt sect4.rpt rc=0
clk_core u_alu/sum_reg -0.018
--
sections expected=2 seen=2 missing=0 empty-sections=1 orphan-rows=0empty-sections=1 — correctly detected — and rc=0.
clk_ddr has a heading and no rows beneath it, the counter (k in saw) && !(k in rows) identifies exactly that, and the script reports it accurately. Then it exits 0, because there is no if (empty) branch: the value is computed, printed, and never reaches a verdict.
That is a defect in my own script, and it is a shape worth naming: a measured condition with no verdict attached is not a check. It reads as diligence in the source — a counter with a clear name, printed in the summary — and a caller that keys on the exit status is unaffected by it. A reader who skims the summary line sees empty-sections=1 next to an exit code that says everything is fine.
Chapter 14.5's Exercise 6 made the complementary point about a linter rule that cannot fire. This is the other half: a rule that fires and is ignored. Both look like coverage and neither changes an outcome, and the test that distinguishes them is the same one — construct the input, run it, and check that the verdict moves. Three exit paths moved; this fourth condition did not, which is how it was found.
The fix is one line, and the reason it is worth stating rather than silently adding is that empty is not obviously an error: a tool may legitimately print a heading for a domain with no violating paths. Whether it exits 1 or 0 is a policy, exactly as Chapter 14.4's field-count minimum was — so it belongs in the usage comment and the caller's hands, not in a quiet default.
12. Summary
- Do not sum a margin.
clk_corereported+0.008across three paths of which two were violations; the group with the most violations had the most positive total. - Report an extremum with its count, and seed it with
!(k in w)— an unseeded minimum reports0.000, which in a timing report means exactly met. NR == FNRswallows the data file when the key file is empty: three keys loaded from the data, zero data records processed, exit 0.- Neither the key count nor a
loadedflag detects that — both are computed from the same mistaken premise.FILENAME == ARGV[1]tests which file it actually is. - Sort labels together with values, and convert on the way in with
+ 0. for-inreturned exact reverse order here and guarantees nothing. Keep a first-seenord[]array for input order.inis a test;a[k] == ""is a test that creates the element.c[i, j]is one key,i SUBSEP j, withSUBSEPat octal 034.c["a","b"]andc["a" SUBSEP "b"]are the same key;split(k, p, SUBSEP)recovers the parts.- Paragraph mode makes newline a field separator too — NF=6 for a three-line block, and
FS = "\n"makes NF the line count. - A blank line inside a logical record defeats paragraph mode, which is why Module 10 is built on carried state.
- A dropped section heading produces three plausible rows, one wrong, and clearing on a blank line is byte-identical when the file has no blank lines.
- The naive precondition passed both files, and its row count was inflated by two because the heading is itself an
NF == 2record with nonext. - The checks that work are the expected-section list and the per-group row count —
clk_core=3 clk_ddr=0. - Two of three empty-key-file detectors passed wrongly. The key count and the
loadedflag are both computed downstream of the fault; rule 1 fired six times and rule 2 none. - Measure the premise, not a consequence of it.
FILENAME == ARGV[1]is the only one of the three that works. - Prove each exit path can fire. Three inputs gave rc=0, rc=2 and rc=1 — no dead branch.
- A measured condition with no verdict attached is not a check.
empty-sections=1was detected correctly and the script still exited 0, because no branch reads it.
Chapter 14.9 — Interview Problems: Advanced closes the track with the problems that have no one-liner: a question that cannot be answered from the data given, a measurement that needs its own control, and the design arguments an interviewer asks when the syntax is no longer in doubt.
Where this fits
Part of the awk curriculum.
