Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,30 +114,44 @@ public `Generator`/`TestCase`/`Generators`/`Hegel`/`Stateful` surface stays in `
`AssumeRejected` → INVALID, `INVALID_ARG` → `IllegalArgumentException` with the engine's
diagnostic, other negatives → `HegelException`) and short-circuiting once a case is aborted.
- **Run loop** — `Runner` builds the settings handle and drives `hegel_run_start` →
`hegel_next_test_case` → `hegel_mark_complete`. The engine only *explores* (generation and
shrinking), so every pumped case is non-final; after the loop drains, `hegel_run_result` yields
PASSED, FAILED, or ERROR. On FAILED the runner replays each distinct counterexample's
**reproduce blob** (`hegel_test_case_from_blob`) with reporting enabled — capturing the shrunk
draws, notes, and the body's own exception into a `Failure` (a replay that no longer fails is a
*flaky* failure). The runner returns a `RunReport` (status, client-side `RunStatistics` counted
`hegel_next_test_case` → `hegel_mark_complete`. The engine owns the *whole* run — generation,
shrinking, the replays that confirm a failure, and (since 0.44) the final replay of every failure
it is about to report — so the runner never replays anything itself. It reads
`hegel_test_case_should_capture` once per case: a *stamped* case's `TestCase` records its
top-level draws and notes (`isFinal()` exposes the stamp), and every interesting case's exception
is kept per origin in a capture map (a stamped capture outranks an unstamped one, newest wins at
equal rank — the unstamped discovery of a failure that never fails again is all there is for an
*unconfirmed* nondeterministic failure). After the loop drains, `hegel_run_result` yields PASSED,
FAILED, or ERROR. On FAILED each distinct failure is built from its origin's capture: the recorded
draws/notes are replayed to the reporter (`caseStarted(true)` … `caseFinished(true)`), and the
`Failure` carries the body's own exception, the reproduce blob (`hegel_failure_reproduction_blob`,
null for an unconfirmed failure and for a blob replay) and the **caveat**
(`hegel_failure_caveat`, non-null for a nondeterministic failure — `Failure.nondeterministic()`;
the printing reporter prints it as `note: …`). A failure whose origin has no capture is an
internal error. `Settings.reproduceFailure` drives the same loop over `hegel_run_start_blob`
(the engine replays until a replay fails, under its budget): PASSED → `Runner.STALE_BLOB`
`HegelException`, ERROR → "blob is not valid" `HegelException`. There is no Java-side flaky
detection any more: the engine handles nondeterminism per `nondeterminismStrictness`
(`NondeterminismStrictness.QUIET`/`WARN` confirm-by-replay and report with a caveat; `ERROR`
aborts the run, surfacing as the engine's own `Flaky test detected` message). The runner returns a `RunReport` (status, client-side `RunStatistics` counted
per `mark_complete` — the C ABI has no counter accessor — engine error, failures) and never
prints: everything goes through the run's `Reporter` (`Reporter.printing(System.err)` is the
default and reproduces the classic output; `Reporter.silent()` for library use). `Hegel.run`
returns the report; `Hegel.test` = `run` + `RunReport.throwIfFailed()`, which rethrows a single
failure as-is (checked exceptions included), aggregates several into an `AssertionError`, and
maps ERROR to `HealthCheckFailure`/`HegelException`. `HegelException` (binding/engine errors) is
always thrown, never reported. `Settings` is the immutable config; its closed-state setting types live
alongside it — `Backend` (auto = leave it to the engine's profile, which picks urandom inside Antithesis / default / urandom), `Database`, `OptBoolean` —
alongside it — `Backend` (auto = leave it to the engine's profile, which picks urandom inside Antithesis / default / urandom), `Database`, `OptBoolean`, `NondeterminismStrictness` (`DEFAULT` = leave it to the engine) —
plus `printBlob` (print a copy-pasteable reproducer per failure) and `reproduceFailure` (replay
a stored blob instead of running the property). `testCases`, `printBlob`, `derandomize`, `seed`
and `database` are nullable ("unset"): `Runner.applySettings` sends only what the user set, so
a stored blob instead of running the property). `testCases`, `printBlob`, `derandomize`, `seed`,
`nondeterminismStrictness` and `database` are nullable ("unset"): `Runner.applySettings` sends only what the user set, so
the engine's resolution — its profile (`hegel.toml`, the shipped `development`/`ci`/`workload`
profiles) with the `HEGEL_TEST_CASES`/`HEGEL_DATABASE`/`HEGEL_SEED`/`HEGEL_DERANDOMIZE`/
`HEGEL_PRINT_BLOB` variables applied over it in `hegel_settings_new` — stands for the rest, and
`HEGEL_PRINT_BLOB`/`HEGEL_NONDETERMINISM_STRICTNESS` variables applied over it in `hegel_settings_new` — stands for the rest, and
explicit Java settings win over both. There is no Java-side CI detection (the engine's `ci`
profile covers it). `Libhegel.settingsNew` returns a raw result code because a malformed
variable or `hegel.toml` fails it with `E_INVALID_ARG` (→ `IllegalArgumentException`); after
applying, the runner reads `settingsGetTestCases`/`settingsGetPrintBlob` back and hands the
applying, the runner reads `settingsGetTestCases`/`settingsGetPrintBlob`/`settingsGetNondeterminismStrictness` back and hands the
*effective* `Settings` to the reporter and the run. Verbosity and `reportMultipleFailures` keep
Java-side defaults and are always sent. `EnvironmentTest` covers the variables and `hegel.toml`
through a child JVM (`EnvironmentFixture`), since the engine reads its own process environment.
Expand Down Expand Up @@ -173,7 +187,9 @@ public `Generator`/`TestCase`/`Generators`/`Hegel`/`Stateful` surface stays in `
for `@Invariant(alwaysRun = true)`, sampled otherwise; the initial and final checks run every
invariant unconditionally. The machine handle is freed in a `finally`. An engine-level
`E_ASSUME` inside a rule (the data source is aborted) unwinds the whole case as invalid instead
of being reported as the rule's rejection. `Pool<T>` tracks previously generated values over
of being reported as the rule's rejection. (Since engine 0.44 concurrency alone no longer marks
a run nondeterministic, and `HEGEL_RUN_STATUS_FAILED_NONDETERMINISTIC` is retired; this binding
drives machines sequentially anyway.) `Pool<T>` tracks previously generated values over
the engine's pool primitives so rules can reuse or consume them.
- **Derivation** — `dev.hegel.generators.Derive` + `RecordGenerator` build generators from records,
enums, scalars, and generic `List`/`Set`/`Optional`/`Map` by reflection.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@ report.statistics(); // valid / invalid / overrun / interesti
for (Failure f : report.failures()) { // one per distinct counterexample
f.draws(); // labelled draws of the minimal example, as Java values
f.exception(); // the body's own throwable
f.reproduceBlob(); // replay it later with Settings.reproduceFailure
f.reproduceBlob(); // Optional: replay it later with Settings.reproduceFailure
f.caveat(); // Optional: how reliably a nondeterministic failure reproduced
}
```

`TestCase.isFinal()` identifies the final replay of a counterexample, `TestCase.span` and `Label`
let custom composite generators tell the engine about their structure, and
`Settings.infrastructurePackages` keeps a frontend's own stack frames out of failure origins.
`TestCase.isFinal()` identifies the executions the engine stamped for the failure report (the
final replay of a counterexample among them), `TestCase.span` and `Label` let custom composite
generators tell the engine about their structure, and `Settings.infrastructurePackages` keeps a
frontend's own stack frames out of failure origins.

## Binding Hegel yourself

Expand Down
79 changes: 79 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
RELEASE_TYPE: minor

This release upgrades the bundled libhegel engine from 0.43.4 to 0.44.0, which handles
nondeterministic tests instead of refusing them, and changes the `Failure` API accordingly.

A test whose outcome changes when the same generated data is replayed — because it depends on hidden
global state, time, an outside service, or thread scheduling — previously aborted the run with a
`HegelException` reading "Flaky test detected". The engine now switches such a run to
nondeterministic handling: a discovered failure is confirmed by repeated replay before it is shrunk
or saved to the database, shrunk under a guard on how much reproduction reliability a shrink step may
trade away, and reported with a *caveat* quoting the run's own replay evidence. `Hegel.test` then
throws the test's own exception as for any other failure, and the printed report carries the caveat:

```
x = 11;
note: nondeterministic failure, confirmed: failed 7 of 20 replays at confirmation and 1 of 2 at report time

