UCIe · Module 20
UCIe Scoreboards
Building a distributed UCIe scoreboard that follows obligations rather than expected packets — four independent models instead of one class, associative storage keyed by identity and generation, correlation across semantic operations, transport objects and physical attempts, epoch tracking, recovery-safe state, and a first-divergence report that names the layer instead of the symptom.
Chapter 20.3 made properties fail at the first illegal cycle. But it also found the boundary they cannot cross: an SVA attempt cannot follow an obligation that lives for thousands of cycles, completes out of order, spans retries and survives recoveries. This chapter builds what can.
1. The One-Sentence Model
A scoreboard is a model of obligations, not a queue of expected packets. Every accepted event creates an obligation; every completion discharges one; and the scoreboard's job is to answer, at any cycle, what exists, who owns it, what may still arrive, what has already happened, what must never happen twice, and what survives a recovery.
The "queue of expected packets" model is not merely weaker — it is structurally incapable of the four things UCIe verification needs most: out-of-order completion, one-to-many object relationships, identity reuse, and state that outlives a link event. §13 is that model and why it breaks.
2. What This Chapter Owns
| Question | Where it is answered |
|---|---|
| Boundary contracts, the five planes, model independence, the layered join as an architecture | 20.1 — Protocol Verification |
| The link lifecycle, reference link model, fault injection, recovery scenarios | 20.2 — Link Verification |
| Temporal properties, triggers, scoping, vacuity, the assertion inventory | 20.3 — UCIe Assertions |
| The full functional-coverage model | 20.5 — UCIe Functional Coverage |
| The UVM environment — agents, sequencers, virtual sequences, factory, configuration | 20.6 — UVM Architecture for UCIe |
| Compliance and interoperability gates | 20.7 — UCIe Compliance Testing |
20.1 said four models are needed. 20.2 said what they must contain. This chapter is how they are built — and the building is where the interesting failures are:
Storage that matches the access pattern (§10–§14). An associative array keyed by identity and generation, not a FIFO of expected values — because out-of-order completion is the normal case and a queue makes it a bug.
Correlation across three object levels (§18–§22), with a join key the design cannot influence and a join that checks itself.
Epochs, and what each model does at each reset kind (§27, §30–§33) — the table that makes a model recovery-safe rather than accidentally correct.
Record lifetime (§34–§36). A scoreboard that ages out records has a silent loss mechanism; one that never ages out has a memory ceiling. Both are real and the resolution is not obvious.
And a first-divergence report (§37–§39) that names a layer and a cycle rather than the symptom at the client boundary.
3. Sourcing
4. Why One Scoreboard Is Wrong
The monolith that every project starts with:
// WRONG — one class, everything in it.
class ucie_scoreboard;
// semantic
int unsigned outstanding[int unsigned];
int unsigned expected_response[int unsigned];
// transport
int unsigned objects[int unsigned];
int unsigned attempts[int unsigned];
int unsigned replay_live[int unsigned];
// resources
int unsigned credit[NUM_CLASSES];
int unsigned occupancy[NUM_STRUCTURES];
// link
int unsigned link_state, link_epoch, cfg_epoch;
cfg_t active_cfg;
// data
bit [7:0] memory[longint];
// ... and it keeps growing
endclassFive specific costs, and the fifth is the one that matters.
A failure names the scoreboard, not a layer. "The scoreboard reported a mismatch" is not localisation. The whole value of the four-model split is that the failing model is the finding (20.1 §37).
The reset policy becomes one decision for five kinds of state. Semantic obligations must survive a recovery; credit agreements must not; link state must be rebuilt. One clear() on one class cannot express three answers — and 20.2 §12 is what happens when it tries.
The models cannot be independently disabled. A long regression run frequently needs the data model off for performance. In a monolith, turning it off means editing the class that holds everything else.
Ownership becomes unclear. Five engineers touch one file, and the credit logic acquires a dependency on the semantic map because it was convenient.
And the fifth: independence is lost. 20.1 §31's rule is that a model's transitions must derive from contract events. In a monolith it becomes irresistible to derive one model's state from another's — the transport model reads the semantic map's outstanding count instead of counting objects itself — and the moment that happens, the two can no longer disagree, which is the only way either of them detects anything.
Four models. Separate classes, separate state, separate reset policies, separate enable switches, and no model reads another's state except through the explicit join of §22.
5. The Four-Model Architecture
| Model | Owns | Keyed by | Lifetime of a record |
|---|---|---|---|
| Semantic | higher-layer obligations, identity, ordering, completion | monitor tag | accept → terminal (the longest) |
| Transport | Adapter objects, attempts, verdicts, replay history | monitor tag + object id | commit → resolution |
| Resource | credits, occupancies, reservations, outstanding counts | class / structure index | continuous, epoch-scoped |
| Link / config | phase, epochs, negotiation, active configuration | singleton | continuous, run-scoped |
| Data (optional) | expected memory or cache contents | address | run-scoped |
Two notes on the table.
The data model is optional and expensive, and it is the one to switch off first in a long run. It is also the only one that can hold a set-valued expectation (§29), which is what a lost response requires.
And the key column is the architecture. The semantic model is keyed by a tag the design cannot influence; the resource model by an index the design does not choose; the link model is a singleton. Nothing is keyed by a protocol field (§21).
6. The Scoreboard Architecture
Three things to read, and one absence.
One event bus, four consumers. Each model subscribes to the events it needs and ignores the rest. That is what makes a model independently testable — feed it a synthetic event stream and check its state, with no monitors and no design present.
The join is the only place records meet. No arrow runs between models. §4's fifth cost is prevented structurally rather than by discipline.
And the reporter is downstream of the join, not of the models. A single model's failure is a finding; the ordering of failures across models is the localisation, and only the join has that ordering.
The absence: no arrow from the design into any model. Every model input is an observed event (20.1 §9).
7. The Event Bus
// ILLUSTRATIVE, VERIFICATION-ONLY. One event type for every boundary. This is
// NOT a UCIe format and no field corresponds to a wire signal (§3).
typedef enum {
EVT_SEM_ACCEPT, // a client operation was accepted
EVT_SEM_COMPLETE, // it completed
EVT_SEM_FAIL, // it failed explicitly
EVT_OBJ_ALLOC, // a transport object was created for it
EVT_OBJ_COMMIT, // the object was committed to the link
EVT_ATTEMPT, // a physical transmission attempt
EVT_ARRIVAL, // the far end received an attempt
EVT_VERDICT, // an integrity verdict was reached
EVT_DELIVER, // the far end delivered it semantically
EVT_RESOLVE, // the object's reliability obligation ended
EVT_CREDIT_CONSUME,
EVT_CREDIT_RETURN,
EVT_CREDIT_ADVERT,
EVT_RECOVERY_ENTER,
EVT_RECOVERY_EXIT,
EVT_CFG_COMMIT,
EVT_RESET // with a kind field (§33)
} evt_kind_e;
class ucie_event;
evt_kind_e kind;
longint cycle; // per clock domain — §46
int unsigned boundary_id; // which monitor produced it
// ---- identity (§8) ----
int unsigned mon_tag; // THE JOIN KEY — verification-only
int unsigned sem_id; // as OBSERVED; may be legitimately reused
int unsigned generation; // which incarnation of sem_id
int unsigned obj_id; // as observed
int unsigned attempt_num; // 1 for the first transmission
// ---- context ----
int unsigned cls;
int unsigned cfg_epoch;
int unsigned link_epoch;
// ---- payload, where the model needs it ----
bit [63:0] addr;
int unsigned units; // credit units, byte counts
bit verdict_good;
int unsigned reset_kind;
endclassFour design decisions, each with a reason.
One type rather than one per boundary. A single type means the models' case statements are exhaustive and a new event kind is a compile-time obligation everywhere. Per-boundary types drift, and the drift shows up as a model silently ignoring an event it should have handled.
cycle is per clock domain (§46). The four boundaries may be in different domains (19.6 §30), so comparing raw cycle numbers across boundaries compares incomparable quantities. The join is on the tag; time is diagnostic context.
boundary_id is what makes §37's report mechanical. Grouping by boundary and ordering by event produces the divergence table directly.
And both epochs travel on every event. Not just on epoch-change events. A model that has to look up "what was the epoch at that cycle" needs a time-indexed history and gets the answer wrong across a domain boundary — carrying it on the event is cheaper and correct.
8. Event Identity
Five identity fields, and the reason there are five rather than one is that they answer different questions.
| Field | Answers | Unique over |
|---|---|---|
mon_tag | which unit of work is this? | the whole run |
sem_id | what did the design call it? | its own lifetime only |
generation | which incarnation of that name? | wraps (19.2 §22) |
obj_id | which transport object? | its own lifetime only |
attempt_num | which transmission of that object? | per object |
Three rules.
mon_tag is the only key. Everything else is data to be checked. 20.1 §24's argument, and §21 is the structural version of it.
sem_id and generation are recorded together, always. A record holding one without the other cannot distinguish a legitimate reuse from an aliasing bug — and the pair is what §11 exists for.
And attempt_num must be present even when it is always 1. Its absence makes a retransmission indistinguishable from a first transmission of a different object, which is the exact distinction plane 4 is about (20.1 §20).
9. Monitor Independence
// ILLUSTRATIVE. A monitor emits on a CONTRACT event and allocates the tag.
// It holds no expectations and makes no comparisons — that is the model's job.
class ucie_protocol_monitor;
int unsigned next_tag;
mailbox #(ucie_event) out;
task run();
forever begin
@(posedge vif.clk);
if (vif.rst_n && vif.sem_valid && vif.sem_ready) begin // the CONTRACT event
ucie_event e = new();
e.kind = EVT_SEM_ACCEPT;
e.cycle = cycle_count;
e.boundary_id = BND_PROTOCOL;
e.mon_tag = next_tag++; // the monitor owns the tag
e.sem_id = vif.sem_id; // as OBSERVED
e.generation = vif.sem_generation;
e.cls = vif.sem_cls;
e.addr = vif.sem_addr;
e.cfg_epoch = observed_cfg_epoch; // from the management monitor
e.link_epoch = observed_link_epoch;
out.put(e);
end
end
endtask
endclassThree properties of a monitor built this way.
It is passive and opinion-free. No expectations, no comparisons, no correctness logic. A monitor that decides anything has taken a position the models should be taking, and if that position is wrong the models inherit it.
Its trigger is a contract event, not a state value. 20.1 §11's lesson: a monitor that infers an event from state == SENT emits one event per cycle the state persists, and the scoreboard reports duplicates that do not exist.
And it needs its own directed tests. Drive a stalled handshake and confirm exactly one event; drive a multi-beat object and confirm one object event rather than one per beat. A monitor is logic; an unverified monitor fails silently in the passing direction (20.1 §49).
10. The Semantic Model
// ILLUSTRATIVE. The obligation record — the longest-lived state in the
// environment, and the one that must survive a recovery (§31).
typedef enum { OB_OUTSTANDING, OB_COMPLETED, OB_FAILED, OB_ABANDONED } ob_state_e;
class ucie_obligation;
// ---- identity, captured at accept and IMMUTABLE afterwards ----
int unsigned mon_tag;
int unsigned sem_id_at_accept;
int unsigned generation_at_accept;
int unsigned cls;
bit [63:0] addr;
int unsigned cfg_epoch_at_accept; // §27 — the rules this operation obeys
int unsigned link_epoch_at_accept;
longint accept_cycle;
// ---- expectations, derived from the CONTRACT (not from the design) ----
int unsigned expected_response_kind;
int unsigned parts_expected_mask; // a BITMAP, not a count (§15)
int unsigned ordering_group;
// ---- observed progress ----
int unsigned parts_seen_mask; // a BITMAP, for the same reason
int unsigned delivery_count; // MUST end at exactly 1
int unsigned completion_count; // MUST end at exactly 1
int unsigned obj_tags[$]; // transport objects carrying this
int unsigned recoveries_spanned;
// ---- terminal ----
ob_state_e state;
longint terminal_cycle;
string terminal_reason;
endclass
class ucie_semantic_model;
ucie_obligation obligations [int unsigned]; // keyed by mon_tag — §12
int unsigned live_sem_ids [int unsigned]; // sem_id -> mon_tag, for §11
endclassFour decisions in that record.
The identity fields are captured at accept and never rewritten. 20.2 §29's properties depend on this: a completion is checked against the epoch the operation began under, not the current one. A record that reads the live epoch at completion time compares the operation against rules it was never subject to.
Expectations are derived from the contract. expected_response_kind comes from the operation kind and the protocol's rules, not from what the design put in its outstanding table (20.1 §31).
parts_expected_mask and parts_seen_mask are bitmaps. 19.2 §33's argument, on the verification side: a count cannot distinguish "part 2 arrived twice" from "parts 2 and 3 arrived once each." This is the tenth appearance of bitmap, not counter in this curriculum.
And delivery_count and completion_count are separate scalars. A delivery with no completion is work that arrived and was dropped locally; a completion with no delivery is a completion invented on this side. Two serious and different bugs, and one combined counter cannot express either.
11. Why Generation Matters
A design may legitimately reuse a semantic identity after retirement (19.2 §21). The model must distinguish a legal reuse from an aliasing bug, and the pair (sem_id, generation) is how.
// ILLUSTRATIVE. The reuse check, in the model. Note it uses the model's own
// live map, not the design's free-list (20.3 §19).
function automatic void on_sem_accept(ucie_event e);
// A legal reuse: the previous holder of this sem_id is terminal.
if (live_sem_ids.exists(e.sem_id)) begin
int unsigned prev = live_sem_ids[e.sem_id];
if (obligations[prev].state == OB_OUTSTANDING)
report_error(ERR_ID_REUSE_WHILE_LIVE, prev, e.mon_tag,
$sformatf("sem_id %0d reused; tag %0d still outstanding",
e.sem_id, prev));
// A reuse WITHOUT a generation change is indistinguishable at the wire and
// must be flagged as a modelling hazard even when the previous holder is
// terminal — it means a delayed response cannot be attributed.
else if (obligations[prev].generation_at_accept == e.generation)
report_warning(WARN_ID_REUSE_SAME_GEN, prev, e.mon_tag,
"same sem_id and generation reused — responses ambiguous");
end
obligations[e.mon_tag] = make_obligation(e);
live_sem_ids[e.sem_id] = e.mon_tag;
endfunctionThree notes.
The first branch is the aliasing bug — a reuse while the previous holder is still outstanding, which is what 19.2 §21 designs against.
The second branch is subtler and is a warning, not an error. A reuse where the previous holder has retired is legal — but if the generation did not change, a response still in flight for the previous holder is indistinguishable from a response for the new one. The design may be correct and the observability is degraded, and the model should say so rather than silently guessing.
And live_sem_ids maps to a tag, not to a record. A record can be aged out (§34); the map must be updated when it is, or a stale entry points at a record that no longer exists. §35 is that failure.
12. Associative Storage
The access pattern decides the structure, and the access pattern here is random by identity, not sequential.
| Question the model must answer | Access | Right structure |
|---|---|---|
| does obligation with tag T exist? | random by key | associative array |
| what is outstanding right now? | iterate live records | associative array + a live count |
| which obligations belong to ordering group G? | filter | associative array + a per-group index (§48) |
| which obligation holds sem_id S? | random by a secondary key | a second associative array (§11) |
| what is the oldest outstanding obligation? | min by cycle | associative array + a heap or a scan (§48) |
Three notes.
Every row is random access. Nothing here is a queue operation, and that is the structural argument against §13's design.
Secondary indices are needed and must be maintained atomically with the primary. live_sem_ids in §10 is one; an ordering-group index is another. An index updated in one place and not another produces a model that contradicts itself, which §42's self-checks catch.
And the associative array's key must be the tag. Keying by sem_id makes the primary structure lose a record on every legal reuse — the design's identity is not unique over time, and an associative array keyed by a non-unique key silently overwrites.
13. Wrong Scoreboard — The Expected-Packet Queue
// WRONG — the classic first-attempt scoreboard.
class ucie_scoreboard_bad;
ucie_txn expected_q[$];
function void on_request(ucie_txn t);
expected_q.push_back(predict_response(t));
endfunction
function void on_response(ucie_txn r);
ucie_txn exp = expected_q.pop_front(); // ASSUMES ORDER
if (r != exp) report_error("mismatch");
endfunction
endclassFour ways this fails, and each one is a normal UCIe situation rather than a corner case.
Out-of-order completion. Two requests to different destinations in different classes complete in the opposite order. The queue pops the wrong expectation and reports a mismatch on correct hardware — and the natural fix, searching the queue instead of popping, is the first step toward the associative array it should have been.
One operation, several objects. A fragmented operation produces several transport objects and one completion. A queue holding one expectation per request cannot represent the one-to-many relationship, and a queue holding one per object expects several completions.
Retry. A retransmission is not a new transaction (20.2 §31) but it generates a second attempt event. A queue-based model either enqueues a second expectation — and then reports a missing completion — or ignores the event and loses the ability to check attempt counts.
And recovery. The queue holds expectations with no epoch, no generation and no record of having spanned a link event. After a recovery it cannot answer any of §31's questions, because it never held the state they need.
The fifth failure is the one that hides the other four. predict_response(t) computes the expected response at request time, which means the model has committed to an expectation before it knows what configuration will be active at completion, whether a recovery will intervene, or whether the operation will be fragmented. An obligation record commits to an identity and evaluates expectations against observations as they arrive — which is the whole difference between a packet queue and an obligation model.
14. Out-of-Order Completion
// ILLUSTRATIVE. Completion by lookup, not by order. No queue, no pop, no
// assumption about which obligation completes next.
function automatic void on_sem_complete(ucie_event e);
if (!obligations.exists(e.mon_tag)) begin
report_error(ERR_COMPLETION_NO_OBLIGATION, e.mon_tag,
"completion for an unknown obligation");
return;
end
ucie_obligation ob = obligations[e.mon_tag];
if (ob.state != OB_OUTSTANDING) begin
report_error(ERR_COMPLETION_AFTER_TERMINAL, e.mon_tag,
$sformatf("completion in state %s", ob.state.name()));
return;
end
// The parts bitmap must be complete BEFORE a completion is legal (§10).
if (ob.parts_seen_mask != ob.parts_expected_mask) begin
report_error(ERR_INCOMPLETE_PARTS, e.mon_tag,
$sformatf("expected %b, seen %b",
ob.parts_expected_mask, ob.parts_seen_mask));
end
// Expectations are checked against the epoch captured at ACCEPT (§10).
if (e.cfg_epoch != ob.cfg_epoch_at_accept)
report_error(ERR_EPOCH_CHANGED_UNDER_OBLIGATION, e.mon_tag,
$sformatf("accepted under cfg_epoch %0d, completed under %0d",
ob.cfg_epoch_at_accept, e.cfg_epoch));
ob.completion_count++;
ob.state = OB_COMPLETED;
ob.terminal_cycle = e.cycle;
live_sem_ids.delete(ob.sem_id_at_accept); // §11 — keep the index honest
endfunctionFour properties of completion-by-lookup.
Order is irrelevant. Any obligation can complete at any time, which is exactly what the protocol permits and what §13's queue forbids.
Every failure has its own error code and its own message. An unknown completion, a completion after a terminal state, incomplete parts, and an epoch change are four distinct bugs. A single "mismatch" message conflates them and costs a day per occurrence.
The completion-after-terminal check is the one that catches 20.3 §33's dangerous case — a late success after a reported failure, when the client has already taken its error path and possibly reissued.
And the index is maintained in the same function that changes the state. §12's rule: an index updated somewhere else eventually disagrees.
15. The Obligation State Machine
// ILLUSTRATIVE. The model's own state machine, driven ONLY by observed events.
// Compare 20.2 §10 — the same discipline, at the obligation level.
function automatic void step(ucie_event e);
if (!obligations.exists(e.mon_tag)) return;
ucie_obligation ob = obligations[e.mon_tag];
case (e.kind)
EVT_OBJ_ALLOC: ob.obj_tags.push_back(e.obj_id);
EVT_DELIVER: begin
ob.delivery_count++;
ob.parts_seen_mask |= (1 << part_index_of(e));
if (ob.delivery_count > 1)
report_error(ERR_DUPLICATE_DELIVERY, e.mon_tag,
$sformatf("delivery %0d", ob.delivery_count));
end
EVT_SEM_COMPLETE: on_sem_complete(e);
EVT_SEM_FAIL: begin
ob.state = OB_FAILED;
ob.terminal_cycle = e.cycle;
ob.terminal_reason = "explicit failure";
end
EVT_RECOVERY_ENTER:
// The obligation is NOT touched — only annotated. §31.
ob.recoveries_spanned++;
default: ; // this obligation does not care about this event
endcase
endfunctionThree notes.
EVT_RECOVERY_ENTER annotates and does not clear. This one line is the difference between a model that can detect 20.2 §12's bug and one that hides it, and §31 develops it.
The duplicate-delivery check fires on the second delivery, at the cycle it happens, rather than at end of test. An end-of-test check reports "two deliveries" with no cycle; this reports the cycle, which is where the waveform is.
And the default arm is empty deliberately. An obligation ignores credit events, configuration commits and link phases — those belong to other models, and an obligation record that started tracking them would be §4's monolith reassembling itself.
16. Wrong Model — Mirroring the Design's Obligation State
// WRONG — the model's state comes from the design's table.
function void update(ucie_event e);
ob.state = dut_state_to_ob_state(vif.sem_table[e.sem_id].state);
endfunction20.1 §31 and 20.2 §8 both made this argument. Here is what it costs specifically at the obligation level.
Worked. The design has a bug: on a recovery it clears its semantic table (19.2 §44's failure). The mirroring model reads the table.
| Cycle | Design's table | Mirroring model | Independent model |
|---|---|---|---|
| 900 | 4 outstanding | 4 outstanding | 4 outstanding |
| 901 | recovery entry | — | recoveries_spanned++ on all four |
| 902 | cleared — the bug | 0 outstanding | 4 outstanding |
| 950 | recovery exit | 0 | 4 |
| 951 | comparison | agrees — PASS | FAIL: design has 0, model has 4 |
Three properties.
The mirroring model's agreement is the bug. It agreed with the design at the exact cycle the design was wrong.
The independent model's failure names the cause. "The design's outstanding count is 0 and the model's is 4 after recovery exit" points at recovery clearing semantic state, which is one line of RTL.
And the mirroring model is worse than no model. Its silence is read as evidence that recovery preserves obligations, so the check is marked done and nobody writes the independent one.
17. What the Model Derives From the Contract
The line between "observed" and "expected" is the whole architecture, and it is worth stating explicitly per field.
| Field | Source | Never from |
|---|---|---|
mon_tag | the monitor allocates it | anywhere |
sem_id, generation, cls, addr | observed on the wire at accept | the design's table |
cfg_epoch_at_accept | observed from the management monitor | the design's current epoch register |
expected_response_kind | derived from the contract and the operation kind | the design's outstanding-table field |
parts_expected_mask | derived from the contract and the operation's size | the design's fragment counter |
ordering_group | derived from the contract | the design's ordering logic |
delivery_count, completion_count | counted from observed events | the design's counters |
recoveries_spanned | counted from observed events | anywhere |
The test for any field, in one sentence:
Could somebody who has never read the RTL produce this value, given the interface document and the observed waveform? If yes, the field is independent. If it requires knowing what a design register means, the model has become part of the implementation.
And the row that is most often violated is expected_response_kind. It is genuinely easier to read the design's outstanding table than to re-derive the expected response from the operation kind — and reading it means the model agrees with the design about what it was expecting, which is exactly the record a mis-decode corrupted.
18. The Transport Model
// ILLUSTRATIVE. One record per transport object. Shorter-lived than an
// obligation, longer-lived than an attempt (20.2 §4).
typedef enum { TR_STAGED, TR_COMMITTED, TR_IN_FLIGHT, TR_RESOLVED, TR_ABANDONED }
tr_state_e;
class ucie_transport_obj;
int unsigned mon_tag; // the obligation this carries — the JOIN
int unsigned obj_id_observed;
int unsigned cls;
int unsigned part_index; // which part of a fragmented operation
// Attempts are a QUEUE, not a count — §19.
longint attempt_cycles[$];
longint arrival_cycles[$];
bit verdicts[$]; // one per arrival
int unsigned retry_triggers[$]; // cycle of each observed retry trigger
// Reliability lifetime
tr_state_e state;
bit history_live; // a recoverable copy is retained
longint commit_cycle, resolve_cycle;
int unsigned link_epoch_at_commit;
int unsigned recoveries_spanned;
endclass
class ucie_transport_model;
ucie_transport_obj objects [int unsigned]; // keyed by a transport tag
int unsigned tags_of_obligation [int unsigned][$]; // mon_tag -> objects
endclassFour decisions.
mon_tag is a field, not the key. One obligation can have several objects, so the key must be per-object and the obligation link is a field — with a secondary index the other way (tags_of_obligation), maintained atomically (§12).
attempt_cycles, arrival_cycles and verdicts are queues. A count of attempts cannot answer "was attempt 3 the same object as attempt 2?" or "did the first attempt arrive at all?" — and the second question is what distinguishes an injected corruption from an injected drop (20.2 §31).
retry_triggers exists so that a retransmission with no observed cause is detectable. 20.1 §33's R3: a spontaneous retry still produces exactly one delivery and passes every other check.
And history_live is the model's own view of whether a recoverable copy is retained — derived from commit and resolve events, never read from the design's replay ring, which is the structure whose sizing is the likely bug (20.3 §32).
19. Attempt Tracking
// ILLUSTRATIVE. Attempts, arrivals and verdicts, with the checks that only a
// queue can express.
function automatic void on_attempt(ucie_event e);
ucie_transport_obj o = objects[e.obj_id];
o.attempt_cycles.push_back(e.cycle);
o.state = TR_IN_FLIGHT;
// An attempt beyond the first requires an OBSERVED trigger — 20.1 §33's R3.
if (o.attempt_cycles.size() > 1) begin
bit triggered = 0;
foreach (o.retry_triggers[i])
if (o.retry_triggers[i] < e.cycle
&& o.retry_triggers[i] > o.attempt_cycles[o.attempt_cycles.size()-2])
triggered = 1;
if (!triggered)
report_error(ERR_UNEXPLAINED_RETRANSMIT, o.mon_tag,
$sformatf("attempt %0d at cycle %0d with no trigger since %0d",
o.attempt_cycles.size(), e.cycle,
o.attempt_cycles[o.attempt_cycles.size()-2]));
end
// Ownership must be continuous — 19.3 §11 / 20.3 §31, in model form.
if (!o.history_live && reliability_enabled)
report_error(ERR_ATTEMPT_WITHOUT_HISTORY, o.mon_tag,
"attempt made with no retained recoverable copy");
endfunction
function automatic void on_arrival(ucie_event e);
ucie_transport_obj o = objects[e.obj_id];
o.arrival_cycles.push_back(e.cycle);
// Arrivals never exceed attempts. A surplus means an object arrived that was
// never sent — a monitor bug, or a genuinely duplicated transmission.
if (o.arrival_cycles.size() > o.attempt_cycles.size())
report_error(ERR_ARRIVAL_WITHOUT_ATTEMPT, o.mon_tag,
$sformatf("%0d arrivals, %0d attempts",
o.arrival_cycles.size(), o.attempt_cycles.size()));
endfunctionThree notes.
The trigger search is between the previous attempt and this one. A trigger from before the previous attempt already explained that one. Checking merely that "some trigger exists" accepts a design that retries three times off one trigger.
ERR_ATTEMPT_WITHOUT_HISTORY is conditional on reliability_enabled. CRC and retry are optional Adapter functions (§3), and in Raw Mode the Adapter is not in the path at all — so this check must be configuration-conditional or it fires falsely in a supported mode and gets deleted (20.3 §56).
And arrivals-never-exceed-attempts is a cheap consistency check that catches a monitor bug as often as a design bug. Both are worth catching, and the message says which counts it saw so triage can start immediately.
20. Correlating Three Levels
20.1 §22's hierarchy, made concrete. The correlation is what lets the environment state the property that matters:
| Level | Count for one retried operation | Model that holds it |
|---|---|---|
| semantic operations | 1 | semantic |
| semantic allocations | 1 | semantic |
| transport objects | 1 (unfragmented) | transport |
| physical attempts | ≥ 2 | transport |
| arrivals | 1 or 2 | transport |
| semantic deliveries | 1 | semantic |
| semantic completions | 1 | semantic |
// ILLUSTRATIVE. The correlation check, run per obligation at its terminal
// event. Every quantity comes from a DIFFERENT model — this is the join.
function automatic void check_correlation(int unsigned tag);
ucie_obligation ob = sem_model.obligations[tag];
int unsigned total_attempts = 0, total_arrivals = 0;
foreach (trans_model.tags_of_obligation[tag][i]) begin
ucie_transport_obj o = trans_model.objects[trans_model.tags_of_obligation[tag][i]];
total_attempts += o.attempt_cycles.size();
total_arrivals += o.arrival_cycles.size();
end
// The semantic invariants — TRUE regardless of retries or recoveries.
chk(ob.delivery_count == 1, ERR_DELIVERY_COUNT, tag);
chk(ob.completion_count == 1, ERR_COMPLETION_COUNT, tag);
chk(ob.parts_seen_mask == ob.parts_expected_mask, ERR_PARTS, tag);
// The transport variables — may legitimately exceed 1.
chk(total_attempts >= 1, ERR_NO_ATTEMPT, tag);
chk(total_arrivals <= total_attempts, ERR_ARRIVAL_SURPLUS, tag);
// The RATIO check: attempts beyond arrivals+1 means an unexplained resend.
chk(total_attempts <= total_arrivals + 1, ERR_UNEXPLAINED_RETRANSMIT, tag);
endfunctionTwo properties of this function.
Every quantity comes from a different model, which is what makes it a join rather than a model's internal check. A single model containing all three levels could not distinguish "the semantic layer lost it" from "the transport layer duplicated it" — the split is what makes the failure attributable.
And the ratio check is the one that catches a spontaneous retry even when everything else is correct: exactly one delivery, exactly one completion, and one more attempt than any arrival can explain.
21. Wrong Correlation — A Wire Field as the Key
// WRONG — the join key is the design's transaction identity.
class ucie_scoreboard_bad;
ucie_obligation obligations [int unsigned]; // keyed by sem_id
endclassWorked. Operation A is issued with sem_id = 7, completes, retires. sem_id = 7 is legitimately reallocated to operation B. A delayed response for A arrives after B was allocated.
What the model concludes. sem_id = 7 exists, is outstanding, and a response arrived for it. It marks B complete with A's data — and reports nothing wrong, because from its point of view everything matched.
Four consequences.
The model reproduced the design's bug rather than detecting it. 19.2 §21 is exactly this failure in RTL, and a wire-keyed model makes the identical mistake for the identical reason: the design's identity is not unique over time.
The check that would have caught it becomes unstateable. "An identity is not reused while its previous holder is live" requires distinguishing two holders. With sem_id as the key there is only one.
The associative array silently overwrote A's record. Not an error — an assignment. A's obligation ceased to exist and nothing reported it, so even the end-of-test check for outstanding obligations passes.
And a generation field does not fully rescue it. 19.2 §22's generation narrows the window enormously and is the right design mechanism — but it is finite and wraps, while the model has no reason to accept a finite guard when it can have an infinite one. The monitor tag never wraps and never aliases.
The design's identity is data to be checked, not a key to check with. Record it, assert properties about it, and key on something the design cannot influence.
22. The Join, and Its Own Failure Modes
The join is code, and it has three bugs of its own. Each needs its own check and its own error category — because a join anomaly reported as a design failure sends the investigation to the wrong place, and reported as nothing loses the evidence that the environment is unreliable.
// ILLUSTRATIVE. The join's self-checks, reported in a SEPARATE category.
function automatic void check_join_health();
// 1. An event carrying a tag the environment never allocated.
// ALWAYS an environment bug — the tag inference (§20) went wrong.
foreach (orphan_events[i])
report_env_error(ENV_TAG_WITHOUT_WORK, orphan_events[i].mon_tag,
"event carries an unknown tag");
// 2. An observed object that could not be attributed to any obligation.
// MAY be a design bug (a spontaneously generated object) or an
// environment gap. The report must say both — it cannot tell.
foreach (trans_model.objects[t])
if (trans_model.objects[t].mon_tag == TAG_NONE)
report_ambiguous(AMB_WORK_WITHOUT_TAG, t,
"object not attributable: DUT-generated, or a join gap");
// 3. Two obligations sharing one tag. ALWAYS an environment bug, and the
// worst of the three — it silently MERGES two histories into one.
foreach (tag_use_count[t])
if (tag_use_count[t] > 1)
report_env_error(ENV_TAG_COLLISION, t,
$sformatf("%0d obligations share tag %0d",
tag_use_count[t], t));
endfunctionThree rules.
Join anomalies go in their own report category, never mixed with design failures. A regression whose failure list contains environment bugs teaches the team to skim the list.
Category 2 is genuinely ambiguous and must be reported as such. The environment cannot distinguish a design that generated an object nobody asked for from an environment that failed to attribute one. Guessing either way is wrong; saying both is correct.
And category 3 hides design failures. Two histories merged into one means the checks that would have run on each ran on neither. The scoreboard must check its own join before it checks the design, or it reports passes it has not earned — and that is the one failure mode a verification report cannot detect from its own output.
23. The Resource Model
// ILLUSTRATIVE. Per class, per direction. Every quantity derived from observed
// events (§17) — nothing read from a design register except as a comparison
// SUBJECT.
class ucie_resource_model;
// ---- credit, per class ----
int unsigned advertised [NUM_CLASSES];
int unsigned consumed [NUM_CLASSES];
int unsigned returned [NUM_CLASSES];
int unsigned credit_epoch [NUM_CLASSES];
bit agreement_valid [NUM_CLASSES];
int unsigned stale_returns [NUM_CLASSES];
int unsigned min_credit [NUM_CLASSES]; // a sizing report, not an error
// ---- live claims: a SET, not a count (19.5 §54) ----
bit live_claim [NUM_CLASSES][MAX_CAPACITY];
// ---- occupancy, per structure ----
int unsigned pushed [NUM_STRUCTURES];
int unsigned popped [NUM_STRUCTURES];
int unsigned high_water [NUM_STRUCTURES]; // sizing evidence
// ---- capacity, from the CAPABILITY STRUCTURE, never a testbench constant ----
int unsigned active_capacity [NUM_CLASSES]; // 19.6 §20
function int unsigned expected_credit(int c);
return advertised[c] - consumed[c] + returned[c];
endfunction
function int unsigned occupancy(int s);
return pushed[s] - popped[s];
endfunction
endclassFour notes.
expected_credit is derived, never stored. A stored value can drift from the three counts that define it; a function cannot. This is 19.4 §9's derived-occupancy argument applied to the model.
live_claim is a bitmap, not a count. 19.5 §54: a duplicate consume or a duplicate return is a set-a-set-bit or clear-a-clear-bit event, which is directly checkable, while a count absorbs both silently.
active_capacity comes from the capability structure (19.6 §20), which is the elaborated parameters made observable. Taking it from a testbench constant is 19.6 §53's hardcoded-default failure, and it reappears the moment somebody runs a parameter sweep.
And min_credit and high_water are reports, not checks. 19.4 §54: a buffer whose high-water mark never exceeded a quarter of its depth is not a bug and is evidence for the next sizing review. Printing it is how the argument reaches whoever chose the parameter.
24. Conservation in the Model
// ILLUSTRATIVE. The checks that make the resource model worth having. Bounds
// are necessary and nowhere near sufficient (20.3 §25).
function automatic void check_resources(int unsigned observed_credit[NUM_CLASSES]);
for (int c = 0; c < NUM_CLASSES; c++) begin
// C1 — the replica is right. Catches a wrong adder.
chk(expected_credit(c) == observed_credit[c], ERR_CREDIT_MISMATCH, c);
// C2 — live claims never exceed capacity. Catches a wrong EVENT SET even
// when the counter is self-consistent (19.5 §55's check 2).
chk(count_live_claims(c) <= active_capacity[c], ERR_OVER_ALLOCATION, c);
// C3 — bounds. Necessary, weakest.
chk(observed_credit[c] <= active_capacity[c], ERR_CREDIT_RANGE, c);
// C4 — the receiver's identity: nothing is stranded or double-counted.
chk(occupancy(struct_of(c)) + in_flight(c) + free_of(c)
== active_capacity[c], ERR_CAPACITY_IDENTITY, c);
end
endfunctionWhy C1 and C2 are both needed, and this is the section's whole point.
C1 catches a design whose counter update is wrong. C2 catches a design whose counter is perfectly consistent with a wrong set of events — 19.5 §52's replay miscount is exactly that: the counter agrees with the consume events, and the consume events are wrong. C1 passes; C2 fails.
And C4 is the receiver-side identity that catches a drifted reservation in either direction — an over-advertisement and an under-advertisement both break it, while a bound only catches one.
25. Stranded Resources
A leaked resource is neither an overflow nor an error. It reduces capacity permanently and silently, and no design assertion can see it — because the design believes the entry is legitimately allocated, and that belief is the bug.
// ILLUSTRATIVE. The stranded check: resources believed live whose work is no
// longer outstanding. This requires the JOIN — one model cannot compute it.
function automatic void check_stranded();
// A replay entry still live for an obligation that reached a terminal state.
foreach (trans_model.objects[t]) begin
ucie_transport_obj o = trans_model.objects[t];
if (o.history_live
&& sem_model.obligations.exists(o.mon_tag)
&& sem_model.obligations[o.mon_tag].state != OB_OUTSTANDING)
report_error(ERR_STRANDED_REPLAY, o.mon_tag,
$sformatf("history live for a %s obligation",
sem_model.obligations[o.mon_tag].state.name()));
end
// A credit claim live for an object that resolved.
for (int c = 0; c < NUM_CLASSES; c++)
foreach (res_model.live_claim[c][i])
if (res_model.live_claim[c][i] && claim_owner_resolved(c, i))
report_error(ERR_STRANDED_CREDIT, i,
$sformatf("class %0d claim %0d live after resolution", c, i));
endfunctionThree properties.
It is a join check, not a model check. "Is this resource live for work that is finished?" relates the resource model to the semantic model. Neither can compute it alone, which is one more reason §4's monolith loses information rather than gaining it.
The failure signature is a slow monotonic capacity reduction with no error anywhere (19.5 §63). It presents as a link that gets slower over hours and eventually stops, and by then the cause is far in the past.
And this check must run periodically, not only at end of test. A stranded resource detected at end of test tells you it happened; detected at the cycle it strands, it tells you which obligation and which event.
26. The Link and Configuration Model
// ILLUSTRATIVE. The singleton that supplies CONTEXT every other model needs.
// Its phase logic is 20.2 §10's — evidence-driven, never mirroring.
class ucie_link_model;
ref_link_state_e phase;
int unsigned link_epoch;
int unsigned cfg_epoch;
bit cfg_agreed;
cfg_t local_requested, remote_capability, expected_agreed, active;
bit reliability_enabled; // §19's conditional — mode-dependent
bit adapter_in_path; // Raw Mode changes what exists (§3)
int unsigned recovery_count;
longint phase_entry_cycle;
endclassWhy it exists separately rather than folded into the others.
It supplies context the others cannot compute. "Was there a recovery during this object's lifetime?" is a transport-model question answered from link-model data. "Which capacity applied when this credit was advertised?" is a resource question answered from configuration data. Duplicating that state into each model is how the models drift apart.
And adapter_in_path changes what the other models should even contain. In Raw Mode the Adapter is bypassed (§3), so the transport model's reliability fields are legitimately empty and every check on them must be conditional — 20.1 §51's argument at the model level rather than at the assertion level.
27. Epoch Tracking
Two epochs, two lifetimes, deliberately not one counter.
| Epoch | Increments on | Stamped on | Answers |
|---|---|---|---|
link_epoch | entry to the operational phase | every record at accept/commit | is this event from a dead agreement? |
cfg_epoch | a configuration commit | every obligation at accept | which rules does this operation obey? |
Three rules.
They are independent. 20.2 §30's trace: a recovery advances the link epoch while an operation's cfg_epoch_at_accept stays where it was — and a design or a model with one counter cannot express that trace at all.
Every record captures both at creation and never rewrites them. A completion is checked against the epoch the operation began under (§14).
And a stale-event check compares against the model's epoch, not the design's. If the design's epoch register is the thing that is wrong — advanced by a reset scope that should not have touched it (19.6 §27) — a model reading it inherits the error and every stale event looks fresh.
28. The Data Model
// ILLUSTRATIVE, AND OPTIONAL. The most expensive model and the first to switch
// off in a long run. Note the value type — §29 is why it is not a byte.
typedef struct {
bit known; // 0 = the expected value is a SET, not a value
bit [7:0] value; // valid only when known == 1
bit [7:0] candidates[$]; // the ambiguity set when known == 0
int unsigned last_writer_tag; // which obligation last wrote it
longint last_write_cycle;
} mem_cell_t;
class ucie_data_model;
mem_cell_t cells [longint]; // sparse — only touched addresses exist
endclassThree notes.
Sparse storage is not an optimisation, it is a requirement. A dense model of a 64-bit address space does not fit. Only addresses the test actually touched are represented, and an untouched address has no expectation at all — which is correct, because the environment does not know what is there.
last_writer_tag turns a data mismatch into a localisation. "Address 0x1000 read 0xA5, expected 0x5A, last written by obligation 4127 at cycle 8,412" is a starting point; "data mismatch" is not.
And candidates is what makes the model able to represent an ambiguity rather than guessing — §29.
29. Set-Valued Expectations
20.2 §37 established the situation: a request is delivered, the far end acts, and the response is lost. The local side cannot know whether the action took effect.
A model that holds a single expected value must guess, and both guesses are wrong somewhere:
| Guess | Wrong when | Failure mode |
|---|---|---|
| "it did not happen" | the far end acted and deduplicates a retry | false failure on the next read |
| "it happened" | the operation genuinely failed | missed corruption |
// ILLUSTRATIVE. The three-valued update. The ambiguity is RECORDED, and it is
// discharged only by evidence — never by assumption.
function automatic void on_ambiguous_write(longint addr, bit [7:0] new_val,
int unsigned tag);
mem_cell_t c = cells[addr];
if (c.known) begin
c.candidates = '{ c.value, new_val }; // old value OR new value
c.known = 1'b0;
end else begin
c.candidates.push_back(new_val); // the ambiguity widens
end
c.last_writer_tag = tag;
cells[addr] = c;
endfunction
// A read against an ambiguous cell accepts ANY candidate — and NARROWS the set.
function automatic void on_read(longint addr, bit [7:0] observed);
mem_cell_t c = cells[addr];
if (c.known) begin
chk(observed == c.value, ERR_DATA_MISMATCH, addr);
end else begin
if (!(observed inside { c.candidates })) begin
report_error(ERR_DATA_OUTSIDE_AMBIGUITY, addr,
"read value is none of the possible outcomes");
end else begin
// The read RESOLVED the ambiguity — collapse the set.
c.known = 1'b1;
c.value = observed;
c.candidates.delete();
cells[addr] = c;
end
end
endfunctionThree properties.
The read narrows rather than merely passing. Once a read has observed one of the candidates, the ambiguity is resolved and subsequent reads are checked strictly. A model that leaves the set open forever accepts anything at that address for the rest of the run, which is a permanent hole.
ERR_DATA_OUTSIDE_AMBIGUITY is a genuine finding. The value is neither of the two possible outcomes, so something other than the ambiguous write touched that address — corruption, a stray write, or a wrong address decode.
And the ambiguity must widen, not replace. A second ambiguous write to the same address adds a candidate. A model that overwrites the set has forgotten one possible outcome and will report a false failure if that outcome is what the far end actually has.
30. Recovery-Safe Models
The single most consequential property of a scoreboard, and it is decided by one design choice made early.
The environment's models have their own reset policy, derived from the contract — not from the design's reset signals.
Three questions per model, per event:
| Event | Semantic | Transport | Resource | Link/config |
|---|---|---|---|---|
| recovery entry | annotate only | annotate; mark history retention | void the agreement; clear pending | phase → recovery; cfg_agreed = 0 |
| recovery exit | unchanged | unchanged | re-establish on advertisement | phase → operational; link_epoch++ |
| configuration reset | unchanged | unchanged | clear credit state | reset requested + active |
| cold reset | report abandoned, then clear | report, then clear | clear | clear |
| diagnostic clear | unchanged | unchanged | clear counters only | unchanged |
Two rows deserve attention.
Recovery entry annotates the semantic model and touches nothing else in it. That one decision is what makes 20.2 §12's design bug detectable.
And cold reset reports before clearing. Even where clearing is correct, the obligations that were destroyed are named in the log, because "we reset while four operations were outstanding" is information and a silent clear discards it.
31. Wrong Recovery Handling
// WRONG — the models are cleared by the design's reset signal.
always @(posedge clk)
if (!dut_rst_n) begin
sem_model.clear();
trans_model.clear();
res_model.clear();
endWorked, on the design bug it exists to catch: the design's recovery path drives the same reset net as the global reset, so a recoverable link event clears its semantic table.
| Cycle | Design | Environment | Verdict |
|---|---|---|---|
| 900 | 4 outstanding | 4 outstanding | — |
| 901 | recovery; reset net asserted | — | — |
| 902 | semantic table cleared | models cleared | — |
| 950 | recovery completes | — | — |
| 951 | 0 outstanding | 0 outstanding | agree — PASS |
| ∞ | the 4 operations never complete | nothing expects them | never reported |
Four properties.
The failure is an absence, and absences are only detectable by something that remembers. The only thing that remembered was cleared at the same instant.
It is the most likely single environment defect, because clearing on reset is obviously correct for a cold reset and is written once, early, for all resets.
End-of-test checks do not save you. §55's check for remaining outstanding obligations finds nothing, because there are none. A green report on a design that lost four operations.
And the fix is not "never clear". It is §30's table — the environment's policy, derived from the contract, distinguishing the reset kinds the contract distinguishes.
32. Per-Model Reset Policy
// RIGHT — one handler, four models, four different answers (§30).
function automatic void on_reset_event(ucie_event e);
case (e.reset_kind)
RST_COLD: begin
sem_model.report_abandoned("cold reset"); // name them BEFORE clearing
trans_model.report_abandoned("cold reset");
sem_model.clear(); trans_model.clear();
res_model.clear(); link_model.clear();
end
RST_CONFIG: begin
res_model.clear_credit_state();
link_model.reset_configuration();
// sem_model and trans_model DELIBERATELY UNTOUCHED.
end
RST_DIAG: begin
res_model.clear_counters_only(); // NOT the epochs (19.6 §27)
// Nothing else touched.
end
default: report_env_error(ENV_UNKNOWN_RESET_KIND, e.reset_kind, "");
endcase
endfunction
function automatic void on_recovery_entry(ucie_event e);
link_model.phase = RM_RECOVERY;
link_model.cfg_agreed = 1'b0;
res_model.void_agreement(); // 19.5 §28
res_model.clear_pending_returns();
trans_model.mark_recovery_spanned();
sem_model.annotate_recovery(); // ANNOTATE — never clear
endfunctionThree notes.
RST_DIAG clears counters and not epochs. 19.6 §27's subtlest matrix row: a counter-clear implementation that resets a register block wholesale destroys the epoch, and every in-flight return becomes acceptable again. The model must not make the same mistake, or it cannot detect the design making it.
The default arm is an environment error, not a silent ignore. An unrecognised reset kind means the monitor saw something the model does not model — which is a gap, and gaps must be loud.
And annotate_recovery is one line that carries the whole chapter's most important property.
33. What Each Model Does at Each Reset
§30's table is the policy. This is the check that the design agrees with it — one property per row, which is 19.6 §29's reset matrix used as a verification plan.
// ILLUSTRATIVE. One check per matrix row, run at each reset event. The
// PRESERVE direction is the one that finds bugs — clearing is the accidental
// default, and nothing accidentally preserves state.
function automatic void check_reset_matrix(ucie_event e);
case (e.reset_kind)
RST_RECOVERY: begin
chk(dut_outstanding_count() == sem_model.outstanding_count(),
ERR_RECOVERY_CLEARED_SEMANTICS, 0);
chk(dut_replay_occupancy() == trans_model.live_history_count(),
ERR_RECOVERY_CLEARED_REPLAY, 0);
chk(dut_first_fault_valid() == 1'b1 || !first_fault_was_set,
ERR_RECOVERY_CLEARED_FIRST_FAULT, 0);
chk(dut_cfg_epoch() == link_model.cfg_epoch,
ERR_RECOVERY_CHANGED_CFG_EPOCH, 0);
end
RST_DIAG: begin
chk(dut_credit_epoch() == res_model.credit_epoch[0],
ERR_DIAG_CLEARED_EPOCH, 0); // 19.6 §27's subtle row
chk(dut_outstanding_count() == sem_model.outstanding_count(),
ERR_DIAG_CLEARED_SEMANTICS, 0);
end
endcase
endfunctionThe RST_DIAG epoch check is the highest-value row in the function, and it is the one nobody writes. A software diagnostic read-and-clear that also zeroes the credit epoch turns every stale in-flight return into an acceptable one — a distributed over-advertisement caused by a debug operation, days after the code that caused it was reviewed.
34. Record Lifetime
A scoreboard that keeps every record forever runs out of memory. One that ages records out has a silent loss mechanism. Both are real.
| Policy | Cost |
|---|---|
| keep everything | memory grows with the run; a multi-million-cycle regression exhausts it |
| age out by cycle count | a slow-but-legal obligation is deleted while still live (§35) |
| age out at terminal state | correct, and loses the post-mortem history |
| age out at terminal + N | the practical answer, with N derived |
// ILLUSTRATIVE. Retire on TERMINAL STATE plus a grace window, never on age
// alone. The grace window keeps recent history for the report (§39).
function automatic void retire_records();
foreach (sem_model.obligations[t]) begin
ucie_obligation ob = sem_model.obligations[t];
if (ob.state != OB_OUTSTANDING
&& (current_cycle - ob.terminal_cycle) > RETIRE_GRACE) begin
// Fold the record into aggregate statistics BEFORE deleting it.
stats.record_latency(ob.accept_cycle, ob.terminal_cycle,
ob.recoveries_spanned);
// Keep the secondary index honest (§12).
if (sem_model.live_sem_ids.exists(ob.sem_id_at_accept)
&& sem_model.live_sem_ids[ob.sem_id_at_accept] == t)
sem_model.live_sem_ids.delete(ob.sem_id_at_accept);
sem_model.obligations.delete(t);
end
end
endfunctionThree rules.
Retire on state, never on age. §35.
Fold into statistics before deleting. Latency distributions, recovery-spanning counts and retry histograms are what the run is for in a passing regression (20.2 §50). Deleting a record without folding it discards the only evidence a passing run produces.
And maintain the secondary index in the same function. A live_sem_ids entry pointing at a deleted record makes §11's reuse check dereference a record that no longer exists — which is an environment crash, or worse, a silently skipped check.
35. Wrong Retirement — Ageing Out a Live Obligation
// WRONG — retire by age, regardless of state.
if ((current_cycle - ob.accept_cycle) > MAX_AGE)
sem_model.obligations.delete(t);Worked. MAX_AGE was chosen from a clean-link latency distribution. An obligation is accepted, its transport object is attempted, a fault occurs, a recovery runs for 40,000 cycles, and the operation completes afterwards — legally.
| Cycle | Event | Model |
|---|---|---|
| 800 | obligation accepted | outstanding |
| 810 | attempt 1 | — |
| 815 | fault; recovery entry | annotated |
| 5,800 | age exceeds MAX_AGE | record deleted |
| 42,000 | recovery exits | — |
| 42,100 | the operation completes | completion for an unknown obligation |
Four properties.
The completion is reported as an error on correct hardware. §14's ERR_COMPLETION_NO_OBLIGATION fires, and the finding is a phantom.
The real risk is the "fix". The natural response is to suppress completions for unknown obligations — which removes the check that catches a genuinely invented completion, and that is a real bug class.
The correct age bound would have to include a full recovery, which makes it enormous and therefore useless as an ageing policy — which is the argument for retiring on state instead.
And the same failure hides a real loss. If an obligation is aged out while genuinely stuck, §55's end-of-test check finds nothing outstanding and passes — a hang reported as a clean run.
36. Memory on Long Runs
Four techniques, in the order to apply them.
Retire on terminal state with a grace window (§34). This alone bounds the live set to the genuinely outstanding obligations plus a small tail.
Fold to statistics rather than keeping records. A latency histogram is a few hundred bytes; a million records are not.
Disable the data model first. §28 is the most expensive model per record, and it is the one whose absence costs the least in a run aimed at protocol behaviour rather than data integrity.
And bound the live set explicitly, with an error rather than a silent drop:
// ILLUSTRATIVE. If the live set exceeds what the design can possibly have
// outstanding, something is wrong — with the design or with the environment.
// Either way it must be LOUD, never a silent eviction.
if (sem_model.obligations.size() > MAX_PLAUSIBLE_OUTSTANDING)
report_error(ERR_LIVE_SET_EXPLOSION, sem_model.obligations.size(),
"live obligation count exceeds the design's capacity — "
"a leak in the DUT, or a missing completion event in a monitor");That last check is worth its line. A live set larger than the design's outstanding capacity is impossible if both the design and the monitors are correct — so it is a genuine finding either way, and it fires long before memory becomes the symptom.
37. The First-Divergence Engine
The output that justifies the architecture. Given a failure, the engine answers where do I start looking rather than what went wrong at the end.
// ILLUSTRATIVE. Every error carries enough context to be ordered and attributed.
typedef struct {
int unsigned code;
int unsigned boundary_id; // WHICH LAYER — the localisation
int unsigned model_id; // which model detected it
int unsigned mon_tag;
longint cycle;
longint event_seq; // a monotonic sequence across ALL boundaries
int unsigned cfg_epoch, link_epoch;
bit is_env_error; // §22 — reported separately
string message;
} sb_error_t;
sb_error_t errors[$];
// The report: sort by the EVENT SEQUENCE, not by cycle (§46), and name the
// earliest boundary at which observation and expectation disagreed.
function automatic void report_first_divergence();
sb_error_t design_errors[$];
foreach (errors[i]) if (!errors[i].is_env_error) design_errors.push_back(errors[i]);
design_errors.sort() with (item.event_seq);
if (design_errors.size() == 0) return;
sb_error_t first = design_errors[0];
$display("FIRST DIVERGENCE: %s at boundary %s, cycle %0d",
code_name(first.code), boundary_name(first.boundary_id), first.cycle);
$display(" obligation tag %0d, cfg_epoch %0d, link_epoch %0d",
first.mon_tag, first.cfg_epoch, first.link_epoch);
$display(" CONTEXT: recoveries in this window = %0d, injections = %0d",
link_model.recovery_count, injection_log.size());
$display(" %0d later errors are candidates for being CONSEQUENCES.",
design_errors.size() - 1);
endfunctionThree notes.
Sorting is by event_seq, not by cycle. 20.1 §8 and §46: the four boundaries may be in different clock domains, so raw cycle numbers are not comparable across them. A monotonic sequence assigned as events enter the bus is.
Environment errors are excluded from the divergence computation and reported separately (§22). An environment bug ranked first sends the whole investigation to the testbench, and an environment bug ranked not first is worse — it is buried.
And the last line is the one that saves the week. Naming how many later errors are candidates for being consequences tells the reader that the list below is not a list of independent bugs.
38. Wrong Reporting — the Final Symptom
// WRONG — report the last thing that failed.
final begin
if (error_count > 0)
$error("Scoreboard failed: %s", last_error_message);
endWorked, on §16's cascade. The design's recovery clears the semantic table at cycle 902. Nothing is reported then. At cycle 42,000 the client's operations time out. The last error is "obligation 4127 never terminated".
What the report says: an obligation did not complete.
What actually happened: a recovery cleared semantic state 41,000 cycles earlier.
Four properties.
The reported failure is real and useless. The obligation genuinely never terminated. It is a consequence, and it points at the completion path.
Cascades are the normal shape of a scoreboard failure, not the exception. One lost obligation produces a missing completion, a stranded replay entry, a stranded credit claim, an occupancy that never drains, and a liveness timeout — five errors, one cause.
And ordering is not enough by itself — the boundary is what localises. "The earliest error was at cycle 902" is better than the last error; "the earliest error was at the Adapter boundary at cycle 902, and 41 later errors are candidates for being consequences" is actionable.
The fourth: a report that lists all errors unordered is nearly as bad as reporting the last one. A regression producing forty-one errors gets triaged by whoever opens it first, and the one that matters is not usually the one at the top of an unsorted list.
39. Report Structure
Four sections, in this order, and the order is the point.
| Section | Contains | Why first/last |
|---|---|---|
| 1 — Environment health | join anomalies (§22), monitor self-check failures, model self-check failures (§42) | must be first — if the environment is broken, nothing below it is trustworthy |
| 2 — First divergence | one error, with boundary, cycle, tag, epochs and context (§37) | the finding |
| 3 — Consequence candidates | the remaining errors, ordered, grouped by tag | context for the finding |
| 4 — Statistics | latency distributions, retry histograms, high-water marks, min credit, coverage of key scenarios | last, and read on passing runs too (§34) |
Two rules.
Section 1 being empty is a precondition for reading section 2. A first-divergence computed by an environment whose join is broken is a first divergence in the environment, and presenting it as a design finding wastes a day.
And section 4 is what a passing run produces. A regression that reports only pass/fail discards the sizing evidence, the latency distribution and the scenario coverage that the run generated for free — which is the information the next design review needs and the only output of a green run.
40. Cross-Model Consistency
The models are independent, and their independence is checkable. Three consistency relations that must hold between them, and each catches something no single model can see.
// ILLUSTRATIVE. Relations across models — computed in the JOIN, periodically.
function automatic void check_cross_model();
// R1 — every live transport object belongs to an outstanding obligation,
// unless it is being resolved. Catches an orphaned object.
foreach (trans_model.objects[t]) begin
ucie_transport_obj o = trans_model.objects[t];
if (o.state inside {TR_COMMITTED, TR_IN_FLIGHT})
chk(sem_model.obligations.exists(o.mon_tag)
&& sem_model.obligations[o.mon_tag].state == OB_OUTSTANDING,
ERR_ORPHANED_OBJECT, o.mon_tag);
end
// R2 — live credit claims never exceed live objects that need them.
for (int c = 0; c < NUM_CLASSES; c++)
chk(res_model.count_live_claims(c) <= trans_model.count_unresolved(c),
ERR_CLAIM_WITHOUT_OBJECT, c);
// R3 — no obligation's cfg_epoch is ahead of the link model's current one.
// Catches a monitor that stamped an epoch the config model never saw.
foreach (sem_model.obligations[t])
chk(sem_model.obligations[t].cfg_epoch_at_accept <= link_model.cfg_epoch,
ERR_EPOCH_FROM_THE_FUTURE, t);
endfunctionThree notes.
R1 catches an object that outlived its obligation — which is 19.3 §45's free-on-send from the other direction, or a completion that fired early.
R2 is the credit-versus-transport relation that neither model can check alone, and it is the one that catches a claim leaked by a resolution that did not release it.
And R3 catches an environment bug specifically. An epoch ahead of the link model's means a monitor stamped a value the configuration monitor never observed — a monitor ordering problem (§45), reported before it corrupts a design conclusion.
41. Wrong Environment — The Join That Fails Silently
// WRONG — an unattributable event is quietly dropped.
function void on_event(ucie_event e);
if (!obligations.exists(e.mon_tag)) return; // silently ignored
// ...
endfunctionWorked. A monitor's tag inference (§20) has a bug: for fragmented operations it attributes the second and subsequent objects to the wrong obligation, and sometimes to none.
Every event for those objects is silently dropped.
Four properties.
The affected obligations never see their parts. parts_seen_mask stays incomplete, so §14's completion check fires — and it fires as a design error, blaming the design for parts the environment threw away.
Or worse, the parts are attributed to the wrong obligation, whose mask then completes early. Two design errors reported, both false, and the actual environment bug invisible.
The return looks defensive and reasonable. Guarding an associative-array access is good practice; doing it silently is what turns a guard into a data-loss mechanism.
And the fix is one line — count it and report it in the environment category (§22):
// RIGHT — a drop is a finding, in its own category.
if (!obligations.exists(e.mon_tag)) begin
report_env_error(ENV_TAG_WITHOUT_WORK, e.mon_tag,
$sformatf("%s event at cycle %0d dropped", e.kind.name(), e.cycle));
return;
end42. Model Self-Checks
A model is a program, and it fails in the passing direction. Five checks the environment runs on itself, and none of them involves the design.
Invariants within a model. The semantic model's outstanding count equals the number of records in OB_OUTSTANDING. The resource model's expected_credit is non-negative. The transport model's arrivals never exceed attempts. These are cheap, and a violation is an environment bug caught before it produces a false design finding.
Index consistency. live_sem_ids and tags_of_obligation agree with the primary maps (§12). An index and its primary drifting apart is the most common model bug, and it is silent until a lookup returns the wrong record.
Monitor unit tests. Drive a stalled handshake and confirm exactly one event; a multi-beat object and confirm one object event; a level held for eight cycles and confirm one, not eight (20.1 §10).
Join health (§22), reported in its own category.
And a seeded-defect regression. Take a known-good design, introduce each of a set of seeded defects, and confirm which check catches each one.
The seeded-defect run's value is not the pass rate — it is the mapping. Knowing that "a recovery clearing the semantic table" is caught by §33's matrix check and by nothing else tells you exactly what you lose if that check is disabled for performance. Every environment eventually has something disabled for performance.
43. Distribution Across an Environment
20.6 owns the UVM architecture. What belongs here is the shape the scoreboard imposes on it, because getting the shape wrong forces the monolith back.
Four requirements the scoreboard places on whatever environment hosts it:
One event type, many publishers. Each monitor is an independent component with an analysis port; the models subscribe. A model must not be a subcomponent of a monitor — that couples its lifetime and its reset to one boundary's.
Models are siblings, not a hierarchy. No model owns another. The join is a separate component that holds handles to all four, which is what keeps §6's "no arrow between models" true in the code as well as in the diagram.
Configuration reaches models directly, not through the design. Capacities, class counts and supported widths come from the capability structure or the test's configuration object (19.6 §20) — never from a package the design also imports, or 19.6 §53's shared-default failure returns.
And the reset policy is a scoreboard-level decision, not a component-level one. §32's handler lives in the join or in a dedicated policy component, because the whole point is that the four models answer differently and something above them must know all four answers.
44. Delivery Order
Events from different monitors arrive at the models in an order the environment controls, and the models must not depend on it being the real order.
Three hazards.
Same-cycle events from different boundaries. A protocol accept and an Adapter allocation can occur in the same cycle. Whichever monitor's process runs first publishes first, and that order is a scheduling artefact.
A model that assumes an ordering breaks intermittently. If the semantic model requires EVT_SEM_ACCEPT before EVT_OBJ_ALLOC for the same tag, and the transport monitor happens to run first, the allocation is dropped by §41's guard — intermittently, seed-dependently, and reported as a design bug.
And the fix is to make models order-independent within a cycle, not to fix the scheduling:
// RIGHT — the model tolerates an out-of-order arrival within a cycle by
// creating a provisional record, and reconciles when the accept arrives.
function automatic void on_obj_alloc(ucie_event e);
if (!sem_model.obligations.exists(e.mon_tag)) begin
// Not necessarily an error — the accept may arrive later this cycle.
pending_allocs.push_back(e);
return;
end
attach_object(e);
endfunction
// Drained at the END of each cycle, when all monitors have published.
function automatic void end_of_cycle();
while (pending_allocs.size() > 0) begin
ucie_event e = pending_allocs.pop_front();
if (sem_model.obligations.exists(e.mon_tag)) attach_object(e);
else report_env_error(ENV_TAG_WITHOUT_WORK, e.mon_tag,
"allocation with no accept, after cycle drain");
end
endfunctionThe end-of-cycle drain is the general answer. Within a cycle, order is arbitrary; across cycles it is meaningful. A model that reconciles at the cycle boundary is correct regardless of which monitor runs first, and the error it reports after the drain is a genuine one.
45. Wrong Ordering Assumption
// WRONG — assumes the transport monitor publishes after the protocol monitor.
function void on_obj_alloc(ucie_event e);
ucie_obligation ob = sem_model.obligations[e.mon_tag]; // may not exist yet
ob.obj_tags.push_back(e.obj_id);
endfunctionThree failure modes, all seed-dependent.
A null handle or an implicit record creation, depending on the language semantics — one crashes, the other silently creates an obligation with no accept data, which then fails every expectation check for reasons that have nothing to do with the design.
Intermittency across simulator versions. Process scheduling within a time step is not something to depend on, and a regression that passes on one tool and fails on another usually has one of these.
And it makes a genuine design bug undebuggable. Once some allocations are dropped by scheduling, the counts are unreliable, so a real missing allocation cannot be distinguished from a scheduling artefact.
46. Timestamps Across Clock Domains
The four boundaries may be in different clock domains (19.6 §30). A model comparing raw cycle counts across them is comparing incomparable quantities.
Three rules.
The join key is the tag; time is context. Correlation never uses time. This is why §8's identity fields exist — an environment that correlates by "the nearest event in time" is wrong the moment two domains run at different rates.
A monotonic global sequence number is assigned as events enter the bus, and §37's report sorts by it. It is not a time; it is an order, and an order is all the report needs.
And per-domain cycle counts are still recorded, because a debugger looking at a waveform needs the cycle number in that domain. Both are carried; neither substitutes for the other.
// ILLUSTRATIVE. Both quantities, and the sequence is assigned centrally.
class ucie_event_bus;
longint next_seq;
function void publish(ucie_event e);
e.event_seq = next_seq++; // a global ORDER
// e.cycle is already set by the monitor, in ITS OWN domain
foreach (subscribers[i]) subscribers[i].write(e);
endfunction
endclass47. Performance
A naive scoreboard can dominate simulation time, and four things cause almost all of it.
| Cause | Symptom | Fix |
|---|---|---|
| linear search over the live set per event | slowdown scales with outstanding count | associative arrays and secondary indices (§48) |
| cross-model checks run every cycle | constant overhead | run periodically, plus at key events |
| the data model on every access | dominates in data-heavy tests | make it switchable (§36) |
| string formatting in the non-error path | surprisingly large | build messages only when reporting |
Two notes.
The last row is the one people do not believe. Building a formatted message for every event in case it is needed can cost more than the checks. Format lazily, inside the error branch.
And §40's cross-model checks are the ones to schedule rather than run continuously. They are join-level relations that change slowly. Running them every thousand cycles, plus at every recovery, configuration commit and reset, catches everything a per-cycle run would — because the conditions they detect persist.
48. Indexing Strategy
Five queries, five indices, all maintained atomically with the primary map (§12).
| Query | Index |
|---|---|
| by monitor tag | the primary associative array |
which obligation holds sem_id S | live_sem_ids[sem_id] -> tag |
| which objects carry obligation T | tags_of_obligation[tag] -> queue of object tags |
| which obligations are in ordering group G | by_group[group] -> queue of tags |
| the oldest outstanding obligation | a sorted structure, or a periodic scan |
Three rules.
Every index is updated in the same function that changes the primary. §14 and §34 both do this deliberately. An index updated in a different place eventually disagrees, and §42's consistency check is what finds it.
The oldest-outstanding query does not need an index. It is used by the progress watchdogs (20.2 §49), which run periodically. A scan over a live set bounded by the design's outstanding capacity is cheap, and a sorted structure that must be maintained on every state change is not.
And an index that is only read in the error path does not need to be maintained at all — it can be computed on demand. The rule is to index what the fast path queries, not everything that might be queried.
49. Walkthrough 1 — A Clean Transaction
Illustrative. One operation, no faults. Every model column changes for a reason written in the row.
| Cycle | Event | Semantic | Transport | Resource | Link |
|---|---|---|---|---|---|
| 200 | EVT_SEM_ACCEPT tag 5 | record created, OUTSTANDING, cfg_epoch 2 | — | — | epoch 2 |
| 202 | EVT_OBJ_ALLOC obj 11 | obj_tags = [11] | record created, STAGED | — | — |
| 203 | EVT_CREDIT_CONSUME cls 0 | — | — | consumed 4→5; claim set | — |
| 204 | EVT_OBJ_COMMIT | — | COMMITTED, history_live = 1 | — | — |
| 205 | EVT_ATTEMPT | — | attempt_cycles = [205], IN_FLIGHT | — | — |
| 217 | EVT_ARRIVAL | — | arrival_cycles = [217] | — | — |
| 218 | EVT_VERDICT good | — | verdicts = [1] | — | — |
| 219 | EVT_DELIVER | delivery_count 1, part 0 seen | — | — | — |
| 226 | EVT_RESOLVE | — | RESOLVED, history_live = 0 | — | — |
| 228 | EVT_CREDIT_RETURN | — | — | returned 4→5; claim cleared | — |
| 234 | EVT_SEM_COMPLETE | completion_count 1, COMPLETED | — | — | — |
At cycle 234 the join runs §20's correlation: 1 delivery, 1 completion, 1 object, 1 attempt, 1 arrival. All invariants hold.
Four readings.
The obligation outlives the object by eight cycles, and the object outlives its attempt by nine. Four lifecycles, four durations (20.2 §4) — and the record layout is what makes each one representable.
The credit claim is set at 203 and cleared at 228 — twenty-five cycles during which capacity at the far end is committed and not yet free. That is 19.5 §5's fourth quantity, and the model holds it explicitly because no register anywhere does.
history_live goes 1 at commit and 0 at resolve, not at send. 19.3 §46's rule, and §19's ERR_ATTEMPT_WITHOUT_HISTORY is what would catch a design that cleared it early.
And cfg_epoch 2 is stamped at 200 and never rewritten. Nothing in this clean trace uses it. It matters in walkthrough 4, where a configuration commit lands mid-operation and the record still says which rules apply.
50. Walkthrough 2 — Out-of-Order Responses
Illustrative. Three operations, completing in the order C, A, B. This is the trace §13's queue reports as three mismatches.
| Cycle | Event | Obligation A (tag 7) | B (tag 8) | C (tag 9) |
|---|---|---|---|---|
| 300 | accept A, class 0 | OUTSTANDING | — | — |
| 302 | accept B, class 0 | — | OUTSTANDING | — |
| 304 | accept C, class 1 | — | — | OUTSTANDING |
| 340 | deliver C | — | — | delivery_count 1 |
| 348 | complete C | — | — | COMPLETED |
| 402 | deliver A | delivery_count 1 | — | — |
| 410 | complete A | COMPLETED | — | — |
| 466 | deliver B | — | delivery_count 1 | — |
| 474 | complete B | — | COMPLETED | — |
Four readings.
Each completion is a lookup by tag (§14). Order is irrelevant; there is no queue to pop and nothing to be out of order with respect to.
C is in a different class and completed first — the ordinary reason for reordering, and it needs no explanation because the model asserts no cross-group ordering (20.3 §22).
A and B are in the same class and completed in order here. If they had reordered, whether that is a violation depends on the ordering group — and the model's ordering_group field is where that question is answered rather than assumed.
And §13's queue would have popped A's expectation at cycle 340 and compared it against C. Three consecutive false mismatches, on correct hardware, from a data structure that assumed an ordering the protocol does not promise.
51. Walkthrough 3 — A Retry
Illustrative. A corruption injected after integrity was computed (20.2 §43's point 3).
| Cycle | Event | Semantic (tag 21) | Transport (obj 30) |
|---|---|---|---|
| 500 | accept | OUTSTANDING | — |
| 503 | obj alloc + commit | obj_tags = [30] | COMMITTED, history_live = 1 |
| 505 | attempt 1 | — | attempt_cycles = [505] |
| 506 | (corruption injected) | — | — |
| 517 | arrival | — | arrival_cycles = [517] |
| 518 | verdict bad | — | verdicts = [0] |
| 519 | (no delivery) | delivery_count 0 — correct | — |
| 526 | retry trigger | — | retry_triggers = [526] |
| 527 | attempt 2, obj 30 | — | attempt_cycles = [505, 527] |
| 539 | arrival | — | arrival_cycles = [517, 539] |
| 540 | verdict good | — | verdicts = [0, 1] |
| 541 | deliver | delivery_count 1 | — |
| 548 | resolve | — | RESOLVED, history_live = 0 |
| 556 | complete | COMPLETED | — |
§20's correlation: 1 obligation, 1 object, 2 attempts, 2 arrivals, 1 delivery, 1 completion. attempts ≤ arrivals + 1 holds with no slack.
Four readings.
obj_tags has one entry, not two. The retransmission reused object 30. A design that allocated a new object would give obj_tags = [30, 31] — not wrong in itself, and it changes the credit question (19.5 §51). The model records the fact rather than asserting the rule, which makes the trace an experiment that answers the open question for a given implementation.
Cycle 519 is a negative observation and it needs the model. "No delivery followed the bad verdict" over an unbounded window is not an assertion-shaped statement.
retry_triggers at 526 is between attempt 1 and attempt 2, which is what §19's trigger search requires. An attempt 2 with no trigger in that interval is a spontaneous retransmission — still exactly one delivery, and caught only by the ratio check.
And had the first attempt never arrived — a drop rather than a corruption — arrival_cycles would have one entry and the ratio would be 2 ≤ 1+1, still passing. The counts tell you which fault was injected, which is how §42's seeded-defect mapping is built.
52. Walkthrough 4 — Recovery With Live Obligations
Illustrative. Three obligations outstanding; a fault; a recovery that renegotiates a narrower configuration.
| Cycle | Event | Semantic | Transport | Resource | Link |
|---|---|---|---|---|---|
| 700 | steady state | 3 OUTSTANDING, all cfg_epoch 5 | 2 live objects | credit 3 | epoch 5, OPERATIONAL |
| 702 | fault injected | — | — | — | — |
| 703 | EVT_RECOVERY_ENTER | annotated only — recoveries_spanned++ ×3 | mark_recovery_spanned | agreement void; pending cleared | RECOVERY, cfg_agreed 0 |
| 704–769 | (no admissions, no attempts) | 3 — unchanged | unchanged | no consumption | RECOVERY |
| 770 | EVT_CFG_COMMIT — narrower | 3 | — | — | cfg_epoch 5→6 |
| 778 | EVT_CREDIT_ADVERT 8 | — | — | credit 8 exactly | — |
| 784 | EVT_RECOVERY_EXIT | 3 | — | agreement valid | OPERATIONAL, link_epoch++ |
| 790 | attempt (retained object) | — | attempt_cycles grows | claim set | — |
| 812 | deliver, complete #1 | 2, cfg_epoch_at_accept 5 | — | — | epoch 6 |
| 860 | complete #2 | 1, cfg_epoch_at_accept 5 | — | — | — |
| 902 | complete #3 | 0, cfg_epoch_at_accept 5 | — | — | — |
Five readings.
The semantic column is 3 for 112 cycles, across the fault, the whole recovery and the renegotiation. §32's annotate_recovery is that one line, and §31's wrong handler would have written 0 at cycle 703.
Each completion carries cfg_epoch_at_accept = 5 while the current epoch is 6. §14's epoch check, and it passes because the record captured the epoch at accept and never rewrote it. A model reading the live epoch at completion time would report three false failures.
Credit becomes exactly 8 at 778. Not 3 plus something. The agreement was voided at 703, so the new count is the new advertisement and nothing else (19.5 §12) — and §33's matrix check is what verifies the design agrees.
Cycles 704 to 769 contain no consumption, which §40's R2 relation would catch if a claim appeared while the agreement was void.
And link_epoch advanced while every obligation's cfg_epoch_at_accept stayed at 5. §27's two independent epochs. A model with one counter cannot represent this trace.
53. Walkthrough 5 — A Duplicate Caught
Illustrative. 20.1 §55's scenario: the acknowledgement is lost, the retry is correct, and duplicate suppression at the far end fails.
| Cycle | Event | Transport (obj 44) | Semantic (tag 60) | Verdict |
|---|---|---|---|---|
| 900 | accept, alloc, commit, attempt 1 | attempt_cycles = [900] | OUTSTANDING | — |
| 913 | arrival, verdict good | arrival_cycles = [913] | — | — |
| 914 | deliver | — | delivery_count 1 | correct |
| 916 | (acknowledgement lost) | — | — | — |
| 940 | retry trigger — timeout | retry_triggers = [940] | — | — |
| 941 | attempt 2, obj 44 | attempt_cycles = [900, 941] | — | correct so far |
| 954 | arrival, verdict good | arrival_cycles = [913, 954] | — | — |
| 955 | deliver again | — | delivery_count 2 | ERR_DUPLICATE_DELIVERY |
Five readings.
Everything up to cycle 954 is correct. The acknowledgement was lost, the timeout was right to fire, the retransmission was right to happen, and the object arrived intact. The reliability mechanism did its job.
The bug is duplicate suppression, not retry. The far end's window failed to recognise the second arrival — because it was sized too small, or cleared, or keyed on something reused (19.3 §32).
No local check at the far end can catch this, because the far end has no way to know it already delivered unless it remembers, and the memory is the window that failed. The check must be in the environment, which remembers everything (§32's note on not consulting the design's window).
§15's error fires at cycle 955, the cycle of the second delivery — not at end of test, which is where a count-only model would report it with no cycle to look at.
And the near-end view is completely clean: one operation, two attempts, one acknowledgement eventually, one completion. Every near-end check passes, which is why the far-end observation point (20.1 §6) is not optional for plane 4.
54. Walkthrough 6 — A Stranded Resource
Illustrative. The failure with no client-visible symptom, caught only by §25's join check.
| Cycle | Event | Semantic (tag 77) | Transport (obj 88) | Resource |
|---|---|---|---|---|
| 1000 | accept, alloc, commit, attempt | OUTSTANDING | COMMITTED, history_live = 1 | claim set, credit 7 |
| 1024 | deliver | delivery_count 1 | — | — |
| 1032 | complete | COMPLETED | COMMITTED, history_live still 1 | claim still set |
| 1040 | §25 runs periodically | — | — | — |
| 1040 | — | — | ERR_STRANDED_REPLAY | ERR_STRANDED_CREDIT |
| 1100+ | traffic continues normally | — | one replay slot permanently held | one credit permanently held |
Five readings.
The client saw a completely correct transaction. Accepted, delivered once, completed once. Every semantic invariant holds and the data was right.
The design believes both resources are legitimately allocated, which is the bug — so no design assertion can fire, because from the design's own view nothing is wrong.
The failure signature is a slow monotonic capacity reduction (19.5 §63). One stranded slot is invisible; five hundred of them stop the link, hours later, with the cause far in the past.
§25 catches it at cycle 1040 because it is a join check — it relates the transport model's history_live and the resource model's claim to the semantic model's terminal state. No single model can compute it, which is the clearest single argument against §4's monolith.
And it must run periodically, not only at end of test. At end of test the finding is "some resources were stranded"; at cycle 1040 the finding is "obligation 77's replay entry and credit claim survived its completion at cycle 1032" — which names the release path.
55. End-of-Test Checks
// ILLUSTRATIVE. What must be true when the run ends. Order matters: the
// environment's own health first (§39).
function automatic void check_end_of_test();
// 0 — the environment. If this fails, nothing below is trustworthy.
check_join_health();
check_model_self_consistency();
// 1 — no obligation left outstanding, unless the run ended in a fatal state
// in which case each must carry a CLASSIFICATION (20.2 §41).
foreach (sem_model.obligations[t]) begin
ucie_obligation ob = sem_model.obligations[t];
if (ob.state == OB_OUTSTANDING) begin
if (link_model.phase == RM_FAILED)
report_error(ERR_UNCLASSIFIED_ON_FATAL, t,
"outstanding at a fatal end with no classification");
else
report_error(ERR_OBLIGATION_NEVER_TERMINATED, t,
$sformatf("accepted at %0d, still outstanding", ob.accept_cycle));
end else begin
chk(ob.delivery_count == 1 || ob.state != OB_COMPLETED, ERR_DELIVERY_COUNT, t);
chk(ob.completion_count == 1 || ob.state != OB_COMPLETED, ERR_COMPLETION_COUNT, t);
end
end
// 2 — no transport object left unresolved.
foreach (trans_model.objects[o])
chk(trans_model.objects[o].state inside {TR_RESOLVED, TR_ABANDONED},
ERR_OBJECT_UNRESOLVED, o);
// 3 — resources returned to baseline.
for (int c = 0; c < NUM_CLASSES; c++) begin
chk(res_model.count_live_claims(c) == 0, ERR_CLAIM_LEAKED, c);
chk(res_model.expected_credit(c) == res_model.active_capacity[c],
ERR_CREDIT_NOT_RESTORED, c);
end
foreach (res_model.pushed[s])
chk(res_model.occupancy(s) == 0, ERR_STRUCTURE_NOT_DRAINED, s);
// 4 — the run actually exercised something (§56).
check_minimum_activity();
// 5 — statistics, printed on PASSING runs too (§39).
print_statistics();
endfunctionThree notes.
Check 1's fatal branch matters. 20.2 §41: an obligation outstanding at a fatal end is not necessarily a bug — it must be classified. A test whose pass criterion is "nothing outstanding" cannot express a correct fatal run and reports a failure on correct fatal handling.
Check 3's credit condition is equality against capacity, not merely non-zero. A credit count that returned to a value below capacity means returns were lost; above capacity means returns were manufactured. Both are silent during the run.
And check 4 exists because of §56.
56. Wrong End-of-Test — Passing on an Empty Model
// WRONG — the entire end-of-test check.
final begin
if (sem_model.obligations.size() == 0) $display("PASS");
endFour ways an empty model is achieved without verifying anything.
The stimulus never ran. A configuration error, a clock that never started, a reset never released. Zero obligations, zero errors, PASS.
Every obligation was cleared by §31's wrong reset handler. Zero obligations, and four operations lost. PASS.
Every obligation was aged out by §35's wrong retirement policy. Zero obligations, and one of them was genuinely stuck. PASS.
And the monitors were never connected. A virtual interface that was never assigned, or a bind path that silently matched nothing. Zero events, zero obligations, PASS — and this is the one that survives longest, because it produces no warnings anywhere.
The defence is a minimum-activity check, and it is three lines:
// RIGHT — the run must have DONE something, and the thresholds come from the
// test's own intent rather than from a global constant.
function automatic void check_minimum_activity();
chk(stats.total_accepted >= test_cfg.min_expected_operations,
ERR_INSUFFICIENT_ACTIVITY, stats.total_accepted);
chk(stats.total_completed >= test_cfg.min_expected_completions,
ERR_INSUFFICIENT_COMPLETIONS, stats.total_completed);
chk(stats.events_observed > 0, ERR_NO_EVENTS_OBSERVED, 0);
// Per-monitor, because one silent monitor is the failure §56 describes.
foreach (stats.events_per_boundary[b])
chk(stats.events_per_boundary[b] > 0, ERR_SILENT_MONITOR, b);
endfunctionThe per-boundary check is the valuable one. A run in which three monitors published and the fourth did not looks completely healthy in aggregate — and the fourth monitor is the one whose plane is now unverified.
57. Coverage Hooks
The models already hold the state a coverage model needs. Exposing it is cheap; 20.5 owns the model itself.
| Hook | Sampled from | Enables the bin |
|---|---|---|
recoveries_spanned per completion | semantic record | did any obligation cross a link event? (20.2 §53) |
attempt_cycles.size() per resolution | transport record | were retries exercised, and how deep? |
arrival_cycles.size() vs attempts | transport record | which fault kind was injected? |
outstanding_count at each recovery entry | semantic model | was preservation actually tested? |
stale_returns per class | resource model | was the epoch guard exercised? |
min_credit, high_water | resource model | sizing evidence |
| ordering-group pair orders observed | semantic model | were both legal orders seen? (20.3 §22) |
cfg_epoch deltas per obligation lifetime | join | did a commit land mid-operation? |
Two notes.
Row 4 is the bin that validates the whole recovery-preservation story. A regression where every recovery happened with nothing outstanding exercised the recovery path and never tested preservation.
And row 3 is what turns fault injection into evidence. Attempts-minus-arrivals distinguishes a corruption from a drop, so the coverage model can confirm that both were injected rather than only one of them, twice as often.
58. Debug Taxonomy
Seven symptoms specific to scoreboards, each with a first place to look.
A mismatch on correct hardware, at every reordered completion. A queue-based expectation model. §13 — the fix is associative storage keyed by tag, not a search added to the queue.
A completion for an unknown obligation. Either a record was retired while live (§35), a tag was mis-attributed (§41), or the design invented a completion. Check the retirement policy first, because it is the most likely and the cheapest to confirm.
Zero outstanding after a recovery, and the design also reports zero. §31. The environment cleared its models on the design's reset, so the agreement is the bug. Check the reset policy before believing the pass.
Intermittent, seed-dependent drops of allocation events. Monitor delivery order. §44–§45 — the model assumed one monitor publishes before another.
A slowly growing live set, or memory exhaustion. Either the design is leaking obligations, or a monitor is not publishing completions. §36's live-set-explosion check names which, because it fires long before memory does.
Errors reported that name no layer. §4's monolith, or §38's last-error reporting. The fix is structural — four models and a first-divergence engine — and it is not a reporting tweak.
And a green run with almost no activity. §56. Check per-boundary event counts before anything else, because one silent monitor produces a healthy-looking aggregate.
59. Debug Checklist
A scoreboard reported a failure, or a green run is under suspicion. In order:
- Is the environment-health section empty (§39)? If not, stop and fix that first.
- Did every monitor publish events (§56)? A silent monitor is a whole unverified plane.
- How many events did each boundary publish, and are the ratios plausible?
- Are there join anomalies — a tag with no work, work with no tag, or a tag collision (§22)?
- Do the models' self-checks pass (§42)?
- Do the secondary indices agree with their primaries (§12, §48)?
- What is the first divergence by event sequence, and at which boundary (§37)?
- How many later errors are candidates for being consequences?
- Which model detected the first divergence? That is the layer.
- What was the link phase at that cycle, and the two epochs?
- Was there a recovery in the window? If so, was the semantic model annotated or cleared (§32)?
- Does the design's outstanding count match the model's at the last recovery exit?
- For the failing obligation: what is its
cfg_epoch_at_accept, and does the completion carry the same value (§14)? - How many objects carry it, and how many attempts and arrivals does each have (§20)?
- Is
attempts ≤ arrivals + 1? A surplus is an unexplained retransmission. - Does each attempt beyond the first have a trigger in the right interval (§19)?
- Is
delivery_countexactly 1, andcompletion_countexactly 1? - Are the parts bitmaps equal, and if not which parts are missing (§10)?
- Was the obligation retired while still outstanding (§35)?
- Is any resource live for an obligation that is terminal (§25)?
- Does credit conservation hold, and does the live-claim count fit the capacity (§24)?
- Is any capacity identity broken — occupancy plus in-flight plus free not equal to capacity?
- Does any model read another model's state outside the join (§4)?
- Does any model or monitor read a design-internal signal (§17)?
- Does the model call a design function (19.6 §13)?
- Is the primary map keyed by the monitor tag, or by a protocol field (§21)?
- Are the epochs two independent counters, or one (§27)?
- Did the run meet its minimum-activity thresholds (§56)?
60. Common Misconceptions
"A scoreboard is a queue of expected packets." Then out-of-order completion is a mismatch, a fragmented operation cannot be represented, a retry is a phantom transaction, and nothing survives a recovery. All four are normal UCIe situations (§13).
"One scoreboard class is simpler." It is simpler to write and it loses the localisation, cannot express three reset policies, cannot be selectively disabled, and — the real cost — makes it irresistible for one model to derive its state from another's, after which the two can no longer disagree (§4).
"Key the scoreboard on the transaction ID." The design's identity is not unique over time. A legal reuse silently overwrites a record and the aliasing bug becomes unstateable (§21).
"Clear the models when the design resets." Then a recovery wired to a global reset clears the semantic table, the environment clears too, both report zero, and four operations vanish with a green report (§31).
"Age records out to bound memory." Then a legally slow obligation — one that spanned a recovery — is deleted while live, its completion is reported as an error on correct hardware, and the fix applied removes a real check (§35).
"Empty at end of test means it worked." It also means the stimulus never ran, the models were cleared, the records were aged out, or the monitors were never connected. Three of those four produce zero warnings anywhere (§56).
"Report the failure the test ended with." Cascades are the normal shape: one lost obligation produces five errors. The last one points at the completion path and the cause was 41,000 cycles earlier at a different boundary (§38).
"A model can read the design's table — it is the same information." It is the same information including the error. The mirroring model agreed with the design at the exact cycle the design was wrong, and its silence was read as evidence (§16).
"Attempts can be a counter." Then a retransmission is indistinguishable from a first transmission of another object, and attempts-versus-arrivals cannot tell you which fault was injected (§18).
"Parts can be a counter." Then part 2 arriving twice is indistinguishable from parts 2 and 3 arriving once each (§10).
"Monitors can assume a publication order." Within a cycle the order is a scheduling artefact. A model that depends on it fails intermittently, seed-dependently, and differently across tool versions (§45).
"Cycle numbers are comparable across monitors." Not when the boundaries are in different clock domains. Correlate by tag; use a global sequence for ordering; keep per-domain cycles for the waveform (§46).
"A stranded resource will show up eventually." It shows up as a link that stops working hours later, with the cause far in the past. Only a periodic join check between the resource and semantic models names it at the cycle it happened (§54).
61. Understanding Check
62. Summary and What Comes Next
A scoreboard models obligations, not expected packets — so it answers what exists, who owns it, what may still arrive out of order, what has already happened, what must never happen twice, and what survives a recovery.
Four models, not one class. Separate state, separate reset policies, separate enable switches, and no model reading another's state except through an explicit join — because the moment one model derives its state from another, the two can no longer disagree, and disagreement is the only detection either provides.
Key on a tag the design cannot influence. The design's identity is not unique over time, and an associative array keyed by it silently overwrites a record on every legal reuse.
Use queues and bitmaps where the question needs them. Attempts and arrivals as queues, because their ratio says which fault was injected; parts as a bitmap, because a count cannot separate one part twice from two parts once.
Capture epochs at acceptance and never rewrite them. Two independent epochs — link and configuration — because an operation legitimately outlives the configuration it began under.
Annotate at a recovery; clear only on a cold reset, and report before clearing. That one line is the difference between an environment that detects a recovery clearing semantic state and one that agrees with it.
Retire on terminal state, not on age. Age-based retirement deletes a legally slow obligation and then reports its completion as an error, and the fix applied removes a real check.
Check the join before checking the design, and report environment anomalies in their own category — because a scoreboard whose join fails silently reports passes it has not earned, and that is the one failure a verification report cannot detect from its own output.
Report the first divergence with its boundary, not the last symptom. Cascades are the normal shape: one lost obligation produces five errors, and the last one points at the wrong layer.
And make an empty model mean something. Zero outstanding at end of test is also what a run with no stimulus, cleared models, aged-out records or unconnected monitors produces — and three of those four emit no diagnostic at all.
Module 20 now has its philosophy, its link-level composition, its temporal properties and its long-lived models. What none of them yet answers is whether the tests actually reached the situations these checks were written for — how many of the widths, protocol mixes, fault kinds, recovery paths and simultaneous-event combinations were genuinely exercised, and how a plan states its own completeness rather than asserting it. The next chapter builds the coverage model that turns "every check passed" into "every check ran, on this enumerated set of situations".
- 20.5 — UCIe Functional Coverage — coverage model for link widths, protocols and error injection.
Browse the full path on the UCIe tutorials index.