Libraries describe and report work. Owners finish it. Checkpoints run cancellation and application callbacks. Observers read the resulting state. Encoder adoption that is still in progress is recorded in the zenav1 measurement; what is tested is in the validation notes.

Crates

The crates follow one dependency direction:

library → how-far → enough
application → how-far-along → how-far
application/tests → how-far-really → how-far-along + how-far

how-far owns the portable protocol: Pulse, Stop, Report, planning specs, Child, SharedPulse, StageKey, Stage, Stages, Phases, pacing and result handoffs, plus the two pulses that observe nothing: NoPulse and the cancellation-only StopOnly. AsStopReason lives in enough, which libraries already depend on, and is re-exported. Libraries expose &dyn Pulse and their own error types, never tracker owners or diagnostic records unless observation is their purpose.

how-far-along owns tracking: Phase, PulseTree, Reporter, Observer, NodeId, Status, Summary, Snapshot, polling and checkpoint callbacks. Its root re-exports the author-facing core types, not ChildPulse, RunError or TryStages, which implementors import from core. It never re-exports diagnostics. how-far-really owns opt-in profiling and analysis in two public modules, profile and diagnostics, with no root re-exports. The API inventory under docs/public-api and compile-fail doctests guard these boundaries.

Interface and results

A library accepts &dyn Pulse. check asks whether to stop and may run a checkpoint callback; advance only counts completed units; step does both, in that order. Hot codec loops stay non-generic over their pulse.

Stages::define(pulse, [specs...]) returns a sequence and an array of reservations. Bind them to variables and call stages.begin(key) in order. Keys carry plan identity and position; labels are display text only. Wrong-plan, reused and out-of-order keys record StageMismatch and return an untracked cancellation-preserving handle without consuming the declaration. A key is not a completion owner: discarding it leaves the phase pending. One Arc identity allocation per nonempty definition keeps keys valid across moves and prevents stale keys matching a later plan. There is no shared mutable coordination state. Stages::new with begin_next() serves runtime-sized repeated work using the same active handles and result handoffs.

The Stage borrows its plan, preventing overlapping sequential handles. operation(&stage).finish_phase(stage)? records the result before control leaves the function. Failure or cancellation immediately resolves remaining stages as NotRun. Dropping an active stage records Abandoned and also resolves the remaining sequence as NotRun. Complete the plan on success to discharge optional unused stages.

stage.fork([specs...]) starts independently owned Child handles under Execution::ForkJoin. They borrow the enclosing stage; scheduling and joining belong to the caller. Each branch can run a different operation or declare its own sequence. Finish each branch with its result, join all workers, then hand the combined result to the enclosing stage. A failed branch does not finish or cancel siblings. Unlike sequential reservations, forked handles are already active and abandon their branches when dropped unfinished.

Phases selects independent attempts and permits recovery. Stages::run and Complete::complete_with are optional closure conveniences. All result handoffs preserve the original value or error. A bare ? outside a handoff still cannot tell an owner why control left; use an ordinary operation's returned result or a closure helper when appropriate. From<StopReason> lets library code use ? at a checkpoint; AsStopReason tells an owner whether an error is a cancellation. It is a trait rather than a TryFrom<&LibraryError> for StopReason convention because a bound on that conversion lets rustc infer StopReason for an unannotated error and report a misleading mismatch when the conversion is missing; a missing AsStopReason is reported as such. Foreign error wrappers use the _classified variants. No planning error ever enters a library's Result: best-effort planning records a rejected plan and supplies untracked children that still check the stop. The explicitly fallible TryStages remains available for callers who want planning failures in their return type.

split hands out child owners; libraries never finish their borrowed input. Completion rights are owned and consumed, so completing twice through one owner is unrepresentable, and a private runner enum prevents contradictory lifecycle flags. Owners and guards are must_use, which catches discarding one but cannot prove it is eventually completed. A repeated or unknown stage index is recorded and runs through an untracked view, which keeps the work's result but does not make running unintended work correct.

share() returns a cloneable, 'static SharedPulse with the same planning, cancellation, start and total-revision capabilities, but never completion rights. It works wherever a Stop or Report is expected, such as a codec context that stores impl Stop + 'static; join such work before finishing the original owner. A pulse that cannot be owned, such as a borrowed stop policy, returns NotShareable: no path panics or silently drops cancellation. References, boxes, Arcs, children, callback pulses and diagnostic wrappers forward every operation.

States

