tiny.reducer
Overview · API · Code relationships · Verification · Audit
Overview
Starting from an input that reproduces a defect and deleting runs of bytes from it until a smaller input still reproduces it is test-case reduction.
The example running through this doc is the input "abcXYZdef" with a function answering whether a candidate reproduces the defect, the oracle, that answers interesting for any candidate containing "XYZ":
const std = @import("std");const reducer = @import("reducer");const Context = struct { needle: []const u8 };fn checkNeedle(input: []const u8, ptr: *anyopaque) anyerror!reducer.Interesting { const ctx: *const Context = @ptrCast(@alignCast(ptr)); return if (std.mem.indexOf(u8, input, ctx.needle) != null) .interesting else .uninteresting;}test "basic reducer demonstration" { const allocator = std.testing.allocator; const initial = "abcXYZdef"; var ctx = Context{ .needle = "XYZ" }; var storage = try reducer.Storage.init(allocator, .{ .max_input_bytes = initial.len }); defer storage.deinit(allocator); storage.activate(); var result = try reducer.reduce(&storage, initial, checkNeedle, &ctx, .{}); defer result.deinit(); try std.testing.expectEqualSlices(u8, "XYZ", result.bytes); try std.testing.expectEqual(reducer.Completion.one_minimal, result.completion);}Once the workspace (the one byte allocation Storage makes up front and reuses for every run) is initialized and sealed into steady state, its owner allocates nothing further, and the reduction loop itself calls no allocator.
The Oracle Contract
The caller hands the condition under test to reduce() as a callback whose type is InterestingFn:
fn interesting(candidate: []const u8, context: *anyopaque) anyerror!InterestingThe callback answers interesting when the candidate (one shorter byte sequence built by deleting a run of bytes from the current sequence and handed to the oracle) reproduces the condition under test and uninteresting when it does not, which covers a candidate that fails to parse, holds invalid UTF-8, or runs into an unrelated error. A caller's oracle answers interesting for the one symptom under investigation, and a candidate that crashes or errors in some other way is uninteresting. Returning a Zig error from the callback ends the run at once: the error travels out to the caller, and this run's lease on the workspace is given up.
The search works on raw bytes, so a deletion falls at any byte offset and respects no line of text and no character boundary. On attempt 1 (the call of the oracle on the caller's own input), the callback receives the caller's own initial slice, so that memory stays valid and unchanged for the whole run. On every later attempt the callback receives a slice into the scratch lane (the fixed part of the workspace where each candidate is assembled), whose bytes the next iteration overwrites, so the callback retains no candidate slice past its return.
The context pointer stays alive for the whole synchronous call, and the cast the callback performs on it matches the real type and alignment of what it points at. Side effects such as logging or the callback's own scratch allocation are allowed as long as the answer for a given candidate stays the same. The minimality claims below rest on an oracle that is deterministic and decides from the candidate's content alone, which is an assumption about the caller's code.
Reduction searches the space of inputs for a smaller one that still reproduces the defect. Fault injection answers a different question: lib/tripwire fires simulated error sites and needs its own postcondition assertions to check that rollback happened.
Deletion Search and Minimality
Dropping the byte at index of a sequence whose length is , for , and joining the two half-open pieces x[0..i] and x[i+1..n] is occurrence deletion, producing a shorter subsequence:
At the leading piece x[0..0] is empty, and at the trailing piece x[n..n] is empty.
An interesting sequence where every single-byte deletion from it was tried and came back uninteresting is 1-minimal under that oracle:
An empty sequence has no index to delete, so an empty interesting input is already 1-minimal.
Differences from Textbook ddmin
Andreas Zeller and Ralf Hildebrandt formulated delta debugging (ddmin), which splits an input into subsets and alternates between testing one subset alone and testing its complement , moving between 2 and (Simplifying and Isolating Failure-Inducing Input, IEEE TSE 2002, Figure 5).
This package differs from that formulation in three ways:
- Deletion sweeps: the search runs contiguous deletion sweeps (passes over the current sequence at a fixed chunk, from its start to its end) through
without()and has no separate phase that tests one subset alone. Deleting one half of a two-way split leaves the other half behind, so that case arrives through deletion. - Chunk halving: the search tracks a chunk (the number of adjacent bytes one candidate deletes) in bytes where
ddmintracks a count of subsets. It starts with the chunk at the whole length of the current sequence (the shortest sequence the oracle has accepted so far, which the search deletes from next), which tests the empty sequence"", and halves the chunk with ceiling division down to 1:
- Greedy restart: accepting a candidate is a greedy restart that records a reduction, one candidate accepted by the oracle that replaces the current sequence and is strictly shorter. The current sequence becomes that candidate,
reductionsgoes up by one, and the chunk resets to the whole length of the new current sequence.
Building a candidate copies bytes into the scratch lane through without(), and accepting one swaps which lane each of the two local names refers to, so no bytes are copied back.
Trace: abcXYZdef to XYZ
For the input "abcXYZdef", whose length is 9, with an oracle requiring "XYZ", the search runs 14 attempts and accepts 2 reductions:
| Attempt | Candidate | Outcome |
|---|---|---|
| 1 | "abcXYZdef" |
Baseline verified; start chunk 9 |
| 2 | "" |
Rejected; next chunk 5 |
| 3 | "Zdef" |
Rejected (delete [0..5]) |
| 4 | "abcXY" |
Rejected (delete [5..9]); next chunk 3 |
| 5 | "XYZdef" |
Reduction 1: accepted; restart chunk 6 |
| 6 | "" |
Rejected; next chunk 3 |
| 7 | "def" |
Rejected (delete [0..3]) |
| 8 | "XYZ" |
Reduction 2: accepted; restart chunk 3 |
| 9 | "" |
Rejected; next chunk 2 |
| 10 | "Z" |
Rejected (delete [0..2]) |
| 11 | "XY" |
Rejected (delete [2..3]); next chunk 1 |
| 12 | "YZ" |
Rejected (delete index 0) |
| 13 | "XZ" |
Rejected (delete index 1) |
| 14 | "XY" |
Rejected (delete index 2); sweep complete |
Every single-byte deletion at chunk 1 came back uninteresting, so the run ends with Completion.one_minimal.
Local and Global Minimality
1-minimality says that no single byte comes out of the result on its own. It leaves open whether some shorter interesting sequence exists, which would be globally shortest (no shorter interesting sequence existing at all).
A worked case: the input "ABCD" with an oracle that accepts any sequence containing 'A' or containing "CD":
"ABCD"is interesting.- At chunk 2, deleting
"AB"tests"CD", which the oracle accepts. - From
"CD", the single-byte deletions"D"and"C"are both rejected. - The single-byte sweep then finishes, which certifies
"CD", of length 2, asone_minimal. "A", of length 1, also satisfies that oracle.
Greedy chunk deletion follows the first deletion that succeeds, so when an input carries multiple independent causes of the failure, the result depends on which deletion the sweep reaches first.
Abstract Formal Model
A formal model of reduction paths over List α lives in verification/foundations/Foundations/Reduction, in Core.lean and Necessity.lean:
gap_is_not_globallyShortestproves that a 1-minimal list can fail to be globally shortest.budget_truncation_is_not_oneMinimalgives a concrete witness where cutting the search short of all single-byte deletions gives up 1-minimality.
Completion records how the search stopped at run time. A run that ends in budget_exhausted carries no 1-minimality guarantee, even when its result happens to be minimal. The Lean development is an abstract calculus over lists, and it proves nothing about this Zig code.
Attempt Budgets and Boundary Behavior
The ceiling on the number of oracle calls, Settings.max_attempts, forms the attempt budget, capping how many times the oracle is called and counting the call on the caller's own input as attempt 1. The cap covers oracle calls alone: bytes copied, memory the callback allocates, and wall-clock time all sit outside it.
- A budget of 0 returns
error.AttemptBudgetExhaustedbefore the workspace is acquired, leaving every byte and every flag of the workspace as it was. - A budget of 1:
- A budget of 1 on an empty interesting input returns
Completion.one_minimalstraight away, with 1 attempt and 0 reductions. - A budget of 1 on a nonempty interesting input stops after attempt 1 with
Completion.budget_exhaustedand hands back the input as it arrived. - A budget that is exactly enough for the single-byte sweep to try every deletion still certifies
Completion.one_minimal, including when the sweep finishes on the last allowed attempt.
Storage Architecture and Lifecycle
| Operation | Effect / Transition |
|---|---|
storage.activate() |
Moves initialization to steady (idle) |
storage.acquire(bytes) |
Leases steady (idle) to steady (acquired) |
result.deinit() / storage.release() |
Releases steady (acquired) back to steady (idle) |
storage.deinit(allocator) (unactivated) |
Frees unactivated initialization to teardown |
storage.deinit(allocator) (idle) |
Frees idle steady to teardown (invalid while acquired) |
- Capacity derivation:
Capacity.derive(limits)calculates the workspace capacity (the arithmetic layout derived from the caller's limits, the sizing figure for the longest input submitted) as bytes and allocates nothing. A sum that overflowsusizereturnsDeriveError.CapacityOverflow. For the figure is 0 bytes. Filling inStorageorCapacityfields by hand is unsafe, so callers reach the same layout throughStorage.init()andCapacity.derive(). - Initialization:
Storage.init(allocator, limits)allocates the backing memory once, setting the workspace lifecycle phase toPhase.initialization. A workspace that was never activated can be freed directly bystorage.deinit(allocator). - Activation:
storage.activate()seals the workspace intoPhase.steady, idle with no lease outstanding so the next run can acquire it. After that the workspace owner allocates nothing further. - Acquisition:
storage.acquire(input_bytes)checks the input against the bounds, issues a lease by settingin_use = truefor this run's exclusive hold, and returnsRegions, writing nothing into the lanes. - Error isolation: the three errors raised before acquisition,
AttemptBudgetExhausted,ReductionStorageInUse, andInputCapacityExceeded, leave the workspace as it was, so a call that finds the workspace busy leaves an earlier liveResultholding its lease. An error after acquisition gives up this run's lease througherrdefer storage.release()and returns the workspace to idle, which restores neither the scratch bytes the run overwrote nor any side effect the callback produced. - Release and reuse:
Result.deinit(), orstorage.release(), clearsin_useand returns the workspace to steady and idle.deinit()also clears the result's own fields, settingbytes = &.{},attempts = 0,reductions = 0, andcompletion = .budget_exhausted, which ends the borrow (Result.bytesreferencing workspace memory), and it is called exactly once per result. Feeding one run's result back in as the next run's input on the same workspace means copyingresult.bytesfirst. - Teardown:
storage.deinit(allocator)checks that no lease is out, moves the workspace toPhase.teardown, and frees the memory with the same allocatorinit()received.
Ownership and Concurrency
Result.bytesborrows from the workspace and stays readable untilresult.deinit().Storage.currentandStorage.candidateare fixed regions of the workspace allocation, with the witness lane holding the current sequence, while the localscurrentandspareinsidereduce()trade places, soResult.bytescan end up in either region.Storageoffers no thread safety.in_useis a plain boolean that turns away a second use on one thread, so sharing one workspace across threads is the caller's to synchronize.- One owner holds a given
Storageand a givenResult, makes no shallow copy of either, and leaves their bookkeeping fields alone.
Historical Context and Related Work
- Delta Debugging (
ddmin): Andreas Zeller introduced delta debugging to isolate the code change responsible for a failure (Yesterday, My Program Worked. Today, It Does Not. Why?, ESEC/FSE 1999, pages 253–267, ACM DOI: 10.1145/318774.318946). Andreas Zeller and Ralf Hildebrandt then generalized the algorithm to minimizing an input (Simplifying and Isolating Failure-Inducing Input, IEEE TSE 2002). - Hierarchical Delta Debugging (HDD): Ghassan Misherghi and Zhendong Su pruned parse trees one level at a time (HDD: Hierarchical Delta Debugging, ICSE 2006). Whether the result is syntactically valid rests on the tree manipulator and the grammar keeping it valid.
- C-Reduce: John Regehr, Yang Chen, Pascal Cuoq, Eric Eide, Chucky Ellison, and Xuejun Yang built C-Reduce (Test-Case Reduction for C Compiler Bugs, ACM SIGPLAN PLDI 2012, pages 335–346). It combines Clang AST transformations, delta debugging over lines and tokens, and peephole rewrites, and it minimizes C and C++ inputs that crash a compiler, that are miscompiled, and that expose semantic bugs.
- QuickCheck Shrinking: Koen Claessen and John Hughes introduced property-based random testing (QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs, ICFP 2000). Property testing libraries today shrink by type through an interface like QuickCheck's
shrink, whose signatureshrink :: a -> [a]yields subterms of the value's type or simpler alternatives and defaults to[], which simplifies a counterexample over structured algebraic types.
This package works straight on raw byte sequences.
Definitions
Actions
Public operations.
reduce: Shortens an initial byte sequence against the caller's oracle.
Types and contracts
Public types and contracts.
Capacity: Workspace layout derived from the caller's limits, sized so that a run in steady state needs no further allocation.Completion: Stopping condition that ended the search,one_minimalorbudget_exhausted.Error: Set of errors the reduction framework produces while acquiring the workspace or checking the input.Exhaustion: Set of errors raised when the input exceeds the workspace bounds or another run already holds the workspace.Interesting: Oracle's answer about one candidate,interestingoruninteresting.InterestingFn: Type of the caller's oracle function pointer.Limits: Sizing constraint the caller supplies for the byte reduction workspace.Result: Outcome of a run, with bytes borrowed from the acquired workspace.Settings: Options that control how a run executes.Status: Snapshot of one workspace's state, taken by value at the moment of the call.Storage: Workspace, allocated up front and double-buffered, that carries reduction in steady state.
Namespaces
Public namespaces.
bytes: Namespace holding the byte-oriented implementation.
Code relationships
Direct static dependencies extracted from parsed source by semantic graph analysis.
Uses: tiny.hypothesis, tiny.smg
Used by: None
Verification
No verification records are cataloged for this module in this build.
Audit
| Evidence | Value |
|---|---|
| Source | lib/reducer/src/root.zig |
| Definitions | 33 of 33 documented |
| Members | 32 of 32 documented |
| Public names | 83 API, 83 indexed |
| Version | 26.7.0 |
| Revision | daab053ee433 |