skip to content

Why does a naive random generator explore almost none of a structured input space?

level: seniorimportance: should knowfreq 47%

answer

  1. Random fields rarely assemble into valid shapes
  2. Interesting behaviour lives in relationships
  3. Unique identifiers mean no collisions ever
  4. Construct valid inputs instead of filtering
  5. Classify the sample and report the shares

basics

~20 s

Because independently randomised fields almost never assemble into the interesting shapes: most values are rejected at the first validation check, or are small and unrelated, so the states that carry the defects - collisions, duplicates, boundaries - are never reached.

solid answer

~40 s

Structure is the problem. Fields drawn independently produce values that fail validation immediately, or that are valid but degenerate: tiny collections, unique identifiers everywhere, no repeated keys, no equal timestamps. Interesting behaviour usually lives where inputs *relate* to each other, and independent randomness destroys relationships. The remedies are to **construct** valid inputs rather than generate and filter, to draw identifiers from a deliberately small pool so collisions happen, to include boundary and degenerate values on purpose, and to grow the size and depth of inputs across the run. Then verify rather than assume: classify each generated input into the classes you care about and report the distribution, and check which branches of the code the run actually reached. A generator you have never measured is a generator you are trusting on faith.

code

pseudocode · 11 lines
pseudocode
# naive: ids drawn from the whole space, collisions are vanishingly rare
genBatchNaive = list(of: request(playlistId = anyIdentifier(),
                                version   = anyInteger(),
                                tracks    = anyTrackList()))

# deliberate: eleven ids and a short version range, so conflicts are common
idPool  = ["pl-01" .. "pl-11"]
genBatch = list(size: 0..9,
                of: request(playlistId = elementOf(idPool),
                            version    = integerBetween(1, 4),
                            tracks     = anyTrackList()))

go deeper

for a junior

Understand that the generator, not the assertion, decides what gets tested, and that identifiers drawn at random almost never repeat, so anything about duplicates or conflicts goes untested by default.

for a middle

Be ready to explain generate-and-filter versus valid-by-construction, and to name the deliberate additions - boundary values, growing sizes, a small identifier pool - that make a generator worth running.

for a senior

Demonstrate measurement, not intuition: classify generated inputs and report the shares, check which branches a property-only run reached, and diagnose a silent green suite as a distribution defect rather than adding iterations.

for a principal

Own the standard for generator review and the compute budget behind it - how many inputs, how often, gating or nightly - and the balance between skewing toward known-risky relationships and preserving the freedom that finds unimagined shapes.

