Language Atlas (mini-courses for more languages) Module 6 – Tcl 9 for engineers and automators
Project: a timing-report summariser in Tcl
Build a Tcl 9 timing-report summariser: parse with regexp and scan, sum slack per clock in exact picoseconds, report bad input by line, pass 25 tests.
What you will learn
- Parse a structured text report with regexp, scan and string commands
- Aggregate results per clock with dicts and print a summary sorted by two keys
- Read files as strict UTF-8 and report malformed bytes and missing fields with their line number
- Test the summariser with tcltest, including malformed and Unicode input and a time budget
Before you start
What you’ll learn · Before you start
What you will learn
- Parse a structured text report with regexp, scan and string commands
- Aggregate results per clock with dicts and print a summary sorted by two keys
- Read files as strict UTF-8 and report malformed bytes and missing fields with their line number
- Test the summariser with tcltest, including malformed and Unicode input and a time budget
Before you start
On this page
block_a.txt holds five timing paths on three clocks, and the summary a designer wants from it is three lines:
core’s worst slack is −0.160 ns and its total negative slack −0.215 ns, mem’s both −0.060 ns, and io meets timing with
0.420 ns to spare. Get one rule wrong, how slacks are added, how ties sort, what a bad byte does, and the summary is
wrong without a warning.
This project builds that summariser in Tcl 9 from a specification: regexp and scan to read the report, whole
picoseconds so that every sum is exact, a dict per clock and two stable sorts, a line number and exit status 2 for any
bad input, and 25 tcltest cases, all of them public, with malformed and Unicode input and a report of 10,000 paths.
A specification fixes the report format and every message
The report format is this course’s own, written for the project; no tool’s report format is copied. A path is a block
that starts with path <number>, followed by six indented fields. The slack: In a timing report, the time a path has to spare: its required time minus its arrival time. A negative slack means the signal arrives too late, so the path fails timing. of a path
is its required time minus its arrival time, and a negative slack means the signal arrives too late. Chip-design flows
judge timing by two numbers, which OpenROAD’s flow documentation calls WNS and TNS when it repairs timing; the
specification computes both per clock and defines them exactly:
| The specification says | Tested by |
|---|---|
Blank lines and lines whose first non-space character is # are ignored |
parse-1.3 |
path <number> opens a path; each indented line <field> <value> gives one of from, to, clock, required, arrival, slack, once each, in any order |
parse-1.2, error-1.2, error-1.3, error-1.4, error-1.6 |
Times are ns with an optional minus sign and at most 3 decimals; -0.000 is 0 |
ps-1.1 to ps-1.6 |
| Every path has all six fields, and its slack equals required minus arrival | error-1.1, error-1.5 |
| Per clock: paths, failing paths (slack below 0), the worst negative slack (WNS): The smallest slack among a clock's paths, the path most in need of repair. The summariser of this course prints it for every clock, as a positive number when no path fails. (the smallest, even if positive) and the total negative slack (TNS): The sum of the negative slacks of a clock's paths, and 0 when none fails. It measures the work left across all failing paths, where the worst negative slack shows only the worst one. (0 when none fails) | summary-1.1, summary-1.3 |
| Rows sorted by worst slack, then by clock name | summary-1.1, summary-1.2 |
| The file is UTF-8; LF or CR LF line ends; names keep their Unicode characters | parse-1.4, parse-1.5, error-1.8 |
A bad report gets one line, line N: reason, on standard error; standard output stays empty and the exit status is 2 |
program-1.2 |
| 10,000 paths are summarised in under 1 s | speed-1.1 |
Here are the sample report and the program, all of it in one file of about 170 lines:
report/block_a.txt
# Synthetic timing report of block_a, written for this course in its own format.
path 1
from core/acc_reg_3
to core/out_reg_3
clock core
required 2.450
arrival 2.610
slack -0.160
path 2
from core/pc_reg_0
to core/ir_reg_7
clock core
required 2.450
arrival 2.395
slack 0.055
path 3
from io/rx_sync_1
to io/fifo_wr_4
clock io
required 9.800
arrival 9.380
slack 0.420
path 4
from core/mul_reg_12
to core/acc_reg_12
clock core
required 2.450
arrival 2.505
slack -0.055
path 5
from mem/addr_reg_2
to mem/bank_sel_0
clock mem
required 4.900
arrival 4.960
slack -0.060 report/summarise.tcl
# summarise.tcl: worst and total negative slack per clock, from a timing report in the format of this course's
# project. Run it as tclsh9.0 summarise.tcl report.txt; the tests source it instead.
package require Tcl 9.0-
namespace eval report {
# The six fields of every path.
variable fields {from to clock required arrival slack}
}
# An input error: the message starts with the line number, and the error code says the input is at fault.
proc report::fail {line message} {
return -code error -errorcode {REPORT INPUT} "line $line: $message"
}
# "-0.160" (ns) -> -160 (whole picoseconds). Three decimals at most, so the result is exact.
proc report::picoseconds {line name text} {
if {![regexp {^-?[0-9]+(?:\.[0-9]{1,3})?$} $text]} {
fail $line "$name must be a number of ns with at most 3 decimals, got \"$text\""
}
return [expr {round($text * 1000)}]
}
# The lines of a report file, read as UTF-8; a malformed byte is an input error on its line.
proc report::lines {path} {
set chan [open $path r]
chan configure $chan -encoding utf-8 -profile strict
try {
set text [chan read $chan]
} trap {POSIX EILSEQ} {- options} {
fail [llength [split [dict get $options -data] \n]] "the bytes are not valid UTF-8"
} finally {
chan close $chan
}
return [split [string trimright $text \n] \n]
}
# A report -> a list of paths, each a dict of its six fields, its number and its first line.
proc report::parse {path} {
variable fields
set paths {}
set current {}
set number 0
foreach text [lines $path] {
incr number
set first [string index [string trimleft $text] 0]
if {$first eq "" || $first eq "#"} {
continue
}
if {[regexp {^path\s+[0-9]+\s*$} $text]} {
if {$current ne ""} {
lappend paths [complete $current]
}
set current [dict create id [lindex $text 1] line $number]
continue
}
# An indented field: exactly two words, the field's name and its value.
if {![string is space [string index $text 0]] || [scan $text {%s %s %s} key value extra] != 2} {
fail $number "expected \"path <number>\" or an indented field, got \"$text\""
}
if {$current eq ""} {
fail $number "the field \"$key\" comes before the first path line"
}
if {$key ni $fields} {
fail $number "unknown field \"$key\""
}
if {[dict exists $current $key]} {
fail $number "path [dict get $current id] gives $key twice"
}
if {$key in {required arrival slack}} {
set value [picoseconds $number $key $value]
}
dict set current $key $value
}
if {$current ne ""} {
lappend paths [complete $current]
}
if {![llength $paths]} {
fail [expr {max($number, 1)}] "the report has no paths"
}
return $paths
}
# A finished path: every field present, and the slack equal to required - arrival.
proc report::complete {path} {
variable fields
set line [dict get $path line]
set id [dict get $path id]
foreach key $fields {
if {![dict exists $path $key]} {
fail $line "path $id has no $key"
}
}
if {[dict get $path slack] != [dict get $path required] - [dict get $path arrival]} {
fail $line "path $id: slack is not required - arrival"
}
return $path
}
# Per clock: the number of paths, the failing ones, the worst slack and the total negative slack, in ps.
proc report::summarise {paths} {
set clocks [dict create]
foreach path $paths {
set clock [dict get $path clock]
set slack [dict get $path slack]
if {![dict exists $clocks $clock]} {
dict set clocks $clock [dict create paths 0 failing 0 wns $slack tns 0]
}
dict update clocks $clock stats {
dict incr stats paths
if {$slack < [dict get $stats wns]} {
dict set stats wns $slack
}
if {$slack < 0} {
dict incr stats failing
dict incr stats tns $slack
}
}
}
return $clocks
}
# -160 -> "-0.160", 2450 -> "2.450", 0 -> "0.000"
proc report::ns {ps} {
set sign [expr {$ps < 0 ? "-" : ""}]
set ps [expr {abs($ps)}]
return [format "%s%d.%03d" $sign [expr {$ps / 1000}] [expr {$ps % 1000}]]
}
# The summary as a table: worst slack first, equal worst slacks by clock name.
proc report::table {clocks} {
set rows {}
dict for {clock stats} $clocks {
dict with stats {
lappend rows [list $clock $paths $failing $wns $tns]
}
}
set rows [lsort -integer -index 3 [lsort -index 0 $rows]]
set lines [list [format "%-10s %5s %7s %9s %9s" clock paths failing wns_ns tns_ns]]
foreach row $rows {
lassign $row clock paths failing wns tns
lappend lines [format "%-10s %5d %7d %9s %9s" $clock $paths $failing [ns $wns] [ns $tns]]
}
return [join $lines \n]
}
# The program: 0 with the table on standard output, or 2 with one line on standard error.
proc report::main {argv} {
if {[llength $argv] != 1} {
puts stderr "usage: tclsh9.0 summarise.tcl report.txt"
return 2
}
set file [lindex $argv 0]
try {
puts [table [summarise [parse $file]]]
} trap {REPORT INPUT} {message} {
puts stderr $message
return 2
} trap {POSIX ENOENT} {} {
puts stderr "cannot read $file: no such file"
return 2
}
return 0
}
# Run as a program, not when a test file sources this one.
if {[info exists ::argv0] && [file tail [info script]] eq [file tail $::argv0]} {
exit [report::main $::argv]
} Follow one report through the program, and the branch that every bad report takes:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Read. The file is read in one go with -encoding utf-8 and the strict profile, and split into lines; CR LF ends become LF on the way in.
- Bytes that are not UTF-8. A malformed byte raises POSIX EILSEQ. The text decoded before it gives the line number: bad_bytes.txt stopped with line 4: the bytes are not valid UTF-8.
- Each line. Blank lines and comments are skipped. A line such as path 1 opens a new path; an indented line holds one field; anything else is an error with its line number.
- Fields. scan splits a field line into exactly two words. The name must be one of the six fields and new in this path, and times become whole picoseconds.
- Close the path. At the next path line, or at the end of the file, the path must have all six fields and a slack equal to required minus arrival. missing_slack.txt stopped with line 1: path 1 has no slack.
- Gather. Each path adds to its clock in a dict: one more path, one more failing path when its slack is below 0, a new worst slack, and a negative slack into the total.
- Print. The clocks are sorted by worst slack, ties by name, and printed. For block_a.txt: core -0.160 and -0.215, mem -0.060, io 0.420; the exit status is 0.
Checkpoint
Check: The report format and the summary it asks for
regexp and scan read the lines, and picoseconds keep sums exact
Each line is one of four kinds, and the parser tells them apart with the cheapest command that is still exact. A path
line must match ^path\s+[0-9]+\s*$: the regexp manual says regexp returns 1 for a match and 0 otherwise, and the
anchors make the whole line match, not part of it. A field line is split with scan $text {%s %s %s} key value extra,
which returns how many words it found: exactly 2 is a field, 3 means text after the value, 1 a missing value. The
walkthrough runs each piece on its own:
# walkthrough.tcl: the parser's pieces, one line of the report at a time, then the dicts they build.
source summarise.tcl
puts "1. Which lines start a path:"
foreach line {{path 12} {path 12 x} {Path 12} {path twelve}} {
puts " [format %-15s "\"$line\""] [regexp {^path\s+[0-9]+\s*$} $line]"
}
puts "2. Words that scan finds in a field line:"
foreach line {{ required 2.450} { from a/q extra} { slack}} {
puts " [format %-20s "\"$line\""] [scan $line {%s %s %s} key value extra]"
}
set line " from mémoire/bank_0"
scan $line {%s %s%n} key value end
puts "3. scan's %n on \"$line\": $end, string length: [string length $line]"
puts "4. Nanoseconds to whole picoseconds:"
foreach text {2.450 -0.160 10 -0.000} {
puts " $text -> [report::picoseconds 1 slack $text]"
}
puts " -0.120 + -0.050 as reals: [expr {-0.120 + -0.050}], as picoseconds: [expr {-120 + -50}]"
set paths [report::parse block_a.txt]
puts "5. Path 1 as a dict: [lindex $paths 0]"
puts "6. Per clock:"
dict for {clock stats} [report::summarise $paths] {
puts " $clock: $stats"
} from session import run
# The pieces of the parser, line by line: the path regexp, scan, the picoseconds, and the dicts of block_a.
run("""
cd report
tclsh9.0 walkthrough.tcl
""") Output
$ cd report $ tclsh9.0 walkthrough.tcl 1. Which lines start a path: "path 12" 1 "path 12 x" 0 "Path 12" 0 "path twelve" 0 2. Words that scan finds in a field line: " required 2.450" 2 " from a/q extra" 3 " slack" 1 3. scan's %n on " from mémoire/bank_0": 22, string length: 21 4. Nanoseconds to whole picoseconds: 2.450 -> 2450 -0.160 -> -160 10 -> 10000 -0.000 -> 0 -0.120 + -0.050 as reals: -0.16999999999999998, as picoseconds: -170 5. Path 1 as a dict: id 1 line 2 from core/acc_reg_3 to core/out_reg_3 clock core required 2450 arrival 2610 slack -160 6. Per clock: core: paths 3 failing 2 wns -160 tns -215 io: paths 1 failing 0 wns 420 tns 0 mem: paths 1 failing 1 wns -60 tns -60
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 walk_session.py
Step through how the first path of block_a.txt turns into a dict:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A comment. Line 1 starts with #, so it is skipped. Comments and blank lines may appear anywhere in a report.
- path 1. Line 2 matches ^path\s+[0-9]+\s*$. It opens a path: a dict with its id, 1, and the line it starts on, 2.
- from and to. Lines 3 and 4 are indented, and scan finds two words in each. from and to are fields of the six, not given yet, so both are added as text.
- clock. Line 5 adds clock core. The clock name is text too; it will decide which row of the summary this path counts towards.
- Three times. required 2.450, arrival 2.610 and slack -0.160 are times. Each must be a number with at most three decimals, and each is stored as whole picoseconds: 2450, 2610 and -160.
- The next path closes it. Line 9, path 2, closes path 1. All six fields are there and -160 is 2450 - 2610, so the dict goes into the list of finished paths.
Times become whole picoseconds as soon as they are read. A time with at most three decimals in nanoseconds is a whole
number of picoseconds, so round($text * 1000) gives it exactly, the rounding removing the tiny binary error of the
product, and every later sum is a sum of integers, exact at any size. The fourth part of the walkthrough shows why:
-0.120 + -0.050 as reals printed -0.16999999999999998, where the picoseconds gave −170. -0.000 became 0, so it
never counts as failing.
Checking the end of a line with scan's %n
The obvious check for text after the value is scan $text {%s %s%n} key value end, comparing end with the
length of the line. The scan manual says %n stores the number of characters scanned, but on Tcl 9.0.4 it counted
22 for the 21-character line from mémoire/bank_0: it counted UTF-8 bytes. With that check every ASCII report
passes and a name with é is refused. Counting words with a third %s, as the parser does, gives the same answer for
every character.
Checkpoint
Check: Reading lines with regexp and scan, in picoseconds
A dict per clock and two stable sorts make the table
report::summarise keeps one dict entry per clock, created when the clock’s first path arrives, and updates it with
dict update, which the dict manual says maps the entry to a variable for the body and writes it back when the body
ends. Each path adds one to paths, one to failing when its slack is below 0, its slack into tns when negative,
and lowers wns when it is the worst so far. Step through the five paths of block_a.txt:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Path 1. Path 1 is core with a slack of -160 ps. core is new, so it starts with this path: 1 path, 1 failing, a worst slack of -160 and a total of -160.
- Path 2. Path 2 is core at +55 ps. It counts as a path, but not as failing; the worst slack stays -160 and the total stays -160.
- Path 3. Path 3 is io at +420 ps: 1 path, none failing, a worst slack of 420, which is positive, and a total negative slack of 0.
- Path 4. Path 4 is core at -55 ps: a second failing path. The worst slack stays -160, and the total becomes -160 + -55 = -215.
- Path 5. Path 5 is mem at -60 ps, the first path of mem: 1 path, 1 failing, -60 and -60.
- Sort by name. table sorts the rows by clock name first: core, io, mem. It then sorts them by worst slack, and lsort is stable, so rows with the same worst slack keep this name order.
- Then by worst slack. By worst slack the order is core at -160, mem at -60 and io at 420, the order of the printed table.
The table needs two keys: worst slack first, clock name for ties. lsort sorts by one key at a time, and its manual
says it is a stable merge sort, so report::table sorts by name first and then by worst slack, and rows with the same
worst slack keep their name order. Here is the program on the sample, and on three inputs it must refuse:
from session import run
# The summariser on the sample report, then on three inputs it must refuse, each with exit status 2.
run("""
cd report
tclsh9.0 summarise.tcl block_a.txt
!tclsh9.0 summarise.tcl missing_slack.txt
!tclsh9.0 summarise.tcl bad_bytes.txt
!tclsh9.0 summarise.tcl
""") Output
$ cd report $ tclsh9.0 summarise.tcl block_a.txt clock paths failing wns_ns tns_ns core 3 2 -0.160 -0.215 mem 1 1 -0.060 -0.060 io 1 0 0.420 0.000 $ tclsh9.0 summarise.tcl missing_slack.txt line 1: path 1 has no slack [exit status 2] $ tclsh9.0 summarise.tcl bad_bytes.txt line 4: the bytes are not valid UTF-8 [exit status 2] $ tclsh9.0 summarise.tcl usage: tclsh9.0 summarise.tcl report.txt [exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_session.py
core has 3 paths, 2 of them failing, a worst slack of −0.160 and a total of −0.215 ns, the sum of −160 and −55 ps.
format "%-10s" pads the clock name to 10 characters; Tcl counts characters there, so a name with é would line up
too. report::ns writes the picoseconds back with exactly three decimals, from the whole number and its remainder, so
no rounding happens on the way out either.
Key idea
Keep every time as whole picoseconds from the moment it is read, and sort by one key at a time with a stable sort: then the sums are exact and the order of every tie is decided by the specification, not by chance.
Checkpoint
Check: A dict per clock and a two-key sort
Bad bytes and bad lines end the run with exit status 2
A report is read in one go, chan read after chan configure $chan -encoding utf-8 -profile strict. The chan manual
says that with the strict profile a read of invalid data raises an error with the POSIX code EILSEQ, and that the
text decoded before the bad bytes comes with the error, in its -data entry; strict is also Tcl 9’s default for
channels. report::lines counts the lines of that text to name the line of the bad byte, and the run above printed
line 4: the bytes are not valid UTF-8 for bad_bytes.txt, whose fourth line holds the byte 0xFF. The default
-translation auto turns CR LF into LF on input, so a report saved on Windows reads the same.
Every other problem is one call of report::fail, which raises an error with the code REPORT INPUT and a message
that starts with the line number. Only report::main catches it, with try … trap {REPORT INPUT}, writes the message
to standard error: The second output stream every program has, meant for error messages. Kept apart from standard output, it lets a report be saved to a file while its errors still reach the terminal or a log. More in “Project: a marks report in Object Pascal” and returns 2, the exit status: The whole number a program returns to whatever started it: 0 for success by convention, anything else for a failure. Shells and build pipelines read it to decide what happens next. More in “Project: a marks report in Object Pascal” that a
shell or a build reads. An error with any other code is a bug, not bad input, and is not caught: tclsh prints its
trace and exits with status 1. Each kind of bad input has its own message, and every one is tested:
| Input | Message on standard error |
|---|---|
| a byte that is not UTF-8 | line 4: the bytes are not valid UTF-8 |
| a path without one of its fields | line 1: path 1 has no slack |
| a field given twice | line 8: path 1 gives clock twice |
| a value with four decimals | line 3: slack must be a number of ns with at most 3 decimals, got "2.4501" |
| a slack that is not required − arrival | line 1: path 4: slack is not required - arrival |
| an empty file | line 1: the report has no paths |
Checkpoint
Check: Strict UTF-8, line numbers and exit status 2
Twenty-five tcltest cases pin the summariser down
tcltest is Tcl’s own test package. A test is test name description -body {…} -result {…}, and the tcltest manual
lists options that this suite uses: -returnCodes error for a test that expects an error, -match glob for a result
compared with a pattern, -setup for work that must not be timed, and makeFile, which writes a temporary file and
lets the next cleanupTests remove it. The suite sources summarise.tcl and tests its procedures directly, and two
tests run the program as a user would, with exec, reading the exit status from the CHILDSTATUS error code, as the
exec manual’s examples do:
# summarise.test: the project's tests, run with tclsh9.0 summarise.test. All 25 are public.
package require tcltest 2.5
namespace import ::tcltest::*
source [file join [file dirname [info script]] summarise.tcl]
# One path as report text, with any field left out or changed.
proc path_text {id args} {
set fields [dict create from a/q to b/d clock core required 2.450 arrival 2.610 slack -0.160]
foreach {key value} $args {
if {$value eq "-"} { dict unset fields $key } else { dict set fields $key $value }
}
set lines [list "path $id"]
dict for {key value} $fields { lappend lines " $key $value" }
return [join $lines \n]
}
# The summary table of a report given as text, or the input error it raises.
proc summary_of {text} {
set file [makeFile $text report.txt]
try {
return [report::table [report::summarise [report::parse $file]]]
} trap {REPORT INPUT} {message} {
return "error: $message"
}
}
# Runs the program on a file, as a user would: its exit status and what it printed.
proc run_program {file} {
set script [file join [file dirname [info script]] summarise.tcl]
set status [catch {exec [info nameofexecutable] $script $file} output options]
if {$status} {
set status [lindex [dict get $options -errorcode] 2]
}
return [list $status $output]
}
test parse-1.1 {one path becomes a dict of whole picoseconds} -body {
set path [lindex [report::parse [makeFile [path_text 7] one.txt]] 0]
list [dict get $path id] [dict get $path required] [dict get $path arrival] [dict get $path slack]
} -result {7 2450 2610 -160}
test parse-1.2 {fields may come in any order} -body {
summary_of "path 1\n slack -0.160\n clock core\n arrival 2.610\n required 2.450\n to b/d\n from a/q"
} -result [join {{clock paths failing wns_ns tns_ns} {core 1 1 -0.160 -0.160}} \n]
test parse-1.3 {comments and blank lines are ignored} -body {
summary_of "# a comment\n\n[path_text 1]\n\n # an indented comment"
} -match glob -result {*core 1 1 -0.160 -0.160}
test parse-1.4 {CR LF line ends read like LF} -body {
summary_of [string map {\n \r\n} [path_text 1]]
} -match glob -result {*core 1 1 -0.160 -0.160}
test parse-1.5 {names keep their Unicode characters} -body {
set path [lindex [report::parse [makeFile [path_text 1 from mémoire/bank_0 clock cœur] u.txt]] 0]
list [dict get $path from] [dict get $path clock] [string length [dict get $path clock]]
} -result {mémoire/bank_0 cœur 4}
test ps-1.1 {a negative slack in ns} -body { report::picoseconds 1 slack -0.160 } -result -160
test ps-1.2 {two decimals} -body { report::picoseconds 1 required 2.45 } -result 2450
test ps-1.3 {no decimals} -body { report::picoseconds 1 required 10 } -result 10000
test ps-1.4 {negative zero is zero} -body { report::picoseconds 1 slack -0.000 } -result 0
test ps-1.5 {four decimals are refused} -body {
report::picoseconds 3 slack 2.4501
} -returnCodes error -result {line 3: slack must be a number of ns with at most 3 decimals, got "2.4501"}
test ps-1.6 {an exponent is refused} -body {
report::picoseconds 4 arrival 1e3
} -returnCodes error -result {line 4: arrival must be a number of ns with at most 3 decimals, got "1e3"}
test error-1.1 {a missing field names the path and its line} -body {
summary_of "# report\n[path_text 1 slack -]"
} -result {error: line 2: path 1 has no slack}
test error-1.2 {a field given twice} -body {
summary_of "[path_text 1]\n clock io"
} -result {error: line 8: path 1 gives clock twice}
test error-1.3 {an unknown field} -body {
summary_of "path 1\n from a/q\n slak -0.160"
} -result {error: line 3: unknown field "slak"}
test error-1.4 {a field before the first path line} -body {
summary_of " clock core\n[path_text 1]"
} -result {error: line 1: the field "clock" comes before the first path line}
test error-1.5 {the slack must be required - arrival} -body {
summary_of [path_text 4 slack -0.150]
} -result {error: line 1: path 4: slack is not required - arrival}
test error-1.6 {a line that is neither a path nor a field} -body {
summary_of "[path_text 1]\nslack -0.160"
} -result {error: line 8: expected "path <number>" or an indented field, got "slack -0.160"}
test error-1.7 {an empty file has no paths} -body {
set file [makeFile {} empty.txt]
writeFile $file {}
try { report::parse $file } trap {REPORT INPUT} {message} { set message }
} -result {line 1: the report has no paths}
test error-1.8 {a byte that is not UTF-8 is reported on its line} -body {
set file [makeFile {} bytes.txt]
writeFile $file binary "path 1\n from a/q\n to b/d\n clock c\xffre\n"
try { report::parse $file } trap {REPORT INPUT} {message} { set message }
} -result {line 4: the bytes are not valid UTF-8}
test summary-1.1 {block_a: worst slack first, then clock name} -body {
report::table [report::summarise [report::parse [file join [file dirname [info script]] block_a.txt]]]
} -result [join {
{clock paths failing wns_ns tns_ns}
{core 3 2 -0.160 -0.215}
{mem 1 1 -0.060 -0.060}
{io 1 0 0.420 0.000}
} \n]
test summary-1.2 {equal worst slacks are sorted by clock name} -body {
summary_of "[path_text 1 clock io]\n[path_text 2 clock core]"
} -result [join {
{clock paths failing wns_ns tns_ns}
{core 1 1 -0.160 -0.160}
{io 1 1 -0.160 -0.160}
} \n]
test summary-1.3 {a clock without failing paths has a positive wns and no tns} -body {
summary_of [path_text 1 required 2.700 arrival 2.610 slack 0.090]
} -match glob -result {*core 1 0 0.090 0.000}
test program-1.1 {a good report: exit status 0 and the table} -body {
run_program [file join [file dirname [info script]] block_a.txt]
} -match glob -result {0 {clock *io 1 0 0.420 0.000}}
test program-1.2 {a bad report: exit status 2 and one line on standard error} -body {
run_program [makeFile [path_text 1 arrival -] bad.txt]
} -result {2 {line 1: path 1 has no arrival}}
test speed-1.1 {10,000 paths in under 1 s, the fastest of three runs} -setup {
set lines {}
for {set i 1} {$i <= 10000} {incr i} {
lappend lines [path_text $i clock [lindex {core io mem} [expr {$i % 3}]]]
}
set file [makeFile [join $lines \n] big.txt]
} -body {
set best Inf
for {set run 0} {$run < 3} {incr run} {
set start [clock microseconds]
report::summarise [report::parse $file]
set best [expr {min($best, [clock microseconds] - $start)}]
}
expr {$best < 1000000}
} -result 1
set failed $::tcltest::numTests(Failed)
cleanupTests
exit [expr {$failed > 0}] from session import run
# The project's 25 tests, with tcltest.
run("""
cd report
tclsh9.0 summarise.test
""") Output
$ cd report $ tclsh9.0 summarise.test summarise.test: Total 25 Passed 25 Skipped 0 Failed 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tests_session.py
The cases fall into four groups: parsing, the conversion to picoseconds, the error messages and the summary, plus the
two black-box test: A test that runs the whole program as a user would, with an input file, and compares only what comes out, standard output, standard error and the exit status, with expected files, without looking at the code inside. More in “Project: a marks report in Object Pascal” and a time budget. The Unicode case, parse-1.5, is the one that
would catch the %n trap of the second section: a check built on %n passes every ASCII case and refuses the line
with mémoire/bank_0, whose count was 22 for 21 characters. The speed test builds 10,000 paths in -setup and
asserts that the fastest of three runs of parsing and summarising takes under 1 s; taking the fastest of three keeps a
short burst of other work on the same computer from failing the test. This session times the same work on its own:
from session import run
# A report of 10,000 paths, parsed and summarised three times; the fastest run is printed.
run("""
cd report
tclsh9.0 timing.tcl
""") Output
$ cd report $ tclsh9.0 timing.tcl 10,000 paths: 164 ms, the fastest of three runs clock paths failing wns_ns tns_ns core 3333 1444 -0.584 -297.756 io 3334 1450 -0.584 -299.796 mem 3333 1445 -0.584 -297.748
This output changes from run to run: the time depends on the computer and changes from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 speed_session.py
The time on the Mac that recorded the run is in its output; the table under it is the same on every run. Write new expected results from the specification, never by copying what the program printed: then the tests define the summariser, and a second implementation, such as the one you write next, becomes a test oracle: A second, trusted way to work out the expected results of tests, such as a short program written straight from the specification, used to check an implementation on many inputs. for it.
Checkpoint
Check: The tcltest suite
Practise: write the numbers a second time, in Python
In the lab you write the two halves of the summariser’s arithmetic again from the specification, in Python: first the
conversion between nanosecond text and whole picoseconds, then the summary per clock with its two-key order. Their
sample tests replay block_a.txt, the walkthrough and the error rules, so passing code agrees with the Tcl program.
Code lab · 2 steps
Check the summariser's numbers with a second implementation
Convert report times to whole picoseconds and back, then summarise the slack of each clock, worst first.
- Turn nanoseconds into whole picoseconds and back To do
- Summarise the slack of each clock, worst first To do
Exercise · Easy · Python
Turn nanoseconds into whole picoseconds and back
The summariser keeps every time in whole picoseconds, so that sums of slacks are exact. Write the two conversions of the project's specification in picoseconds.py:
picoseconds(text)turns a value of the report, in nanoseconds, into anintof picoseconds: an optional minus sign, one or more digits, and at most three decimals after a point."-0.160"is-160,"2.45"is2450,"10"is10000and"-0.000"is0. Anything else raisesValueErrorwith the summariser's words:must be a number of ns with at most 3 decimals, got "2.4501".ns_text(ps)writes picoseconds back as the table prints them, with exactly three decimals:-160is"-0.160",2450is"2.450",0is"0.000"and-297756is"-297.756".
The sample tests use the values of the lesson's walkthrough and tables. Work with whole numbers: the walkthrough's sum of -0.120 and -0.050 as floats printed -0.16999999999999998, where whole picoseconds give -170.
Starter code · picoseconds.py
def picoseconds(text):
"""'-0.160' (ns) -> -160 (whole picoseconds)."""
# Replace this with your code: a float loses digits and accepts text the report must refuse.
return int(float(text) * 1000)
def ns_text(ps):
"""-160 -> '-0.160', with exactly three decimals."""
return str(ps / 1000) The sample tests · test_picoseconds.py
from picoseconds import ns_text, picoseconds
def _refusal(text):
"""The message of the ValueError that picoseconds raises for text, or None."""
try:
picoseconds(text)
except ValueError as error:
return str(error)
return None
def test_values_of_the_walkthrough():
"""the four conversions that walkthrough.tcl printed"""
assert picoseconds("2.450") == 2450
assert picoseconds("-0.160") == -160
assert picoseconds("10") == 10000
assert picoseconds("-0.000") == 0
def test_fewer_decimals_and_leading_zeros():
"""one or two decimals, and digits with leading zeros, are whole picoseconds too"""
assert picoseconds("2.45") == 2450
assert picoseconds("0.1") == 100
assert picoseconds("007.5") == 7500
assert picoseconds("-12.005") == -12005
def test_values_the_report_must_refuse():
"""four decimals, an exponent, a plus sign, a comma or spaces are refused"""
for text in ["2.4501", "1e3", "+2.450", "2,450", " 2.450", "", "-", "2."]:
assert _refusal(text) == f'must be a number of ns with at most 3 decimals, got "{text}"', text
def test_ns_text_of_the_tables():
"""the values that the lesson's tables printed"""
assert ns_text(-160) == "-0.160"
assert ns_text(2450) == "2.450"
assert ns_text(0) == "0.000"
assert ns_text(-297756) == "-297.756"
assert ns_text(420) == "0.420"
def test_round_trip():
"""writing a value and reading it back gives the same picoseconds"""
for ps in [-215, -60, 55, 1, -1, 999, 1000, 123456]:
assert picoseconds(ns_text(ps)) == ps A hint
For picoseconds, check the whole text with re.fullmatch(r"-?[0-9]+(\.[0-9]{1,3})?", text) first. Then split at the point, pad the decimals with zeros to three digits, and build the number from the two parts with whole numbers. For ns_text, divmod(abs(ps), 1000) gives the part before the point and the three digits after it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Summarise the slack of each clock, worst first
This is the summariser's second half, written again from the specification: a second implementation that the Tcl program's results can be checked against. Write summarise(paths) in summary.py. Each path is a dict with at least "clock" (a name) and "slack" (whole picoseconds, as the first step of this lab returns them). Return one tuple per clock, (clock, paths, failing, wns, tns):
pathscounts the clock's paths, andfailingthose whose slack is below 0;wns, the worst slack, is the smallest slack of the clock's paths, even when it is positive;tns, the total negative slack, is the sum of the slacks below 0, and 0 when none fails.
Sort the tuples by wns, smallest first, and clocks with the same wns by name. For the lesson's block_a.txt that is core, mem, io, as the Tcl table printed. An empty list of paths gives an empty list.
Starter code · summary.py
def summarise(paths):
"""One (clock, paths, failing, wns, tns) tuple per clock, worst slack first, then by name."""
# Replace this with your code: it sorts by name and adds every slack, positive ones included.
clocks = {}
for path in paths:
clocks.setdefault(path["clock"], []).append(path["slack"])
return [(clock, len(s), 0, min(s), sum(s)) for clock, s in sorted(clocks.items())] The sample tests · test_summary.py
from summary import summarise
BLOCK_A = [
{"clock": "core", "slack": -160},
{"clock": "core", "slack": 55},
{"clock": "io", "slack": 420},
{"clock": "core", "slack": -55},
{"clock": "mem", "slack": -60},
]
def test_block_a_from_the_lesson():
"""the table that summarise.tcl printed for block_a.txt"""
assert summarise(BLOCK_A) == [
("core", 3, 2, -160, -215),
("mem", 1, 1, -60, -60),
("io", 1, 0, 420, 0),
]
def test_equal_worst_slack_sorted_by_name():
"""clocks with the same worst slack come in name order"""
paths = [{"clock": "io", "slack": -160}, {"clock": "core", "slack": -160}]
assert summarise(paths) == [("core", 1, 1, -160, -160), ("io", 1, 1, -160, -160)]
def test_a_clock_that_meets_timing():
"""no failing path: failing 0, tns 0, and the worst slack stays positive"""
paths = [{"clock": "core", "slack": 90}, {"clock": "core", "slack": 30}]
assert summarise(paths) == [("core", 2, 0, 30, 0)]
def test_zero_slack_is_not_failing():
"""a slack of exactly 0, as -0.000 became, does not fail"""
assert summarise([{"clock": "core", "slack": 0}]) == [("core", 1, 0, 0, 0)]
def test_no_paths():
"""no paths, no rows"""
assert summarise([]) == [] A hint
Collect the slacks of each clock in a dict of lists first, then compute the four numbers of each clock from its list. sorted(rows, key=lambda row: (row[3], row[0])) sorts by the worst slack and then by the name in one step.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the five parts of the project, from its format to its tests. Your least certain part comes up first, and each choice is explained once you pick it:
Practice that adapts to you
Practise this lesson
14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): Why does the summariser keep slack in whole picoseconds instead of in nanoseconds as reals?
Because sums of reals are not exact, and the total negative slack is a sum. A time such as 0.120 ns has no exact
binary form, so adding reals collects tiny errors: in the walkthrough, -0.120 + -0.050 printed
-0.16999999999999998. Over 10,000 paths such errors grow, and a total printed with three decimals can come out one
digit off, or a value that should be exactly zero can come out as a tiny negative number and count as failing. The
report gives times with at most three decimals in nanoseconds, so each one is a whole number of picoseconds: the
parser converts once, every sum and comparison after that is integer arithmetic and exact, and report::ns writes the
result back with exactly three decimals. The same habit fits money in paise or cents.
Two longer questions about tools that read reports like this one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
The summariser has worked for months. A team switches to a new tool that writes the same report format, and now one clock's total negative slack is wrong by a few picoseconds while every test passes. How do you find the cause and keep it from coming back?
-
Mid to staff
You inherit a Tcl report parser with no tests and must change its output format next week. Which tests do you write first, how do you choose their inputs and expected results, and how do you keep the suite fast and reliable?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is the slack of a timing path, and what does a negative one mean?
Required time minus arrival time. A negative slack means the signal arrives too late, so the path fails timing.
-
How does the summariser define a clock's worst slack and total negative slack?
Worst slack: the smallest slack of its paths, even when positive. Total negative slack: the sum of the negative slacks, 0 if none fails. core's were −0.160 and −0.215 ns.
-
How does the parser check that a field line holds exactly two words?
scan $text {%s %s %s} key value extra returns how many words it filled: 2 is a field, 3 means text after the value, and 1 a value that is missing.
-
Why is scan's %n no safe way to find the end of a line on Tcl 9.0.4?
It counted UTF-8 bytes, 22 for a 21-character line holding mémoire. A check built on it passes every ASCII report and refuses names with letters such as é.
-
Why are all times turned into whole picoseconds as soon as they are read?
A time of at most three decimals in ns is a whole number of ps, so every sum after that is exact. As reals, −0.120 + −0.050 printed −0.16999999999999998.
-
How does the summariser order its rows by worst slack, with ties by clock name?
It sorts twice, first by name and then by worst slack. lsort is a stable merge sort, so rows with equal worst slack keep the name order of the first sort.
-
How does the summariser name the line that holds a byte which is not UTF-8?
Its strict UTF-8 read raises EILSEQ and returns the text decoded before the bad byte in -data. Counting the lines of that text gave line 4 for bad_bytes.txt.
-
Why does the speed test time the fastest of three runs over 10,000 paths?
So that a brief burst of other work on the same computer cannot fail the test. The budget is under 1 s to parse and summarise them.
Key takeaways
- A specification that fixes the format, the two summary numbers, the order of ties and every error message, each with its test, is what lets the summariser be checked to the byte.
regexpwith anchors tells path lines apart andscanwith three%sfinds exactly two words in a field line; on Tcl 9.0.4,%ncounted UTF-8 bytes, so it is not a safe end-of-line check for Unicode text.- Times become whole picoseconds as they are read, so sums such as −160 + −55 = −215 are exact, where reals printed −0.16999999999999998 for −0.120 + −0.050.
- A dict per clock gathers paths, failing paths, the worst slack and the total; two stable
lsortcalls order the table by worst slack and then by name. - A strict UTF-8 read reports the line of a bad byte, every input error is
line N: reasonon standard error with exit status 2, and 25 tcltest cases, Unicode and a 1 s budget for 10,000 paths included, pin all of it down.
Terms in this lesson
- slack
- In a timing report, the time a path has to spare: its required time minus its arrival time. A negative slack means the signal arrives too late, so the path fails timing.
- total negative slack (TNS)
- The sum of the negative slacks of a clock's paths, and 0 when none fails. It measures the work left across all failing paths, where the worst negative slack shows only the worst one.
- worst negative slack (WNS)
- The smallest slack among a clock's paths, the path most in need of repair. The summariser of this course prints it for every clock, as a positive number when no path fails.
References
- Tcl 9.0 manual: regexp (Tcl Core Team)
- Tcl 9.0 manual: scan (%s and %n) (Tcl Core Team)
- Tcl 9.0 manual: string (Tcl Core Team)
- Tcl 9.0 manual: dict (update and with) (Tcl Core Team)
- Tcl 9.0 manual: lsort (a stable merge sort) (Tcl Core Team)
- Tcl 9.0 manual: chan (configure -encoding, -profile and -translation) (Tcl Core Team)
- Tcl 9.0 manual: encoding (profiles strict, tcl8 and replace) (Tcl Core Team)
- Tcl/Tk 9.0 release page (strict decoding by default) (Tcl Core Team)
- Tcl 9.0 manual: open (Tcl Core Team)
- Tcl 9.0 manual: tcltest (Tcl Core Team)
- Tcl 9.0 manual: exec (CHILDSTATUS) (Tcl Core Team)
- Tcl 9.0 manual: clock (microseconds) (Tcl Core Team)
- OpenROAD Flow Scripts: flow variables (WNS and TNS in timing repair) (The OpenROAD Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress