libhegel implements the core of property-based testing: generation, shrinking, the example database, and the decision of what to run next. It ships as a shared library (libhegel.so, libhegel.dylib, hegel.dll) with a C ABI.
Library: the language-specific frontend that calls into libhegel. Sometimes called the caller below, since from libhegel's point of view it is whatever is making the calls.
Context: holds the diagnostic message of a failed call. Passed as the first argument to nearly every function.
Run: the full lifecycle of one property test, including executing many test cases and shrinking any failures.
Test case: a single execution of the test function and the concrete values generated for it. Cloning a handle yields more handles onto the same test case, each with its own choice sequence.
Span: a labeled grouping of draws that tells the shrinker which draws belong to one unit.
Reproduce blob: a base64 string encoding a test case's choice sequence, which can be replayed later to reproduce it exactly. It is only guaranteed to reproduce the failure in the version of Hegel in which it was generated.
Every function takes a hegel_context_t* as its first argument and returns a hegel_result_t code, except for hegel_context_new, which returns a context, and hegel_context_last_error, which returns the message pointer directly.
HEGEL_OK is zero and every error code is negative. Anything else a call produces is written through a trailing out-parameter named out_*.
Every function returns HEGEL_E_INVALID_HANDLE when passed a NULL handle (except the *_free functions, where NULL is a no-op) and HEGEL_E_INVALID_ARG when passed any other invalid argument (a NULL out-parameter, inverted bounds, a non-UTF-8 string, and so on). The functions below leave these implicit.
A NULL context is always allowed and opts out of error messages. The call still returns its usual error code. A context must not be used concurrently from multiple threads, since each fallible call overwrites the stored message.
hegel_context_new
signature
hegel_context_t *hegel_context_new(void)
returns
A new error reporting context initialized with an empty message. Never returns NULL. Must be freed with hegel_context_free.
The most recent error message recorded on ctx, or the empty string if the most recent call taking ctx succeeded. NULL only if ctx is NULL. The pointer borrows the context's internal buffer and is invalidated by the next call taking the same context.
Pointers you pass into a libhegel function are always owned by the caller. libhegel reads them during the call and copies whatever it needs to keep, so you may free or reuse the memory as soon as the call returns. Run results own their data and are independent of the run they came from.
Release every pointer returned by these functions with its matching free:
Constructor
Destructor
hegel_context_new
hegel_context_free
hegel_settings_new
hegel_settings_free
hegel_run_start
hegel_run_free
hegel_test_case_from_blob
hegel_test_case_free
hegel_next_test_case
hegel_test_case_free
hegel_test_case_clone
hegel_test_case_free
hegel_run_result
hegel_run_result_free
hegel_run_result_failure
hegel_failure_free
hegel_string_generator_*
hegel_string_generator_free
hegel_generate_bytes
hegel_generate_bytes_result_free
hegel_generate_string
hegel_generate_string_result_free
Every other pointer libhegel hands back is a borrowed string. The caller must not free it, and it is valid only until a documented point. hegel_context_last_error is invalidated by the next call on that context.
Receives a handle initialized with libhegel's defaults: 100 test cases, all phases enabled, normal verbosity, no seed, and the default disk database under .hegel/.
returns
HEGEL_OK.
notes
When a CI environment is detected (via CI, GITHUB_ACTIONS, and similar variables) the defaults change: the database is disabled and derandomization is enabled. Override either with the explicit setters.
Maximum number of valid test cases to run before declaring the property held. 100 by default. Cases rejected by an assumption do not count against this budget.
Target number of steps to run per stateful test case. Each stateful case runs at least one step and at most n. The default is 50. n must be at least 1.
NULL sets it to the default: ./.hegel/examples/. "" disables the database entirely. Discovered failures will not be stored. Anything else is used as the database root directory. The directory will be created if it does not already exist.
When true, libhegel keeps generating after the first failure to surface additional distinct bugs. Failures from different locations in the program are considered distinct bugs. The final result lists all of them. When false, the run stops after the first failing example.
The caller starts a run, repeatedly asks for the next test case, reports its outcome, and reads the run result after all test cases have been run.
The run handle owns the suspended run loop as a future, and each hegel_next_test_case call resumes it on the calling thread until it returns the next test case or finishes.
The settings for this run. The caller can free the settings after passing them in since libhegel copies the memory.
callback
Where libhegel's output for this run goes. NULL leaves output on stderr.
user_data
Passed through to callback verbatim. Ignored when callback is NULL.
out_run
Receives the run handle.
returns
HEGEL_OK.
notes
This only sets up the run. No test case is generated until the first hegel_next_test_case call. libhegel emits while it runs inside that call, so the callback is invoked on whichever thread makes it. Because it runs inside hegel_next_test_case, the callback must not call back into libhegel on the same run.
Receives a handle for the next test case, or NULL once the run is finished.
returns
HEGEL_OK, including at normal completion, where *out_test_case is NULL and you should call hegel_run_result. HEGEL_E_NOT_COMPLETE if the previous test case was not marked complete.
notes
The handle is owned by the caller and must be released with hegel_test_case_free.
A test-case handle is what a test body draws from. The caller drives it with the per-test-case primitives, concludes it with hegel_mark_complete, and releases it with hegel_test_case_free.
Each handle holds one reference to the shared test case. The underlying data source is released once the last reference is gone. Each handle must be freed exactly once. A run-owned test case still needs hegel_mark_complete from one of its handles before the run can advance, so make every test case complete before freeing your last handle to it.
Receives a new handle onto an independent stream of the same test case.
returns
HEGEL_OK, HEGEL_E_CONCURRENT_USE if another thread is mid-operation on the source handle, HEGEL_E_ALREADY_COMPLETE once the test case has completed.
notes
The clone shares the test case's outcome and budgets but generates from its own choice sequence, so a clone and its source can be driven concurrently from different threads while staying deterministic under replay. Collections, pools, and state machines remain shared across all handles to the test case, but do not use shared objects from two streams since it makes tests flaky.
A hegel_status_t value describing how the test case ended.
origin
Identifies the origin of a failure. Used only when status is HEGEL_STATUS_INTERESTING; NULL otherwise.
returns
HEGEL_OK, or HEGEL_E_ALREADY_COMPLETE if called twice on the same handle.
notes
Completion is first-caller-wins and applies to the whole test case: the first call from any handle records the outcome, and a later call on a different handle is a safe no-op. This function never returns HEGEL_E_CONCURRENT_USE: if another thread is mid-operation on the handle it waits, then completes.
Every draw takes a test-case handle and writes its value through an out-parameter. All of them can return HEGEL_E_STOP_TEST, meaning libhegel has exhausted its choice budget for this test case: abort the test body and call hegel_mark_complete with HEGEL_STATUS_OVERRUN.
Inclusive bounds as two's-complement little-endian signed byte buffers. Both required and must be non-empty.
out_value
Receives the drawn value's two's-complement little-endian bytes. libhegel sign-fills the rest of the buffer up to out_value_cap, so reading the whole buffer as a fixed-width integer also yields the drawn value with no sign extension needed.
32 or 64. 32 bit bounds must be exactly representable as float, and finite 32 bit results are exactly representable as float.
min_value / max_value
Inclusive bounds. Pass -INFINITY / INFINITY for unbounded ends.
allow_nan
NaN is drawn only when this is set.
allow_infinity
Infinities are drawn only when this is set and the corresponding endpoint is unbounded.
exclude_min / exclude_max
Make the corresponding bound exclusive by stepping it to the next representable value at the requested width.
smallest_nonzero_magnitude
Nonzero magnitudes below this are never drawn. Must be positive and finite; pass 5e-324 (width 64) or the smallest float subnormal (width 32) for no restriction.
Receives a libhegel-allocated {uint8_t *data; size_t len;} the caller owns. data is never NULL after a successful draw. Release with hegel_generate_bytes_result_free.
The alphabet's starting range: "ascii", "latin-1" / "iso-8859-1", or "utf-8" / NULL for Unicode.
min_codepoint / max_codepoint
Intersected with the codec's range. Pass 0 and UINT32_MAX for no constraint. Surrogates are always removed.
categories
Restricts to the union of the named Unicode general categories. NULL means no restriction. A non-NULL empty list means an empty alphabet.
exclude_categories
Removes the named categories.
include_characters / exclude_characters
UTF-8 buffers (pointer plus byte length) of individual characters. Characters in include_characters are included first, then characters in exclude_characters are removed.
returns
HEGEL_OK, or HEGEL_E_INVALID_ARG for constraints that leave no characters while max_size > 0.
A generator built by one of the constructors above.
out_result
Receives a libhegel-allocated {char *data; size_t len;} the caller owns. Not NUL-terminated, and it may contain interior NUL bytes since the drawn alphabet can include U+0000, so always use len. Release with hegel_generate_string_result_free.
returns
HEGEL_OK, HEGEL_E_STOP_TEST, or HEGEL_E_ASSUME when the draw rejected itself (for example an email exceeding the RFC length cap).
When has_version is set, the RFC 4122 version nibble is forced to version (0..=15, conventionally 1..=5) and the variant nibble to the RFC 4122 variant. Without a version the 128 bits are uniform, except that the nil UUID is never produced.
Identifies what kind of structure this span groups. The values reserved by libhegel are the hegel_label_t constants in hegel.h. Libraries may use any stable u64 to define their own spans.
For variable-length values, libhegel decides how many elements to produce. The caller loops on hegel_collection_more, drawing one element per returned true.
A pool tracks a set of variable ids libhegel can draw from and shrink over. It is mostly used for stateful testing, where a rule needs to act on some previously generated value. The caller keeps its own mapping from variable id to the value it generated.
For stateful testing libhegel picks which rule runs next and the caller runs it. Each test case enables a random subset of rules and selection draws only from that subset.
Receives the index of the next rule to run, in [0, num_rules). HEGEL_STATE_MACHINE_DONE (-1) means libhegel's step budget for this test case is exhausted, so stop running rules.
returns
HEGEL_OK, or HEGEL_E_STOP_TEST when libhegel's choice budget is exhausted.
A numeric observation. Must be finite. Higher is "more interesting." libhegel biases later test cases toward inputs that produced higher observations under the same label.
label
Non-NULL, valid UTF-8. Each label may be recorded at most once per test case.
returns
HEGEL_OK.
notes
Has no effect unless HEGEL_PHASE_TARGET is enabled.
A run result is the outcome of a finished run, returned as a caller-owned copy. It stays valid after hegel_run_free, and is released separately.
A failed run produced counterexamples to the property. An errored run produced no verdict on the property at all, so it has no failures to inspect. A run errors on a failed health check, a nondeterministic test, or a panic inside libhegel.
Receives the run-level error message when the run errored, or NULL when it completed normally. Owned by the run result and valid until hegel_run_result_free.
Receives a base64 reproduce blob encoding the minimal counterexample's choice sequence, or NULL if libhegel produced none for this failure. Valid until hegel_failure_free.
A library uses a reproduce blob to replay of a counterexample. It reruns the minimal failing test case so it can display the drawn values and re-raise the test's own failure.
There is no run handle and no run loop involved. The caller drives the returned test case with the usual per-test-case primitives, concludes it with hegel_mark_complete, and decides for itself whether the blob reproduced the failure (the property failed again) or is stale/flaky (it passed).
Where this replay's output goes, with the same contract as hegel_run_start. The callback is only ever invoked on this thread and need not outlive the call.
out_test_case
Receives a caller-owned test-case handle. Released like any other with hegel_test_case_free.
returns
HEGEL_OK, or HEGEL_E_INVALID_ARG for a blob that is not valid (corrupt, non-UTF-8, or from an incompatible Hegel version).
notes
A blob whose choices no longer match the caller's generators returns HEGEL_E_STOP_TEST from the draw that overruns.
Each kind of handle has its own threading contract:
A context must not be used concurrently from multiple threads. Each fallible call overwrites its stored message, so sharing one across threads is a data race.
A settings handle may be shared across threads once configured, but each setter call requires exclusive access.
A test-case handle may be driven by at most one thread at a time. Concurrent operations on it return HEGEL_E_CONCURRENT_USE. To generate from several threads, hegel_test_case_clone the handle and give each thread its own clone.
A handle pointer was NULL where it must be non-NULL.
HEGEL_E_INVALID_ARG
-5
An argument other than a handle was invalid.
HEGEL_E_ALREADY_COMPLETE
-6
hegel_mark_complete (or a primitive on the same handle) was called for a test case that has already been completed.
HEGEL_E_NOT_COMPLETE
-7
Something was read before it was ready: hegel_next_test_case without first completing the previous test case, or hegel_run_result before the run finished.
HEGEL_E_INTERNAL
-8
An internal invariant failed inside libhegel. Should not happen in practice. Please file a bug.
HEGEL_E_CONCURRENT_USE
-9
A single test-case handle was used from two threads at once. Clone the handle instead.
Choose automatically (the default): urandom when running inside Antithesis, otherwise the default backend.
HEGEL_BACKEND_DEFAULT
1
Expand a single seeded PRNG. Runs are reproducible from the seed and shrinking / replay work as usual.
HEGEL_BACKEND_URANDOM
2
Read fresh entropy from /dev/urandom on every draw, falling back to an OS-seeded PRNG on platforms without it. Intended for running under Antithesis, whose fuzzer controls /dev/urandom; you almost certainly don't want it otherwise.
Passed to hegel_start_span. libhegel opens spans around its own draws. If your Hegel library opens spans, give them labels libhegel has not reserved, or shrinking may get slower.