### The arithmetic of blind sampling A property is only as good as the inputs it sees. The common failure is not a wrong property but a generator that produces ten thousand inputs from one uninteresting corner of the space. Take the playlist service's batch endpoint, which at peak absorbs a 1,200-request-per-minute stream of playlist updates. A first generator builds a batch of update requests, each with a playlist id, a version number and a track list, every field drawn independently at random. It runs 12,800 batches and stays green. Then production shows corrupted playlists. The reason is visible the moment anyone looks at what was generated. Playlist ids were drawn uniformly from a huge identifier space, so **two requests in the same batch almost never touched the same playlist** - across 12,800 batches there were three collisions. The concurrent-update path, where two updates to one playlist must be applied in version order, was therefore exercised three times, and never with equal version numbers. The defect was an **ordering assumption**: the batch handler applied updates in whatever order the batch happened to arrive in and assumed that order matched version order. Real traffic at 1,200 requests per minute breaks that assumption constantly; a uniform generator practically never does. ### The four ways naive generation goes wrong **Validation death.** Independently random fields rarely satisfy structural constraints - a date inside a range, a checksum, a well-formed identifier, a total that matches its line items. If the property discards invalid inputs after generating them, most of the run is spent generating and throwing away, the effective sample is a fraction of what the count suggests, and the surviving inputs are the ones that were easy to hit by chance. **Lost relationships.** Almost every interesting behaviour depends on two parts of the input relating to each other: the same id twice, a timestamp equal to another, a range that overlaps another, a reference that resolves. Independent draws destroy all of them. **Degenerate size.** Unless size is deliberately grown, generated collections cluster small. Off-by-one behaviour at a cap of 43, pagination past the first page, and recursion deeper than one level are simply never visited. **Missing the boundaries.** The values most likely to break code - empty, zero, one, the maximum, a duplicate, the same value twice, an unusual character class - have negligible probability under uniform sampling of a large domain, yet they are where the defects are. ### What good generation does instead **Construct, do not filter.** Build inputs that are valid by construction: pick a track list first, then derive a consistent count; generate a version sequence rather than independent integers. Filtering is acceptable for a rare, cheap final check; it is a poor way to satisfy the main structure. **Shrink the pool to force collisions.** Draw playlist ids from a pool of eleven, not from the whole identifier space. This single change turns a collision from a once-in-4,000-batches accident into the common case, and it is usually the highest-value edit anyone makes to a generator. **Inject the boundaries deliberately.** Mix in the empty collection, the single element, the value at the cap and just past it, the repeated element, the equal pair. These belong in the generator, not left to chance. **Grow size across the run.** Start small so early failures are readable, and increase size and depth as the run proceeds so deep structures are reached at least sometimes. **Model the traffic you actually have.** The generator should reflect how the real workload is shaped - repeated hot ids, bursts, retries of the same request - not an imagined uniform world. ### Measure, do not assume This is what separates a senior answer. Two cheap instruments: 1. **Classify and report.** Label each generated input with the classes you care about - batch contains a repeated playlist id; batch is empty; two updates share a version - and print the share of each at the end of the run. Most property libraries offer this, and some let a run fail when a required class stays under a threshold. Seeing *0.02% contained a repeated id* ends the argument in one line. 2. **Check what the run reached.** Run the property suite under a coverage tool and look at the branches inside the code under test. Generated inputs that never reach the conflict branch cannot test it, no matter how many there are. A related trap is over-correcting: constraining the generator so tightly that it only produces the shapes you already imagined. Then the suite tests your assumptions rather than the space. Aim for valid-by-construction plus deliberate skew toward the interesting relationships, while leaving room for shapes you did not think of - the value of generated input is precisely that it produces inputs you would not have written. Finally, distribution problems and shrinking problems are different problems. Shrinking makes a *found* failure readable; it cannot find anything. If the generator never produces two updates to one playlist, no amount of shrinking quality will surface the ordering defect.

  • What is wrong with generating freely and discarding whatever fails validation?
    It quietly shrinks the effective sample and skews it. The run reports the number attempted, not the number that survived, so a discard-heavy generator looks far stronger than it is; and the inputs that survive are the ones easiest to hit by chance, which are rarely the interesting ones. Construct valid inputs instead, and keep filtering for a final rare condition that is cheap to check.
  • How would you prove to a sceptical reviewer that a generator reaches the case you claim?
    Two artefacts. First, classification output from the run: label each generated input with the class of interest and report the share, so the reviewer sees a real percentage rather than a claim. Second, a coverage report over the code under test from a property-only run, showing that the branch in question was executed. If either says zero, the generator is the finding, not the property.
  • What is the risk of constraining a generator until every input is realistic?
    You end up testing the shapes you already imagined. Over-constrained generation converges on the same handful of cases an example test would have covered, and loses the main advantage of generated input, which is producing values nobody thought to write down. Constrain enough to be valid and to hit the relationships you know matter, then leave the remaining dimensions free.

Handing a fuzzer the keyboard produces gibberish, not sentences; to test a grammar checker you must generate things that are already sentences, and skew them toward the constructions that trip it up.

saying these in an interview costs you the question

  • Assumes more iterations fix a distribution problem
  • Draws every field independently and calls it thorough
  • Never inspects what the generator actually produced
  • Filters out most inputs and reports the attempted count
  • Expects shrinking to compensate for inputs never generated
  • Constrains the generator until only expected shapes appear

context