To reproduce this failure, replay it with:
@HegelTest(reproduceFailure = "...")
```

A failure that never reproduces after its discovery still fails the run, reported as
`unconfirmed failure: failed 0 of 10 replays after the observed failure` and without a reproduce
blob. A reproduce blob from a nondeterministic failure records the failing runs the engine saw, and
`Settings.reproduceFailure` / `@HegelTest(reproduceFailure = ...)` now replays a blob until one of
its replays fails, under a bounded budget, instead of judging it stale after a single attempt; a
blob none of whose replays fail reports that "the supplied failure blob did not reproduce a failure".

The new `Settings.nondeterminismStrictness` (and `@HegelTest(nondeterminismStrictness = ...)`)
controls the reaction: `NondeterminismStrictness.QUIET` (the engine's default) switches silently,
`WARN` prints a one-line notice once per run, and `ERROR` aborts the run with the previous
flaky-test error, for suites that use determinism as a lint. It is a profile setting like any other
(`nondeterminism_strictness = "error"` in `hegel.toml`), the `HEGEL_NONDETERMINISM_STRICTNESS`
environment variable (`quiet`, `warn` or `error`) overrides it for one run, and a value set in Java
wins over both.

The engine now also runs every failure it is about to report one final time itself, so hegel-java no
longer replays reproduce blobs after a run: the reported draws, notes and exception come from the
engine's own final replay, and a failing test's body runs once less per reported failure. Two
visible consequences. `TestCase.isFinal()` is now true on every execution the engine stamps for the
failure report — the final replay of a counterexample, but also the short replays that confirm a
discovered failure — so a body that uses it to gate expensive diagnostics may run them a few times
per failure rather than once. And a `Reporter` receives the counterexample's `draw` and `note`
callbacks (flagged `finalReplay = true`) when the failure is reported, after the run loop, rather
than live while a replay executes; the order of callbacks is unchanged.

`Failure` changes shape to carry the caveat and to admit failures without a blob:

```java
// before
String blob = f.reproduceBlob();
Optional<Throwable> e = f.exception();
if (f.flaky()) { ... }