State/outcomeMeaning
NotStarted (default)Declared, no observed entry or nonzero report
RunningEntered or reported work, no completion
SucceededOwner reports success
FailedOwner reports an ordinary failure
CancelledOwner reports a recognized stop reason
SkippedWork is unnecessary; its obligation is discharged
NotRunEarlier failure/stop prevented work; earns no completed weight
AbandonedOwner disappeared or parent closed after entry without a result

Status::Finished(Outcome) keeps lifecycle separate from outcome, and the count fraction is independent evidence: a cancelled phase may have counted all its units, and a successful one may have missing reports. On completion, untouched children become Skipped on success and NotRun on failure, started unfinished ones become Abandoned, and finished children keep their outcomes, including failed attempts under a recovered parent. completion_inferred marks a phase resolved by its parent rather than by its own handoff: provenance, not proof that the program was correct. Dropping a plan or root without its Result records abandonment; Drop cannot see a Result.

Lifecycle and administration

The owner holds one completion token separate from shared state, so a retained shared view cannot extend the lifecycle. Reports after completion are ignored and terminal observations are frozen. Administration (start, total revision, split, completion) runs under one short per-phase lock that never spans user code, so the owner and shared views queue instead of refusing each other. Dropping the owner waits at most for an administrative call already under way, then records abandonment outside the lock.

A phase can start before it knows its plan. A nonzero report makes it a counting leaf and prevents a split; zero reports are inert. A leaf's total is revisable, and its 16 most recent revisions are kept; a split clears them, since a branch has no total. Phases nobody observes (NoPulse, StopOnly, untracked children) accept and discard revisions. NoPulse validates plan shapes but keeps no lifecycle state, so it cannot detect stateful misuse.

Accounting and callbacks

PulseTree and FnPulse share one accounting implementation. Reports update a leaf counter, never a job-wide fraction, so independent workers do not contend on one counter. Observer::summary walks the subtree without copying names or allocating; try_summary skips a busy metadata read; snapshots allocate their records. Fractions can regress after a total revision. A leaf with an unknown or overflowed denominator has no fraction. Weighted parents still show known children's progress: unresolved children contribute zero and retain their declared weights, without renormalizing known siblings. Read unresolved_fraction alongside the fraction to show that reserved unknown share. Snapshots, allocation-free summaries and display smoothing use the same accounting rule; an unknown optional stage never hides a known sibling's work.

FnPulse is always available. Each check offers a lazy view (phase, name, summary and snapshot accessors) that walks nothing unless asked. Callbacks can overlap; the first stop reason wins. No internal lock is held during user code, and callbacks must not recursively check the same pulse. Reports, phase completion and Drop never invoke callbacks; an application observes once after finishing the root. LocalPoller drives thread-affine UI callbacks from the host. Paced::finish flushes the final batch and checks; its Drop only counts.

Smoothness is the display's concern, not the library's. A library reports at the granularity its own loop has. how-far-along's optional smooth feature adds ProgressSmoother, which a display holds per observed tree of one operation; libraries never see it. It watches each running leaf's counts at poll time, using the caller's clock (the tracker reads none), and between reports moves the leaf on at that leaf's own average pace: at most the latest change past its recorded count and at most half the work it has left, so extrapolation alone never completes a stage. A running leaf does not move backward under an unchanged total: a previously displayed estimate can remain above a smaller new bound until the work catches up. Recorded outcomes win: a finished leaf shows the snapshot's record and a revised total starts the leaf over, so the display steps back when a stage fails or its total rises. A change is timed when a poll first sees it, so the resolution is the poll interval. Accuracy depends on the library declaring stages whose units cost about the same; the smoother cannot find boundaries a library left out.

Synchronization and portability

Counters use saturating relaxed atomics. They carry no application data and establish no happens-before relation for encoder buffers; thread and Rayon joins do. Phase state is published with release/acquire so terminal readers see the final count and outcome. Changing these orderings is a correctness decision, not a performance knob.

Metadata is an immutable Arc version replaced under a short platform lock; snapshots clone the version under the lock and walk it outside. No lock or critical section spans a callback or host suspension. The no_std backend needs a critical-section provider that excludes every participating core; disabling interrupts on one core is not enough, and there even try_summary enters the critical section.

Core and tracker need pointer atomics for Arc. Counters are native 64-bit where available, saturating at 2^63 - 1 so that a report is one fetch_add that never retries when workers share a phase, and native-width with compare-and-swap saturation elsewhere (Snapshot::counter_max). Pacing reduces cache-line transfers on a shared counter; giving workers separate children helps only when separate totals or outcomes mean something, and worker count must not change the containing phase's weight.

