Skip to documentation
SLOP

tiny.reducer

Reference 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":

zig
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:

zig
fn interesting(candidate: []const u8, context: *anyopaque) anyerror!Interesting

The 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 i of a sequence x whose length is n, for 0≤i<n, and joining the two half-open pieces x[0..i] and x[i+1..n] is occurrence deletion, producing a shorter subsequence:

delete(x,i)=x[0…i]‖x[i+1…n]

At i=0 the leading piece x[0..0] is empty, and at i=n−1 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:

∀i∈{0,…,|x|−1},P(delete(x,i))=uninteresting

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 n subsets Δ1,…,Δn and alternates between testing one subset Δi alone and testing its complement cx∖Δi, moving n between 2 and |cx| (Simplifying and Isolating Failure-Inducing Input, IEEE TSE 2002, Figure 5).

This package differs from that formulation in three ways:

chunk←⌊chunk/2⌋+(chunkmod2)

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 N 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":

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:

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.

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)
  1. Capacity derivation: Capacity.derive(limits) calculates the workspace capacity (the arithmetic layout derived from the caller's limits, the sizing figure N for the longest input submitted) as N+max(N−1,0) bytes and allocates nothing. A sum that overflows usize returns DeriveError.CapacityOverflow. For N=0 the figure is 0 bytes. Filling in Storage or Capacity fields by hand is unsafe, so callers reach the same layout through Storage.init() and Capacity.derive().
  2. Initialization: Storage.init(allocator, limits) allocates the backing memory once, setting the workspace lifecycle phase to Phase.initialization. A workspace that was never activated can be freed directly by storage.deinit(allocator).
  3. Activation: storage.activate() seals the workspace into Phase.steady, idle with no lease outstanding so the next run can acquire it. After that the workspace owner allocates nothing further.
  4. Acquisition: storage.acquire(input_bytes) checks the input against the bounds, issues a lease by setting in_use = true for this run's exclusive hold, and returns Regions, writing nothing into the lanes.
  5. Error isolation: the three errors raised before acquisition, AttemptBudgetExhausted, ReductionStorageInUse, and InputCapacityExceeded, leave the workspace as it was, so a call that finds the workspace busy leaves an earlier live Result holding its lease. An error after acquisition gives up this run's lease through errdefer storage.release() and returns the workspace to idle, which restores neither the scratch bytes the run overwrote nor any side effect the callback produced.
  6. Release and reuse: Result.deinit(), or storage.release(), clears in_use and returns the workspace to steady and idle. deinit() also clears the result's own fields, setting bytes = &.{}, attempts = 0, reductions = 0, and completion = .budget_exhausted, which ends the borrow (Result.bytes referencing 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 copying result.bytes first.
  7. Teardown: storage.deinit(allocator) checks that no lease is out, moves the workspace to Phase.teardown, and frees the memory with the same allocator init() received.

Ownership and Concurrency

This package works straight on raw byte sequences.

Definitions

Actions

Public operations.

Types and contracts

Public types and contracts.

Namespaces

Public namespaces.

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

EvidenceValue
Sourcelib/reducer/src/root.zig
Definitions33 of 33 documented
Members32 of 32 documented
Public names83 API, 83 indexed
Version26.7.0
Revisiondaab053ee433