// after
Optional<String> blob = f.reproduceBlob(); // empty for an unconfirmed failure or a blob replay
Throwable e = f.exception(); // always present
Optional<String> caveat = f.caveat(); // present for a nondeterministic failure
if (f.nondeterministic()) { ... }
```

`Failure.flaky()` is gone: the engine no longer hands back a failure whose replay passed. A
`Failure.reproduceBlob()` is also empty for a failure reproduced from a `reproduceFailure` blob, since
the caller already holds it.

In `dev.hegel:hegel-lowlevel`, `Libhegel` gains `runStartBlob` (`hegel_run_start_blob`),
`testCaseShouldCapture` (`hegel_test_case_should_capture`), `failureCaveat` (`hegel_failure_caveat`)
and the `settingsNondeterminismStrictness` / `settingsGetNondeterminismStrictness` pair, and
`Abi.RUN_STATUS_FAILED_NONDETERMINISTIC` is removed along with the ABI value it mirrored; a failing
nondeterministic run reports plain `RUN_STATUS_FAILED`. Bindings implementing `Libhegel` must add
the new methods.

Between 0.43.4 and 0.44.0 the engine also improved float generation, so tests on floats reach
overflow, underflow, cancellation and special-value bugs far more often (each test case draws its own
mixture of float categories, and bounded ranges cover every power-of-two scale they touch); roughly
halved the per-case cost of text generators built inside the test body by sharing built alphabets
between generators with the same constraints; shrinks a size drawn twice — the rows and columns of a
square matrix — while the values it governs still vary, reaching the smallest failing square; and
picked up correctness fixes in its arbitrary-precision integer dependency.
69 changes: 69 additions & 0 deletions hegel-jna/src/main/java/dev/hegel/JnaLibhegel.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,21 @@ interface HegelNative extends Library {

int hegel_settings_get_print_blob(Pointer ctx, Pointer s, ByteByReference out);

int hegel_settings_set_nondeterminism_strictness(Pointer ctx, Pointer s, int strictness);

int hegel_settings_get_nondeterminism_strictness(Pointer ctx, Pointer s, IntByReference out);

int hegel_run_start(
Pointer ctx, Pointer settings, LineCallback callback, Pointer userData, PointerByReference out);

int hegel_run_start_blob(
Pointer ctx,
Pointer settings,
String blob,
LineCallback callback,
Pointer userData,
PointerByReference out);

int hegel_next_test_case(Pointer ctx, Pointer run, PointerByReference out);

int hegel_run_result(Pointer ctx, Pointer run, PointerByReference out);
Expand All @@ -108,6 +120,8 @@ int hegel_test_case_from_blob(

int hegel_test_case_free(Pointer ctx, Pointer tc);

int hegel_test_case_should_capture(Pointer ctx, Pointer tc, ByteByReference out);

int hegel_generate_boolean(Pointer ctx, Pointer tc, double p, byte hasForced, byte forced, ByteByReference out);

int hegel_generate_integer(Pointer ctx, Pointer tc, long min, long max, LongByReference out);
Expand Down Expand Up @@ -237,6 +251,8 @@ int hegel_state_machine_should_check_invariant(

int hegel_failure_origin(Pointer ctx, Pointer failure, PointerByReference out);

int hegel_failure_caveat(Pointer ctx, Pointer failure, PointerByReference out);

int hegel_version(Pointer ctx, PointerByReference out);
}

Expand Down Expand Up @@ -430,6 +446,22 @@ public boolean settingsGetPrintBlob(long s) {
return out.getValue() != 0;
}

@Override
public void settingsNondeterminismStrictness(long s, int strictness) {
check(
"hegel_settings_set_nondeterminism_strictness",
lib.hegel_settings_set_nondeterminism_strictness(ctx(), pointer(s), strictness));
}

@Override
public int settingsGetNondeterminismStrictness(long s) {
IntByReference out = new IntByReference();
check(
"hegel_settings_get_nondeterminism_strictness",
lib.hegel_settings_get_nondeterminism_strictness(ctx(), pointer(s), out));
return out.getValue();
}

// --- run lifecycle ---

@Override
Expand All @@ -448,6 +480,24 @@ public long runStart(long settings, Consumer<String> output) {
return run;
}

@Override
public long runStartBlob(long settings, String blob, Consumer<String> output) {
LineCallback callback = output == null ? null : new LineCallback(output);
PointerByReference out = new PointerByReference();
int code = lib.hegel_run_start_blob(ctx(), pointer(settings), blob, callback, null, out);
if (code != Abi.OK) {
throw new HegelException("hegel_run_start_blob failed (rc="
+ code
+ "): "
+ java.util.Objects.toString(lastErrorMessage(), ""));
}
long run = address(out.getValue());
if (callback != null) {
runCallbacks.put(run, callback);
}
return run;
}

@Override
public long nextTestCase(long run) {
PointerByReference out = new PointerByReference();
Expand Down Expand Up @@ -491,6 +541,13 @@ public void testCaseFree(long tc) {
check("hegel_test_case_free", lib.hegel_test_case_free(ctx(), pointer(tc)));
}

@Override
public boolean testCaseShouldCapture(long tc) {
ByteByReference out = new ByteByReference();
check("hegel_test_case_should_capture", lib.hegel_test_case_should_capture(ctx(), pointer(tc), out));
return out.getValue() != 0;
}

// --- draws ---

@Override
Expand Down Expand Up @@ -980,6 +1037,18 @@ public String failureOrigin(long result, long index) {
return origin;
}

@Override
public String failureCaveat(long result, long index) {
PointerByReference failureOut = new PointerByReference();
check("hegel_run_result_failure", lib.hegel_run_result_failure(ctx(), pointer(result), index, failureOut));
Pointer failure = failureOut.getValue();
PointerByReference caveatOut = new PointerByReference();
check("hegel_failure_caveat", lib.hegel_failure_caveat(ctx(), failure, caveatOut));
String caveat = readCString(caveatOut.getValue());
check("hegel_failure_free", lib.hegel_failure_free(ctx(), failure));
return caveat;
}

// --- diagnostics ---

@Override
Expand Down
23 changes: 23 additions & 0 deletions hegel-jna/src/test/java/dev/hegel/JnaLibhegelTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ void infrastructureCallsReportNullHandles() {
// diagnostic rather than undefined behaviour.
assertThrows(HegelException.class, () -> lib.runResultStatus(0));
assertThrows(HegelException.class, () -> lib.runStart(0, null));
assertThrows(HegelException.class, () -> lib.runStartBlob(0, "blob", null));
assertThrows(HegelException.class, () -> lib.testCaseShouldCapture(0));
}

@Test
Expand Down Expand Up @@ -197,6 +199,10 @@ void settingsGettersReadTheHandleBack() {
assertFalse(lib.settingsGetPrintBlob(s));
lib.settingsPrintBlob(s, true);
assertTrue(lib.settingsGetPrintBlob(s));
lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_ERROR);
assertEquals(Abi.NONDETERMINISM_ERROR, lib.settingsGetNondeterminismStrictness(s));
lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_WARN);
assertEquals(Abi.NONDETERMINISM_WARN, lib.settingsGetNondeterminismStrictness(s));
lib.settingsFree(s);
}

Expand All @@ -216,6 +222,23 @@ void undecodableBlobWithDefaultOutputIsRejected() {
lib.settingsFree(s);
}

@Test
void blobRunWithDefaultOutputStartsAndReportsTheBadBlob() {
JnaLibhegel lib = real();
long s = newSettings(lib);
// A null output callback leaves the run's output on stderr; the garbage blob surfaces as
// the run's error once it is pumped dry.
long run = lib.runStartBlob(s, "not-a-blob!!!", null);
assertNotEquals(0, run);
assertEquals(0, lib.nextTestCase(run));
long result = lib.runResult(run);
assertEquals(Abi.RUN_STATUS_ERROR, lib.runResultStatus(result));
assertNotNull(lib.runResultError(result));
lib.runResultFree(result);
lib.runFree(run);
lib.settingsFree(s);
}

@Test
void regexGeneratorAcceptsATextAlphabet() {
JnaLibhegel lib = real();
Expand Down
Loading
Loading