Skip to content
VLSI Mentor

Perl Part II · Module 24 · Coverage and Regression Reporting

Trend Data: Comparing Today With Yesterday

The only question anybody asks of a nightly regression is what changed. Three lists answer it — newly failing, newly passing, still failing — and on this corpus the third list is a trap: all five of its entries are failing for a reason that was not there last night. This chapter builds the diff over the CSV files of the last two nights, and measures two ways the obvious loop reports a quiet night that did not happen.

Advanced12 min readPerlPart IIRegression Reporting

Part II · Module 24 · Chapter 24.11 · Trend Data: Comparing Today With Yesterday

1. Three Lists

The diff reads two files, not two data structures — last night's process is gone, and all that remains of it is what it wrote.

Azvya Education Pvt. Ltd.VLSI Mentor
trend.pl
use strict;
use warnings;

my %RANK = (FAIL => 0, TIMEOUT => 1, UNKNOWN => 2, PASS => 3);
my @COLS = qw(test verdict seeds fails first_id);

sub csv_field {
    my ($v) = @_;
    $v = '' unless defined $v;
    return $v =~ /[",\n]/ ? '"' . ($v =~ s/"/""/gr) . '"' : $v;
}
sub csv_row { return join(",", map { csv_field($_) } @_) . "\n" }
sub csv_parse {
    my ($line) = @_;
    $line =~ s/\r?\n\z//;
    my @f;
    pos($line) = 0;
    while (1) {
        if ($line =~ /\G"((?:[^"]|"")*)"/gc) { my $v = $1; $v =~ s/""/"/g; push @f, $v }
        elsif ($line =~ /\G([^,]*)/gc)       { push @f, $1 }
        last unless $line =~ /\G,/gc;
    }
    return @f;
}

