# Native architecture

## Decision

Hegel for Elixir uses a small Rustler NIF to bind the canonical libhegel C ABI.
Cargo pins `hegeltest-c = "=0.32.5"`; the NIF calls its `hegel_*` functions and
links the engine's Rust `rlib` into `hegel_native`. Rustler 0.38.0 handles BEAM
terms and resource ownership. RustlerPrecompiled downloads checksum-verified
GitHub Release artifacts for supported targets. The frontend does not depend
on the high-level Rust `hegeltest` library.

```text
ExUnit property / Hegel.check
              |
              v
Elixir run loop + Hegel.Generators
              |
        Rustler NIF calls
              v
Rustler terms + ResourceArc ownership
              |
        canonical hegel_* API
              v
linked hegeltest-c/libhegel 0.32.5
```

Rustler manages terms and BEAM resources. Direct calls to the C ABI define the
frontend's property-testing semantics. No high-level Rust property wrapper sits
between Elixir and libhegel.

The header
[`hegel-c/include/hegel.h`](https://github.com/hegeldev/hegel-rust/blob/v0.32.5/hegel-c/include/hegel.h),
defines the binding contract. The published
[`libhegel` reference](https://hegel.dev/reference/libhegel) describes the same
calling, ownership, lifecycle, replay, and threading contracts.

## Alternatives considered

### High-level `hegeltest` through Rustler

The Rust frontend accepts Rust test closures, panic/failure machinery, and
generator types. Those types do not fit the Elixir boundary:

- Elixir must execute the property so ExUnit assertions, catches, process-local
  state, stacktraces, and source locations keep their Elixir behavior.
- Calling an Elixir closure back from a NIF-controlled Rust run loop would
  introduce scheduler re-entry and environment-lifetime constraints. The BEAM
  run loop avoids those constraints.
- Converting Elixir generator terms into the high-level Rust generator graph
  would duplicate a frontend inside the NIF and make dependent Elixir control
  flow awkward. The C ABI lets a language frontend request each draw while its
  property executes.
- Hegel groups distinct bugs by origin. The frontend derives that origin from
  the Elixir stacktrace; Rust panic machinery has no Elixir source location.
- Depending on a language-specific wrapper adds API surface without adding
  engine capability. Database reuse, targeting, collections, state machines,
  and reproduce blobs all exist at the C ABI.

The high-level wrapper would save little native code and obscure Elixir failure
semantics, so this frontend omits it.

### A C NIF using `erl_nif.h`

A C NIF could call a released `libhegel` shared library with one fewer source
language. Its authors would still need to implement BEAM resources, reference
counts, mutexes, double-close protection, term decoding, binary allocation,
dirty scheduling, callback lifetimes, and error-path cleanup.

Rustler provides those mechanisms with RAII and typed encoding. A small Rust
shim lets Cargo pin and link `hegeltest-c` without a platform loader. The shim
confines `unsafe` to calls across the C contract. A contract violation can still
crash the VM; Rustler removes much of the handwritten lifetime and allocation
code where such defects occur.

### A separate prebuilt libhegel

A downloaded `libhegel.so`, `.dylib`, or `.dll` would cut compile
time while adding search-path, code-signing, checksum, platform-matrix, and
ABI-skew problems. Intel macOS and unpublished targets would also need another
installation path. Static inclusion produces one target-specific runtime NIF
and gives `Hegel.version/0` one linked engine to report. Each target must compile
the Rust source and rebuild the NIF after an engine upgrade.

## Boundary responsibilities

Each layer owns a defined part of the run:

| Layer | Owns |
| --- | --- |
| `Hegel.ExUnit` | `property` registration, `check all` syntax, left-to-right dependent bindings, assumption conversion, and default database keys |
| `Hegel` | Run/replay loop, current-case process context, property exception capture, origin selection, minimized replay, diagnostics, and re-raising the original failure |
| `Hegel.Generators` | Elixir values and validation, calls to primitive draws, and shrink structure expressed with spans and collections |
| `Hegel.TestCase` | Process ownership, draw/note recording, control-flow translation, and typed conversion between Elixir values and native primitives |
| Native Rustler shim | Term encoding, native resources, C allocation guards, handle serialization, output buffering, and result-code translation |
| `hegeltest-c` / libhegel | Choice generation, example reuse, targeting, health checks, state-machine scheduling, shrinking, database persistence, and reproduce blobs |

Elixir runs all user property logic. Rustler resources keep raw native pointers
inside the NIF.

## Run lifecycle

During a `Hegel.check/2` or `Hegel.run/2` call:

1. `Hegel.Settings.new/1` validates Elixir options. `Settings.to_native/1`
   converts enums and phase/health-check lists to the values defined by the C
   ABI.
2. `run_start` constructs a short-lived C settings handle, applies each setter,
   starts the libhegel run, and returns a BEAM resource for the run handle.
3. Elixir calls `run_next` until completion. libhegel resumes its suspended run
   loop until it yields a test-case handle or finishes.
4. The Elixir property runs in its owning BEAM process. Each generator invokes
   one or more short native draw calls. Compound generators surround their
   choices with the C span and collection primitives.
5. The frontend marks a normal return as `valid`, `Hegel.assume` as `invalid`,
   and a native choice-budget stop as `overrun`. It marks an Elixir error, exit,
   throw, assertion, or `Hegel.fail/1` as `interesting`. `Hegel.UsageError` and
   `Hegel.NativeError` escape the property runner as API or native errors.
6. libhegel generates, targets, and shrinks. For an ExUnit assertion, the
   frontend uses the first user frame inside the property. If tail-call
   optimization removed the anonymous property frame, it combines the property
   module and source with the assertion AST line. Other failures use the first
   user frame above the runner boundary and fall back to the property identity.
   libhegel uses this stable origin to group distinct bugs; final replay rejects
   a failure if its origin changed.
7. Once complete, `run_result` copies status, messages, origins, and blobs into
   BEAM terms and frees the temporary C result allocations.
8. The frontend replays each minimized blob through the Elixir callback to
   capture draw values, notes, the original exception, and its stacktrace.
   `Hegel.check/2` reports them and re-raises that failure; `Hegel.run/2`
   returns them in `Hegel.Result` and `Hegel.Failure`.
9. The runner closes handles on normal and error paths. Rust resource
   destructors cover process death and abandoned resources.

libhegel owns a language-neutral choice sequence. It cannot retain Elixir terms
or exceptions. The frontend replays the minimized choices to produce Elixir
diagnostics.

With `:reproduce` or `HEGEL_REPRODUCE`, the frontend skips the normal run loop
and result. `hegel_test_case_from_blob` creates one case, the property executes
it, and the frontend verifies that it fails. A pass, assumption, or overrun
produces a reproduction error.

## Generator protocol and shrinking

`Hegel.Generator` stores a function from the current `Hegel.TestCase` to an
Elixir value. It stores no RNG and does not implement `Enumerable`:

- Primitive generators ask libhegel for a choice with explicit constraints.
- `map/2` transforms a drawn value but retains the underlying choices.
- `bind/2` and `composite/2` make dependent draws through Elixir control flow.
- Fixed shapes use spans; variable-length shapes use engine-managed
  collections. The frontend reports rejected unique keys or elements to the
  collection so they do not consume its size.
- `filter/3` discards a structural span and retries within that span before it
  rejects the example. The native filter health check can then diagnose a
  narrow domain.
- State-machine steps use their reserved structural label. The shrinker can
  remove or simplify actions while keeping a valid choice sequence.

libhegel's shrinker transforms a recorded sequence of structured decisions.
The Elixir property then interprets the sequence again. The frontend has no
lazy shrink tree or global generation-size input.

libhegel's C settings enable multiple-failure reporting. The Elixir frontend
sets it to `false` by default, matching StreamData's first-failure workflow and
Hegel's higher-level Rust and TypeScript frontends. Set
`report_multiple_failures: true` to collect failures from distinct origins.

## Ownership and cleanup

Rustler wraps each caller-owned C handle in a distinct resource:

- `RunResource`
- `TestCaseResource`
- `StringGeneratorResource`
- `CollectionResource`
- `PoolResource`
- `StateMachineResource`

Test-case, string-generator, collection, pool, and state-machine resources
store `Mutex<Option<RawHandle<T>>>`. The mutex serializes native use and the
`Option` makes close a one-way state transition. Explicit close takes the
pointer and invokes its matching C destructor; `Drop` performs the same take
as a fallback. This one-way take prevents double free. `ResourceArc` keeps the
resource alive throughout a NIF call if its Elixir owner exits during the call.

`RunResource` owns a command channel to one persistent native worker. The
worker alone owns the C run handle. Both explicit `run_close` and
`RunResource::drop` call `RunWorker::shutdown`; shutdown sends `Close`, waits for
the worker to release the run, and joins the worker thread before it returns.
Rustler holds the resource during active NIF calls, so `Drop` reaches an idle
command loop. The synchronous join prevents a worker from executing unloaded
NIF code after a hot-code purge.

Rust guards own short-lived C allocations for settings, run results, failure
copies, byte results, and string results. Their `Drop` implementations call the
matching `*_free` operation on success and error paths. The NIF copies borrowed
C strings into Rust strings before freeing their parent result or context.

Each NIF operation creates and frees its own mutable `CallContext`. It reads the
diagnostic before the next ABI call can overwrite that context's error buffer.

`Hegel.TestCase` records its owner PID and rejects access from another process.
The C ABI supports cloned streams, but this frontend leaves them unexposed
because BEAM scheduling would make choice order unstable and break replay. Draw
immutable values in the property process before starting Tasks.

## Scheduling

`hegel_next_test_case` can do significant engine work between yields during
targeting and shrinking. On macOS, its terminal failure/shrink continuation
also needs more stack than a BEAM scheduler thread supplies. Polling it from
that thread caused a reproducible SIGBUS at the end of a failing run.

`RunWorker::start` creates one worker with an 8 MiB stack for each run and
reuses it until close. The worker serializes `next`, result creation,
cancellation, and run destruction. Rustler marks `run_next`, `run_result`, and
`run_close` as `DirtyIo`; each NIF sends a command and waits for the worker's
reply. The worker performs the engine work without occupying a dirty CPU
scheduler or creating a thread for each generated and shrink example.

`run_next` returns a test-case resource before Elixir calls the property. User
property code therefore runs outside the NIF on its BEAM process.

Bounded scalar draws, settings operations, marking, and destructors remain
normal NIF calls. Rustler marks big-integer and byte generation, string-spec
construction, and string generation as dirty CPU work because their cost grows
with user-controlled input. Future bindings must assign a suitable scheduler to
any C operation that can block or search for an unbounded period.

Resource mutexes serialize native access; they do not permit sharing a live
case. One Elixir process drives a run, and the owner check enforces that public
contract.

## Output and errors

libhegel writes output through a native callback. The callback runs on the run
worker, or on the calling thread for a
standalone blob case. It copies each UTF-8 line into an
`Arc<Mutex<Vec<String>>>` and does not call Elixir. Run and test-case resources
share the buffer across yielded cases.

`Hegel.Result.output` receives the drained buffer, and final replay copies its
lines onto each failure. `Hegel.check/2` renders engine lines with minimized
draws, notes, and the reproduce command. Programmatic callers can read the same
output without exposing BEAM IO to a native callback.

The NIF maps C results as follows:

- `HEGEL_OK` becomes a normal value or `:ok`.
- `HEGEL_E_STOP_TEST` and `HEGEL_E_ASSUME` become internal control flow and are
  marked as overrun or invalid, not user failures.
- Backend, handle, argument, lifecycle, internal, and concurrent-use errors
  become tagged tuples and then `Hegel.NativeError`, or a run-level
  `%Hegel.Result{status: :error}` when libhegel completed without a verdict.

The NIF load callback calls `hegel_version` and compares it with the compiled
constant `0.32.5`. A mismatch stops NIF loading. After that check,
`Hegel.version/0` reports the linked engine version.

## Static linking and distribution

Cargo builds `hegel_native` as a `cdylib` and consumes `hegeltest-c` as an
`rlib`. Each NIF contains libhegel, so the runtime never loads a second engine
shared object and has no `HEGEL_LIBHEGEL_PATH` setting.

RustlerPrecompiled selects NIF ABI 2.15 and supports three release targets:

- `aarch64-apple-darwin`
- `x86_64-apple-darwin`
- `x86_64-unknown-linux-gnu`

The release workflow builds each target with Rust 1.92.0, packages the NIF,
generates SHA-256 checksums, and uploads the assets to the matching GitHub
Release. The Hex package contains the checksum map. A consuming project
downloads and verifies one archive during dependency compilation.

The upstream `hegeltest-c` package also declares a `cdylib`, so a source build
may leave an unused `hegel_c` artifact beside the NIF. `hegel_native` links the
engine into its own binary and does not load that artifact. The Cargo lockfile
fixes the native dependency graph; the consuming project records Hex
dependencies in its Mix lockfile.

Targets outside the release matrix require Rust 1.91 or newer and a native
toolchain. Users add Rustler to the consuming project and set
`HEGEL_ELIXIR_BUILD=1` to compile from source. The Hex package includes the
native source and Cargo files for this path.

Each release archive and the Hex source package carry
`THIRD_PARTY_NOTICES.md`, the cargo-about report in
`THIRD_PARTY_LICENSES.html`, and the Rust standard-library notices in
`RUST_STDLIB_COPYRIGHT.html`.

## Version policy and upgrades

Hegel's beta policy allows a `0.N.0` engine release to break compatibility and
reserves patch releases for compatible changes. The Elixir frontend also has
its own 0.x API. It pins the engine version because:

- the C ABI may change across beta minor releases;
- Hegel guarantees reproduce-blob compatibility only within a version;
- enum values, ownership rules, and output behavior are part of the adapter's
  safety case; and
- a range dependency could change the native engine without a matching Elixir
  code change.

For an engine upgrade:

1. Update the exact `hegeltest-c` dependency and Cargo lockfile together.
2. Diff the canonical `hegel.h` and audit every changed signature, enum,
   ownership rule, threading rule, and label.
3. Update the compiled expected-version constant and all Elixir option/value
   mappings.
4. Regenerate `THIRD_PARTY_LICENSES.html` if the Cargo graph changed.
5. Run lifecycle, shrinking, replay, failure-origin, state-machine, health
   check, and resource-drop tests on every supported target.
6. Build new NIF assets and publish a checksum map for the frontend release.
7. Treat old reproduce blobs as stale unless the new version accepts them, and
   document any frontend migration before releasing.

Keep the exact pin. It forms part of the boundary contract.

## Known tradeoffs

- Forced source builds are slower and less portable than a pure-Elixir
  dependency.
- A NIF defect can affect the whole VM. Rustler and RAII reduce memory-lifetime
  risk but cannot provide process isolation.
- Garbage collection of an abandoned live run waits for the idle worker to
  cancel and exit. Normal completion closes and joins the worker before GC.
- Static linking increases the artifact size and requires a rebuild for each
  engine update.
- The process-owned test-case policy leaves libhegel's cloned concurrent
  streams unexposed. A future concurrency API would need ordered semantics to
  preserve replay on the BEAM.
- The public API exposes generators and state machines. Variable-pool resources
  remain internal until a stateful abstraction needs them.
