Part 3 · Constraint Randomization · Intermediate

State Variables & Cross-Object Constraints

Non-rand state in constraints, config knobs gating ranges, obj.field references, nested rand objects, and global solve order.

Non-rand state variables: parameterized constraints

Any non-rand class property referenced in a constraint is read as a constant at solve time — the solver never assigns it, it just uses its current value. This turns plain variables into configuration knobs: a test sets the knob procedurally, and every subsequent randomize() solves against the new value. It is the lightest-weight control mechanism of all — no blocks to toggle, no subclasses, no inline repetition — and the backbone of every “knobs object” style environment.

systemverilog
class dma_txn;
  // ---- state knobs (NOT rand): set by test/config ----
  int unsigned max_len   = 64;
  bit          allow_wr  = 1;

  // ---- solved fields ----
  rand bit [9:0] len;
  rand bit       write;

  constraint len_c   { len inside {[1:max_len]}; }   // knob gates range
  constraint write_c { !allow_wr -> write == 0; }    // knob gates a mode
endclass

dma_txn t = new();
void'(t.randomize());            // len ∈ [1:64], writes allowed

t.max_len  = 8;                  // reconfigure between calls
t.allow_wr = 0;
void'(t.randomize());            // len ∈ [1:8], write forced to 0

Because the knob is read fresh at each solve, the value at the moment of the randomize() call is what counts — set knobs before randomizing, and remember a knob can make the space empty (max_len = 0 above) just like any contradiction.


Referencing other objects' fields

Constraint expressions can reach through object handles: cfg.max_burst or prev.addr. If the referenced object is NOT being randomized in this call, its fields are simply more state — read as constants. This gives you constraints against a shared config object, or against the previous transaction for ordered stimulus. The handle must be non-null at solve time; a null dereference in a constraint is a runtime error.

systemverilog
class bus_cfg;
  rand bit [9:0] max_burst;     // rand here, but state from txn's view
  constraint c { max_burst inside {[8:64]}; }
endclass

class bus_txn;
  bus_cfg  cfg;                  // handle to shared config (state)
  bus_txn  prev;                 // handle to previous txn   (state)

  rand bit [9:0]  burst;
  rand bit [31:0] addr;

  constraint burst_c { burst <= cfg.max_burst; }           // cross-object
  constraint order_c {
    prev != null -> addr > prev.addr;                       // sequencing
  }
endclass
// cfg was randomized ONCE at env setup; each txn.randomize() reads
// cfg.max_burst as a constant. prev.addr likewise — already-solved history.

The prev != null guard is essential and idiomatic: handles are legal in constraint boolean expressions exactly so you can gate dereferences. The first transaction (prev == null) leaves order_c satisfied vacuously.


Nested rand objects: one big simultaneous solve

If an object handle is declared rand, calling randomize() on the parent recursively randomizes the child in the same solver invocation — parent and child variables become one constraint system, solved simultaneously and bidirectionally. Constraints in the parent that mention child fields (historically called global constraints) connect the two; a child-field constraint can push back on parent fields, exactly like any other bidirectional relation.

systemverilog
class payload;
  rand bit [7:0] nbytes;
  constraint c { nbytes inside {[1:64]}; }
endclass

class frame;
  rand payload   pl;             // rand handle → solved WITH the frame
  rand bit [9:0] total_len;
  rand bit [3:0] hdr_len;

  constraint glue_c {
    total_len == hdr_len + pl.nbytes;   // spans parent AND child — global
    hdr_len inside {[4:12]};
  }

  function new(); pl = new(); endfunction  // must allocate before solve!
endclass
// frame.randomize() solves {pl.nbytes, total_len, hdr_len} TOGETHER:
// e.g. inline 'with { total_len == 20; }' can force pl.nbytes ∈ [8:16].
// Contrast: if pl were NOT rand, pl.nbytes would be frozen state and
// total_len would have to adapt around its current value.
diagram
STATE vs NESTED-RAND — WHO IS IN THE SOLVE?

  non-rand handle / field            rand handle
  ───────────────────────            ────────────────────────────
  cfg.max_burst, prev.addr           frame.pl  (pl.nbytes)
        │                                  │
        ▼                                  ▼
  READ as constants                  JOIN the constraint system
  one-way influence ──►              ◄── bidirectional ──►
  (txn adapts to them)               (child can push parent fields)

  solve order inside one randomize() call:
    1. randc variables (parent + nested, cycled independently)
    2. ALL rand variables of parent + rand children: ONE simultaneous
       solve over every active constraint, incl. cross-object glue_c
  rand_mode(0) on pl   demotes the whole child to state for that call.

Interview angle

What interviewers ask

  • “How does a non-rand variable behave inside a constraint?” — read as a constant at solve time; the solver constrains around it, never assigns it. The screening question.

  • “How do you parameterize transaction ranges per test without subclassing?” — non-rand knobs (or a cfg object handle) referenced in constraints; set procedurally, read at each solve.

  • “What changes when a member object handle is rand?” — its fields join the parent's solve; constraints spanning both become bidirectional, and parent randomize() recurses into it.

  • “Can a child constraint affect a parent field?” — yes, when the child is rand: one simultaneous system. Not when the child is plain state.

  • “What about null handles in constraints?” — dereferencing null at solve time is a runtime error; guard with handle != null -> ... implications.

Key takeaways

  • Non-rand variables in constraints are solve-time constants — the cheapest per-test control knob.

  • obj.field references read other objects as state, enabling config-driven and history-driven constraints.

  • rand object handles merge child variables into one simultaneous, bidirectional solve with the parent.

  • Guard cross-object dereferences with null checks inside implications.

  • rand_mode(0) on a rand handle demotes the entire child object to state for that call.

Common pitfalls

  • Forgetting to construct a rand child before randomize() — null dereference at solve time.

  • Setting knobs AFTER randomize() and wondering why the old range was used — knobs are read at the call.

  • Assuming a non-rand cfg field can be adjusted by the solver to avoid contradiction — state never moves.

  • Knob values that empty the solution space (max_len = 0) — randomize() returns 0, often blamed on the constraints.

  • Deep rand-object trees solved as one system — solver time explodes; demote stable subtrees with rand_mode(0).