Compile-time seams

The core depends only on enough and has no Cargo features. WithStop (combined or replacing, owned or borrowed stop policies) and the strict TryStages runner are always available. These helpers add no fields to ordinary pulses, and erased implementations avoid compiling adapter machinery per wrapped type. The tracker always provides snapshot JSON through core::fmt::Write, without a serializer dependency.

The tracker's only default feature is std, which selects its mutex backend. It does not enable enough/std: both configurations already use enough/alloc. Without defaults the tracker needs alloc, native atomics and a critical-section provider. FnPulse and its dispatcher policy are always available. Ordinary PulseTree owners, children and shared views keep their cancellation-only policy, with no callback slot or dispatch branch. The build-cost guard requires branch-free root checkpoint code. The stable smooth feature owns its state separately from the tree and stays opt-in to preserve the default compile-cost budget.

Diagnostics is a separate std-only crate. Its opt-in experimental-stage-suggestions feature groups timestamps already collected without the feature. Grouping rules and generated suggestions are experimental and may change in patch releases; consumers relying on them should pin an exact version or revision. Shared public types retain their stable compatibility contract. Naming a feature experimental is not enough by itself: this exception applies only to the documented experimental functionality.

An encoder can feature-gate both its how-far dependency and every reporting site, as the zenpng fixture does. The feature review and measurements record why smoothing and stage grouping remain outside the default IR budgets.

Extensible enums and observation records are non-exhaustive. Diagnostic messages are not machine identifiers, and timing thresholds are not correctness rules. IDs identify records within a process. Tracking JSON is schema 2; trace JSON embeds a complete versioned tracking document, and schema changes need an explicit version change.

Wasm

The browser's main thread cannot block, and a worker running a synchronous Rust loop cannot receive a message until the loop returns. There are three ways to stay responsive:

  1. Keep CPU work in workers and read progress from the UI thread with Observer::try_snapshot, retrying a busy read on the next frame. A cancel request reaches the worker's loop through shared memory: a stop policy such as almost_enough::Stopper, flipped from the UI thread. Terminating a worker is a hard abort that runs no Rust cleanup, so no abandonment is recorded.
  2. Return to the host at safe boundaries. A resumable algorithm runs a bounded chunk, returns, and is called again on the next turn. A time budget is checked only at those boundaries, so it cannot shorten one indivisible chunk.
  3. Suspend the stack at a Wasm import with JSPI, or with an Asyncify build, and keep synchronous Rust source.

JSPI's host side looks like this:

const imports = {
  host: {
    checkpoint: new WebAssembly.Suspending(async (completed) => {
      reportProgress(completed);
      if (performance.now() >= deadline) {
        await new Promise(resolve => setTimeout(resolve, 0));
        deadline = performance.now() + 10;
      }
      return cancelled ? 1 : 0;
    }),
  },
};
const { instance } = await WebAssembly.instantiate(bytes, imports);
const completed = await WebAssembly.promising(instance.exports.run)(units);

The callback must reach a suspending import, and the outer call must go through the Wasm export itself; ordinary JavaScript frames in between, such as an arbitrary wasm-bindgen closure trampoline, can prevent suspension. Do not re-enter a mutably borrowed encoder while it is suspended, and do not hold application locks across a suspension. See the JSPI proposal. Safari 27 added JSPI according to the WebKit release notes; feature-detect WebAssembly.Suspending and WebAssembly.promising, and fall back to workers, chunks, or Asyncify. A callback that schedules a timer and returns to the same Rust loop does not yield, and neither does an already-resolved promise.

The Wasm probe runs real callbacks in Wasm under Node with JSPI, and checks the timer boundary, suspension and cancellation, and progress posted from a worker. The browser fixture runs a wasm-bindgen-rayon pool in a worker, in Chromium and Playwright's WebKit; its Wasm and thread-pool dependencies live only there. A second binding context on the UI thread reads the same Rust tree and cancels through shared memory. Threaded Wasm needs shared memory, cross-origin isolation, a worker-pool initializer, and a standard library rebuilt with atomics; see wasm-bindgen-rayon. The crates do not configure that build for you. Playwright's WebKit is not Apple's Safari, and Asyncify builds are not covered.

The profiler needs std but no OS threads. On the web, supply a performance.now() clock instead of StdClock, record on a worker, and read traces from the UI with Profiler::try_snapshot.