# ---- one night's per-test rows, written as the CSV of 24.8
sub night_rows {
    my ($dir) = @_;
    my %by;
    for my $p (sort glob("$dir/*.log")) {
        open my $fh, "<", $p or next;
        my %r = (verdict => "UNKNOWN");
        while (my $l = <$fh>) {
            $r{test} = $1 if $l =~ /^TEST:\s+(\S+)/;
            $r{seed} = $1 if $l =~ /^SEED:\s+(\d+)/;
            $r{budget} = 1 if $l =~ /^UVM_FATAL\s.*\[BUDGET\]/;
            if ($l =~ /^UVM_ERROR\s+\S+\(\d+\)/ && !defined $r{first_id}) {
                $r{first_id} = $1 if $l =~ /\[(\w+)\]/;
            }
            $r{verdict} = "PASS" if $l =~ /^TEST PASSED/;
            $r{verdict} = "FAIL" if $l =~ /^TEST FAILED/;
        }
        close $fh;
        $r{verdict} = "TIMEOUT" if $r{budget} && $r{verdict} eq "UNKNOWN";
        push @{ $by{ $r{test} } }, \%r if defined $r{test};
    }
    my @rows;
    for my $t (sort keys %by) {
        my @r = sort { $a->{seed} <=> $b->{seed} } @{ $by{$t} };
        my ($worst) = sort { $RANK{$a} <=> $RANK{$b} } map { $_->{verdict} } @r;
        my $fails = grep { $_->{verdict} eq "FAIL" } @r;
        my ($id) = grep { defined } map { $_->{first_id} }
                   grep { $_->{verdict} eq "FAIL" } @r;
        push @rows, [$t, $worst, scalar @r, $fails, $id // ""];
    }
    return @rows;
}

for my $pair (["prev", "prev.csv"], ["logs", "today.csv"]) {
    my ($dir, $file) = @$pair;
    open my $o, ">", $file or die $!;
    print {$o} csv_row(@COLS);
    print {$o} csv_row(@$_) for night_rows($dir);
    close $o;
}
printf "wrote prev.csv and today.csv\n";

sub load {
    my ($file) = @_;
    open my $fh, "<", $file or die "$file: $!";
    my @hdr = csv_parse(scalar <$fh>);
    my %by;
    while (my $l = <$fh>) {
        my @f = csv_parse($l);
        my %r;
        @r{@hdr} = @f;
        $by{ $r{test} } = \%r;
    }
    close $fh;
    return \%by;
}
my $was = load("prev.csv");
my $now = load("today.csv");
printf "prev.csv %d test(s), today.csv %d test(s)\n",
    scalar keys %$was, scalar keys %$now;

# ---- 1. the three lists
print "\n==== the three lists ====\n";
my (@newly_fail, @newly_pass, @unchanged);
for my $t (sort keys %$now) {
    my $n = $now->{$t}{verdict};
    my $w = $was->{$t} ? $was->{$t}{verdict} : undef;
    if    (!defined $w)            { next }
    elsif ($n ne "PASS" && $w eq "PASS") { push @newly_fail, $t }
    elsif ($n eq "PASS" && $w ne "PASS") { push @newly_pass, $t }
    elsif ($n eq $w)                     { push @unchanged, $t }
}
printf "newly failing : %d  %s\n", scalar @newly_fail, join " ", @newly_fail;
printf "newly passing : %d  %s\n", scalar @newly_pass, join " ", @newly_pass;
printf "unchanged     : %d  %s\n", scalar @unchanged,  join " ", @unchanged;

# ---- 2. 'unchanged' is not the same as 'nothing happened'
print "\n==== did the unchanged tests fail for the same reason? ====\n";
printf "%-18s %-9s %-11s %-11s %s\n", "TEST", "VERDICT", "WAS", "NOW", "";
my $reason_moved = 0;
for my $t (@unchanged) {
    my ($a, $b) = ($was->{$t}{first_id}, $now->{$t}{first_id});
    $a = "-" if !defined $a || $a eq "";
    $b = "-" if !defined $b || $b eq "";
    my $moved = $a ne $b;
    $reason_moved++ if $moved;
    printf "%-18s %-9s %-11s %-11s %s\n", $t, $now->{$t}{verdict}, $a, $b,
        $moved ? "SAME VERDICT, DIFFERENT REASON" : "same reason";
}
printf "\n%d of %d unchanged tests changed their failure reason\n",
    $reason_moved, scalar @unchanged;
print "a verdict-only diff calls all of them 'still failing', which reads as\n";
print "'nothing happened' -- and what happened is that one bug was fixed and\n";
print "another was introduced across the whole regression\n";

# ---- 3. the 'ne PASS' rule accuses a test that never failed
print "\n==== what 'ne PASS' puts in the failing lists ====\n";
for my $t (sort keys %$now) {
    next unless ($now->{$t}{verdict} // "") !~ /^(PASS|FAIL)$/;
    printf "  %-18s was %-8s now %-8s fails %s of %s seeds\n",
        $t, $was->{$t}{verdict} // "-", $now->{$t}{verdict},
        $now->{$t}{fails}, $now->{$t}{seeds};
}
print "arb_budget is TIMEOUT on both nights with 0 failing seeds on each, so\n";
print "it lands in 'unchanged' -- correct -- but a rule written as\n";
print "'ne PASS' would have called it failing on both nights\n";

# ---- 4. a test that disappears is invisible to a diff over today's keys
print "\n==== a test that vanished ====\n";
my @gone = grep { !exists $now->{$_} } sort keys %$was;
my @added = grep { !exists $was->{$_} } sort keys %$now;
printf "in prev.csv but not in today.csv : %d %s\n", scalar @gone, join " ", @gone;
printf "in today.csv but not in prev.csv : %d %s\n", scalar @added, join " ", @added;
# now take one away and look again
my %short = %$now;
delete $short{arb_stress};
my @lists_over_now = grep {
    my $n = $short{$_}{verdict};
    my $w = $was->{$_}{verdict};
    $n ne "PASS" && $w eq "PASS"
} sort keys %short;
printf "\nwith arb_stress removed from today's file:\n";
printf "  iterating today's keys  -> newly failing: %d %s\n",
    scalar @lists_over_now, join " ", @lists_over_now;
my @vanished = grep { !exists $short{$_} } sort keys %$was;
printf "  iterating the UNION     -> vanished: %d %s\n",
    scalar @vanished, join " ", @vanished;
print "arb_stress was the only newly-failing test. Dropping its row makes the\n";
print "diff report nothing wrong at all, because the loop never visits a test\n";
print "today's file does not mention. Iterate the UNION of both nights.\n";

# ---- 5. the report
print "\n==== the trend report ====\n";
printf "REGRESSION TREND  %s -> %s\n", "2026-10-12", "2026-10-13";
printf "  newly failing : %d\n", scalar @newly_fail;
printf "    %-18s was %-8s now %-8s first error %s\n",
    $_, $was->{$_}{verdict}, $now->{$_}{verdict},
    $now->{$_}{first_id} || "-" for @newly_fail;
printf "  newly passing : %d\n", scalar @newly_pass;
printf "    %-18s was %-8s now %-8s\n",
    $_, $was->{$_}{verdict}, $now->{$_}{verdict} for @newly_pass;
printf "  still failing : %d\n",
    scalar grep { $now->{$_}{verdict} eq "FAIL" } @unchanged;
for my $t (@unchanged) {
    next unless $now->{$t}{verdict} eq "FAIL";
    my ($a, $b) = ($was->{$t}{first_id} || "-", $now->{$t}{first_id} || "-");
    printf "    %-18s %s%s\n", $t, $b,
        $a ne $b ? "   (was $a -- the reason CHANGED)" : "";
}
printf "  unchanged and not failing : %d  %s\n",
    scalar(grep { $now->{$_}{verdict} ne "FAIL" } @unchanged),
    join " ", grep { $now->{$_}{verdict} ne "FAIL" } @unchanged;
printf "  tests in one night only   : %d\n", scalar(@gone) + scalar(@added);
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
wrote prev.csv and today.csv
prev.csv 9 test(s), today.csv 9 test(s)

==== the three lists ====
newly failing : 1  arb_stress
newly passing : 2  arb_single arb_smoke
unchanged     : 6  arb_budget arb_light arb_random arb_random_short arb_soak arb_trunc

==== did the unchanged tests fail for the same reason? ====
TEST               VERDICT   WAS         NOW         
arb_budget         TIMEOUT   -           -           same reason
arb_light          FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_random         FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_random_short   FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_soak           FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_trunc          FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON

5 of 6 unchanged tests changed their failure reason
a verdict-only diff calls all of them 'still failing', which reads as
'nothing happened' -- and what happened is that one bug was fixed and
another was introduced across the whole regression

==== what 'ne PASS' puts in the failing lists ====
  arb_budget         was TIMEOUT  now TIMEOUT  fails 0 of 4 seeds
arb_budget is TIMEOUT on both nights with 0 failing seeds on each, so
it lands in 'unchanged' -- correct -- but a rule written as
'ne PASS' would have called it failing on both nights

==== a test that vanished ====
in prev.csv but not in today.csv : 0 
in today.csv but not in prev.csv : 0 

with arb_stress removed from today's file:
  iterating today's keys  -> newly failing: 0 
  iterating the UNION     -> vanished: 1 arb_stress
arb_stress was the only newly-failing test. Dropping its row makes the
diff report nothing wrong at all, because the loop never visits a test
today's file does not mention. Iterate the UNION of both nights.

==== the trend report ====
REGRESSION TREND  2026-10-12 -> 2026-10-13
  newly failing : 1
    arb_stress         was PASS     now FAIL     first error GNT_VEC
  newly passing : 2
    arb_single         was FAIL     now PASS    
    arb_smoke          was FAIL     now PASS    
  still failing : 5
    arb_light          GNT_VEC   (was GNT_VALID -- the reason CHANGED)
    arb_random         GNT_VEC   (was GNT_VALID -- the reason CHANGED)
    arb_random_short   GNT_VEC   (was GNT_VALID -- the reason CHANGED)
    arb_soak           GNT_VEC   (was GNT_VALID -- the reason CHANGED)
    arb_trunc          GNT_VEC   (was GNT_VALID -- the reason CHANGED)
  unchanged and not failing : 1  arb_budget
  tests in one night only   : 0

Figure 24.11.1 — The trend, from two files to three lists

A six-step trend pipeline: read last night's CSV, read tonight's CSV, key both by test name, classify every test in the union of the two nights, compare the first-error id as well as the verdict, and publish newly failing, newly passing, still failing and vanished.Two nights, four lists1Read last nightprev.csv, written by 24.82Read tonighttoday.csv, same writer3Key both by testone row per test name4Walk the UNIONor a vanished test is missed5Compare the reasonnot only the verdict6Publish four listsfailing, passing, same, gone
Two things in this flow are easy to get wrong and both are measured in this chapter. The classification walks the union of the two nights rather than tonight's tests, because a test that disappears is otherwise never visited. And the comparison looks at the failure reason as well as the verdict, because a test can keep the same verdict for a completely different cause — which happened to five of six here.

The three lists the registry asks for come out like this:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
newly failing : 1  arb_stress
newly passing : 2  arb_single arb_smoke
unchanged     : 6  arb_budget arb_light arb_random arb_random_short arb_soak arb_trunc

One test started failing, two started passing. Read that on its own and the night looks like modest progress.

2. "Unchanged" Is Not "Nothing Happened"

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
TEST               VERDICT   WAS         NOW
arb_budget         TIMEOUT   -           -           same reason
arb_light          FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_random         FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_random_short   FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_soak           FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON
arb_trunc          FAIL      GNT_VALID   GNT_VEC     SAME VERDICT, DIFFERENT REASON

5 of 6 unchanged tests changed their failure reason

Five of the six tests with an unchanged verdict are failing for a different reason than last night.

What happened to this design is not subtle once the ids are visible. Last night the arbiter asserted a grant when nobody was requesting the bus — GNT_VALID. Tonight that is fixed and the round-robin pointer no longer advances — GNT_VEC. One bug out, one bug in, and a verdict-only diff describes it as five tests that are still failing, which is the sentence people read as "no change, carry on".

The two bugs even sort the tests differently, which is the other half of the story: arb_stress never idles the bus, so it could not see last night's bug and it does see tonight's; arb_smoke and arb_single drive one requester at a time, so they saw last night's and cannot see tonight's. The three lists are a shadow of which stimulus profile notices which bug.

So the comparison key is the pair, not the verdict:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
($was->{$t}{verdict}, $was->{$t}{first_id})  against  ($now->{$t}{verdict}, $now->{$t}{first_id})

That is why Chapter 24.9 put the first-error id in the published table. A column that looked like a convenience is the only thing distinguishing "still broken" from "differently broken".

3. "Not A Pass" Accuses The Innocent, Again

The registry's own sketch for this chapter is written with ne 'PASS':

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my @new_fail = grep { $now{$_} ne 'PASS' && ($was{$_} // '') eq 'PASS' } sort keys %now;

and the corpus has exactly the test that catches it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  arb_budget         was TIMEOUT  now TIMEOUT  fails 0 of 4 seeds

arb_budget is TIMEOUT on both nights with zero failing seeds on each. It is genuinely unchanged — the cycle budget stopped it both times — and under ne 'PASS' it reads as failing on both nights, so it would appear in a "still failing" list it has no business in.

Chapter 24.2 measured this rule misreporting two of nine tests; here it is again at the trend layer. The fix is the same three characters, and the reason to keep saying it is that ne 'PASS' is genuinely the shorter and more natural thing to write.

4. A Test That Vanished Is Invisible

Now the defect that is hardest to see, because it hides in the loop rather than in the comparison:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
with arb_stress removed from today's file:
  iterating today's keys  -> newly failing: 0
  iterating the UNION     -> vanished: 1 arb_stress

Delete one row from tonight's file and the diff reports nothing wrong at all. arb_stress was the only newly-failing test, and sort keys %$now never visits a test tonight's file does not mention. The report comes out clean, with correct arithmetic, describing a regression in which a test silently stopped existing.

That is the third time this exact shape has appeared in two modules:

wherewhat disappearedwhat the report said
23.9one corner's cell in a slack storea VIOLATED endpoint read 0.5070 MET
24.4a bin absent from the expected list40 of 40 bins hit — 100%
24.11a test absent from tonight's filenewly failing: 0

A reduction over the wrong set cannot see what is missing from it. So the loop walks the union and the report carries a fourth list:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for my $t (sort keys %{ { %$was, %$now } }) { ... }

and then exists (6.4) distinguishes the three cases a test can be in: in both nights, only in last night's, or only in tonight's. A test that is new is usually fine; a test that vanished is usually not.

5. The Report

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
REGRESSION TREND  2026-10-12 -> 2026-10-13
  newly failing : 1
    arb_stress         was PASS     now FAIL     first error GNT_VEC
  newly passing : 2
    arb_single         was FAIL     now PASS
    arb_smoke          was FAIL     now PASS
  still failing : 5
    arb_light          GNT_VEC   (was GNT_VALID -- the reason CHANGED)
  unchanged and not failing : 1  arb_budget
  tests in one night only   : 0

Four lists and two counts. Every line says what changed, and the "still failing" entries say that their reason changed — so nobody reads this report as a quiet night.

And the last line is the one that is worth having precisely because it is usually zero. tests in one night only: 0 is the report asserting that it compared the same set of tests on both nights. Chapter 24.9 put its self-checks in the published output for the same reason: a reader three weeks later cannot re-run the check, so the artefact has to carry it.

6. What To Remember

  • The diff is over files, not data structures — last night's process is gone, and all that remains is what it wrote (which is why 24.8's stable columns and 24.9's deterministic rows came first).
  • Three lists: newly failing, newly passing, still failing. Measured here: 1, 2 and 5.
  • Measured: 5 of the 6 unchanged tests changed their failure reason, GNT_VALID to GNT_VEC.
  • An unchanged verdict is not an unchanged situation — one bug was fixed and another introduced, and a verdict-only diff calls that "still failing".
  • Compare the pair (verdict, first_id), which is why the id is in the published table (24.9).
  • The three lists are a shadow of which stimulus profile notices which bug — arb_stress never idles, so it could not see last night's bug and does see tonight's.
  • Measured: arb_budget is TIMEOUT both nights with 0 failing seeds, and ne 'PASS' would list it as failing twice (24.2).
  • Measured: deleting the only newly-failing row made the diff report newly failing: 0.
  • sort keys %$now never visits a test tonight's file does not mention — walk the union of both nights.
  • This is the third appearance of one shape: a missing cell (23.9), a bin outside the expected list (24.4), a test missing from a file (24.11). A reduction over the wrong set cannot see what is missing from it.
  • Use exists to separate "in both", "only last night" and "only tonight" (6.4) — a new test is usually fine, a vanished one usually is not.
  • Publish a fourth list and a count of tests present in one night only, even when it is zero: that line is the report asserting it compared the same set twice.

That closes Module 24. The module began by deciding what one test's result holds and ended by comparing two nights of them, and the same finding kept returning in different costumes: a number is only as good as the set it was computed over. A pass rate without its denominator, a coverage percentage without its bin list, a closure figure that averaged rates, a filtered total, a CSV column order taken from a hash, a diff over one night's keys — every one of them was arithmetically correct and described something that had not happened.

Module 25 moves upstream of all of it. Instead of reading what a regression produced, Perl starts the runs: the test list, the seeds and plusargs, launching and capturing, retrying what failed, and making the whole thing reproducible — which is the only way the reports in this module can be trusted twice.

Where this fits

Part of the Perl curriculum.