Tcl · Module 30
A Timing Violation Extractor
Four malformed reports refused by name, and two that parse cleanly while being wrong. The second pair is what the chapter is about.
Advanced23 min readtcledapart-iiparsingcsvcase-study
Part II · Module 30 · Chapter 30.4 · A Timing Violation Extractor
This script has no tool in it. It reads a text report and writes a CSV, which is the most-written script in the industry and the one most likely to be quietly wrong.
The registry asks for the handling for every malformed section it will meet, so the chapter is organised around six specimens rather than around the parser. Four of them are refused and named. Two of them parse perfectly while being wrong, and those two are the chapter.
1. The parser
# ---- the parser ---------------------------------------------------------
proc extract {path} {
if {![file isfile $path]} { error "no such report: $path" {} {RPT NOFILE} }
if {[file size $path] == 0} { error "report is empty: $path" {} {RPT EMPTY} }
set fh [open $path r]
set rows {} ; set check "" ; set group "" ; set blocks 0 ; set n 0
while {[gets $fh line] >= 0} {
incr n
# a section header: carry its identity INTO each row (24.4 section 1)
if {[regexp {^\s*(max_delay/setup|min_delay/hold)\s+\('(\S+)'\s+group\)} $line -> c g]} {
set check $c ; set group $g ; incr blocks ; continue
}
# a data row, recognised by SHAPE and anchored both ends (24.4 section 3)
if {[regexp {^\s+(\S+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s*$} \
$line -> ep req act slk]} {
if {$check eq ""} {
close $fh
error "$path:$n: a data row before any section header" {} {RPT ORPHANROW}
}
lappend rows [dict create endpoint $ep check $check group $group \
required $req actual $act slack $slk]
}
}
close $fh
if {$blocks == 0} { error "$path: no section header found" {} {RPT NOSECTIONS} }
if {[llength $rows] == 0} { error "$path: $blocks section(s) and no data rows" {} {RPT NOROWS} }
return [list $rows $blocks]
}Everything in it is Module 24 applied once rather than explained again:
- State carried forward. The section header sets
checkandgroup; every data row afterwards inherits them. A CSV row that does not know which check it came from is not usable, and the header is the only place that information exists. - Rows recognised by shape, anchored both ends.
^\s+(\S+)then three decimal numbers then\s*$. Chapter 24.4 §3 argued for the shape rather than a keyword, because a tool changes its column labels between versions and does not change its arithmetic. - An orphan row is an error, not a skip. A data row before any header has no check and no group; guessing is worse than refusing, so it raises with a line number.
- Two post-conditions. No sections at all, and sections with no rows, are different failures with different causes and get different messages.
Each refusal carries an errorCode — RPT NOFILE, RPT EMPTY, RPT ORPHANROW, RPT NOSECTIONS, RPT NOROWS — so a caller can route on the kind of malformation without reading the message, which is Chapter 29.4's mechanism.
2. CSV, done properly
proc csv_field {s} {
if {[regexp {[",\n\r]} $s]} { return "\"[string map [list \" \"\"] $s]\"" }
return $s
}
proc csv_row {fields} {
set out {}
foreach f $fields { lappend out [csv_field $f] }
return [join $out ","]
}Four lines, and they are the difference between a CSV file and a file with commas in it. A field containing a comma, a quote or a newline is quoted, and an embedded quote is doubled. Chapter 24.6 measured why hand-joining with commas breaks: endpoint names in real designs contain characters you did not anticipate, and the failure appears in the spreadsheet rather than in your script.
proc to_csv {rows out} {
file mkdir [file dirname $out]
set fh [open $out w]
puts $fh [csv_row {endpoint check group required actual slack}]
foreach r [lsort -real -index 11 $rows] {
puts $fh [csv_row [list [dict get $r endpoint] [dict get $r check] \
[dict get $r group] [dict get $r required] \
[dict get $r actual] [dict get $r slack]]]
}
close $fh
return [llength $rows]
}lsort -real -index 11 sorts by slack — index 11 because a dict is a flat list and slack is the sixth key, so its value is the twelfth element. Worst first, so the first data row of the CSV is the one somebody has to fix.
3. The six specimens
=== every specimen, through the same extractor ===
viol_dropped.rpt 2 row(s), 1 section(s), 1 total-line(s); rows=claim NO sections=totals yes
viol_empty.rpt REFUSED: report is empty: /private/tmp/m30/reports/viol_empty.rpt
viol_good.rpt 3 row(s), 2 section(s), 2 total-line(s); rows=claim yes sections=totals yes
viol_norows.rpt REFUSED: /private/tmp/m30/reports/viol_norows.rpt: 1 section(s) and no data rows
viol_nosections.rpt REFUSED: /private/tmp/m30/reports/viol_nosections.rpt: no section header found
viol_orphanrow.rpt REFUSED: /private/tmp/m30/reports/viol_orphanrow.rpt:2: a data row before any section header
viol_truncated.rpt 2 row(s), 2 section(s), 1 total-line(s); rows=claim yes sections=totals NOFour refusals, each naming the file and the specific problem — and viol_orphanrow.rpt:2 naming the line. That is the easy half. The two that did not refuse are:
viol_dropped.rpt— 2 rows parsed, and the report's ownTotalsays 3.viol_truncated.rpt— 2 rows parsed, 2 section headers, and only 1Totalline.
Both produced a CSV. Both CSVs are wrong. Neither is detectable by anything inside the parser, because nothing inside the parser is wrong — the lines it parsed, it parsed correctly.
And the good one:
=== the CSV from the good one ===
endpoint,check,group,required,actual,slack
u_sel_reg/D,max_delay/setup,core_clk,4.8200,4.9510,-0.1310
u_addr_reg/D,max_delay/setup,core_clk,4.8200,4.8930,-0.0730
u_sync_reg/D,min_delay/hold,core_clk,0.1200,0.0890,-0.0310Three rows, worst first, each carrying its own check and group. The hold row sits in the same file as the setup rows and is distinguishable from them, which is the whole reason the header is carried into the row — and it is the same point Chapter 30.2 §4 made about aggregation, arriving here as a column.
4. The two that need a second opinion
The report hands over a cross-check for free: each section ends with its own count.
# ---- the cross-check the report hands over for free (24.4 section 6) ---
proc claimed_total {path} {
set fh [open $path r] ; set total 0 ; set seen 0
while {[gets $fh line] >= 0} {
if {[regexp {^\s*Total\s+(\d+)\s+\(VIOLATED\)} $line -> k]} { incr total $k ; incr seen }
}
close $fh
return [list $total $seen]
}Check 1: do the rows I parsed equal the count the report claims? That catches viol_dropped.rpt immediately. Its third row is an unconstrained endpoint:
u_unconstrained/D N/A 5.1020 N/AThe shape regex wants three decimal numbers and finds one. It declines the row — correctly, because N/A is not a slack — and the row disappears with no message at all. The parser is right; the output is missing a third of its content. Check 1 notices because the Total line still says 3.
Check 2: is there one Total line per section header? That catches viol_truncated.rpt, which check 1 cannot.
5. Neither check is sufficient
=== neither check is sufficient alone ===
SPECIMEN check 1 check 2 what is actually wrong
viol_good.rpt pass pass nothing
viol_dropped.rpt FAIL pass one row silently unparsed
viol_truncated.rpt pass FAIL a whole section lost
check 1: rows parsed == the sections' own Total count
check 2: one Total line per section header
a truncation removes BOTH sides of check 1, so only check 2 sees it
a dropped row leaves the Total intact, so only check 1 sees itRead the two FAIL cells. They are in different columns, and that is the entire result.
Here is how viol_truncated.rpt ends:
u_addr_reg/D 4.8200 4.8930 -0.0730
u_sel_reg/D 4.8200 4.9510 -0.1310
-------------------------------------------------------------
Total 2 (VIOLATED)
min_delay/hold ('core_clk' group)Why check 1 passes on a truncated report: the file was cut off in the middle of the hold section, after its header and before any of its rows or its Total line. So the rows the parser found (2) and the total the report claims (2) both refer only to the setup section. The truncation removed the evidence of itself from both sides of the comparison. A count against a claim is only as good as the claim surviving.
Why check 2 passes on a dropped row: the section is complete — header, rows, Total — so one header matches one total. Nothing structural is missing; one line inside the structure did not match a regex.
Each check is blind to exactly the corruption the other catches, and the blindness is not an accident of these two specimens: it follows from what each check compares. One compares content against a claim, the other compares structure against structure. A corruption that damages content and claim together defeats the first; a corruption that damages neither structure defeats the second.
6. What two checks still cannot catch
- A number that is wrong but well-formed. If the tool printed
4.8200 4.8930 -0.7300— slack an order of magnitude out — both checks pass and the CSV is confidently wrong. Catching that needs arithmetic:required - actualshould equalslack, and the parser never checks it. Chapter 24.7 names this as the class assertions do not reach. - A report of the wrong run. Both checks are internal. A perfectly consistent report of last night's netlist passes everything, which is Chapter 29.2's problem and needs identity, not counting.
- A section the tool never emitted. If the tool was asked for setup and hold and silently produced only setup, the file is internally consistent — one header, one total, matching rows. Detecting it requires knowing what was expected, which is the plan-driven approach Chapter 30.5 builds next.
- Rows the regex matches that are not data. A summary line that happens to have three decimal numbers in the right places would be extracted as an endpoint. The anchors make it unlikely; they do not make it impossible.
That last pair is the honest ceiling of this chapter: a parser can check itself against the file, and cannot check the file against the world. Everything in section 5 is the first kind. The next chapter is about the second.
Exercises
- Add the arithmetic check from section 6 —
required - actual == slackwithin a tolerance. Which specimen does it catch, and why does the tolerance matter? - Remove the
\s*$anchor from the data-row regex. Construct a line that is now matched and should not be. viol_truncated.rptloses its hold section. Make a specimen that loses the setup section instead. Do both checks behave the same way?- The orphan-row error reports a line number. Why is that worth more here than in most error messages?
- Make
claimed_totaltolerate aTotalline with no(VIOLATED)suffix. What does that cost? - A third check is proposed: the CSV must have as many data lines as rows parsed. Is it worth adding?
Answers
- None of the six — which is the point. Every specimen's arithmetic is self-consistent, so the check passes everywhere and you have to build a seventh specimen to see it work; that is the section-6 class arriving as a chore. The tolerance matters because the three columns are rounded to four decimals independently, so an exact comparison fails on correct data:
4.8200 - 4.8930 = -0.0730works here, but a report rounding to two decimals would not. u_addr_reg/D 4.8200 4.8930 -0.0730 (worst in group)— three numbers in the right shape followed by a trailing annotation. With the anchor it is declined; without it, the annotation is ignored and the row is accepted. Which may even be what you want, and that is the problem: you cannot tell an annotation you want to ignore from a fourth column you needed to read.- No. Losing the setup section means the file starts with the hold header, so the parser sees 1 section, 1 total and the hold rows — internally consistent, both checks pass, and half the violations are gone with no signal at all. Truncation at the end is visible because the header survives without its total; truncation at the front removes the header too. The only defence is knowing which sections were expected.
- Because the message cannot say what is wrong with the row — the row is fine; what is missing is the header that should have preceded it. There is nothing to quote and no value to name, so the location is the diagnosis.
- It costs the distinction between a violating section and a clean one.
Total 0with no suffix is a section that passed; counting it as a claim of zero rows is correct, but then a clean section and a section whose total line was mangled become the same thing. The suffix is the only evidence the line is the one you think it is. - No — it compares the writer against itself.
to_csviterates the rows it was given, so the count can only differ ifputsfailed, and ifputsfailed you have a bigger problem than a count. A check worth adding compares two independently produced things; this one compares a loop to its own input.
Where this fits
Part of the Tcl curriculum.
