Merge dbsp/step5-formulas into dbsp/step6-exactsum
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Generated
+18
@@ -1142,6 +1142,16 @@ version = "0.2.190"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -2786,3 +2796,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proptest",
|
"proptest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zset-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libloading",
|
||||||
|
"zset",
|
||||||
|
]
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp", "crates/numtower", "crates/zset", "crates/improvise-incremental"]
|
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp", "crates/numtower", "crates/zset", "crates/improvise-incremental", "crates/zset-ffi"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "improvise"
|
name = "improvise"
|
||||||
|
|||||||
@@ -269,17 +269,24 @@ is followed in outline, and the places it is not are the reason for this section
|
|||||||
formulas' dependency graph by name. When it has no cycle and there are fewer formulas than
|
formulas' dependency graph by name. When it has no cycle and there are fewer formulas than
|
||||||
passes, one evaluation per cell in dependency order reaches the fixed point eager's passes
|
passes, one evaluation per cell in dependency order reaches the fixed point eager's passes
|
||||||
reach (a chain of fifteen links needs fifteen passes and a sixteenth to see no change). It
|
reach (a chain of fifteen links needs fifteen passes and a sixteenth to see no change). It
|
||||||
differs from the passes in one place: a cell holding NaN is never equal to itself, so eager's passes
|
differs from the passes in two places. A cell holding NaN is never equal to itself, so eager's
|
||||||
never see it settle and call it `circular`, and the cells that read it keep what they computed
|
passes never see it settle and call it `circular`, and the cells that read it keep what they
|
||||||
from the NaN. `mark_nan_circular` does the same afterwards. `Evaluation::Plain` always runs
|
computed from the NaN; `mark_nan_circular` does the same afterwards. And eager stops on `==`,
|
||||||
the passes; `tests/ordering.rs` compares the two and `tests/formulas.rs` compares both with
|
which calls `-0.0` equal to the `0.0` an early pass read from a stored cell, so a stem with a
|
||||||
eager.
|
zero cell can settle on a sign dependency order never sees (a stored `0.0` at `F1`, `F3 = F1`,
|
||||||
|
`F4 = F3 ^ -1`, then `F1 = -0`: eager's `F4` is `inf`). A stem whose ordered evaluation holds any
|
||||||
|
zero is therefore evaluated again by the passes (`holds_a_zero`). `Evaluation::Plain` always
|
||||||
|
runs the passes; `tests/ordering.rs` compares the two after every edit, zero signs included, and
|
||||||
|
`tests/formulas.rs` compares both with eager.
|
||||||
6. **A formula edit rebuilds the stems.** Adding, removing or replacing a formula (the fingerprint
|
6. **A formula edit rebuilds the stems.** Adding, removing or replacing a formula (the fingerprint
|
||||||
of the formula list moves) rebuilds the stems from the store by one pass, clears the cache and
|
of the formula list moves) rebuilds the stems from the store by one pass, clears the cache and
|
||||||
evaluates every stem, because a formula has a cell at each. Clause 5 wants a formula delta;
|
evaluates every stem, because a formula has a cell at each. Clause 5 wants a formula delta;
|
||||||
the index is not maintained across a formula edit. `Meter::rebuilds` counts it.
|
the index is not maintained across a formula edit. `Meter::rebuilds` counts it.
|
||||||
7. **A new aggregation dirties every stem if a formula reads that measure.** `measure_agg` is not
|
7. **A new aggregation dirties the stems it can reach.** `measure_agg` is not a cell, so no change
|
||||||
a cell, so no change reaches the readers; `reads_name` asks the formulas.
|
reaches the readers. A fold under a None category takes its aggregation from the `_Measure`
|
||||||
|
coordinate of the key, which for a formula targeting another category comes from the stem and
|
||||||
|
from no name the formula reads, so every live stem carrying that measure is dirtied
|
||||||
|
(`Stems::carrying`). Where a formula names the measure (it targets `_Measure`) `reads_name` dirties every stem.
|
||||||
8. **A write of the same bits changes nothing; a write of `-0.0` over `0.0` does.** The cell delta
|
8. **A write of the same bits changes nothing; a write of `-0.0` over `0.0` does.** The cell delta
|
||||||
treats the two as one value (clause 3), but a formula may not (`Units ^ -1` is `inf` and
|
treats the two as one value (clause 3), but a formula may not (`Units ^ -1` is `inf` and
|
||||||
`-inf`), so the formula side reads the raw change log, not the canonical delta.
|
`-inf`), so the formula side reads the raw change log, not the canonical delta.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# `zset-ffi`: the reasoning
|
||||||
|
|
||||||
|
`crates/zset-ffi` is a C ABI over `zset` for CFFI callers (the first is Common Lisp). It is a
|
||||||
|
separate crate so `zset` stays std-only, serde-free and wasm32-clean (ADR 009 clause 1). It is
|
||||||
|
native only: it is **not** in `nix/ci.nix`'s `wasmCrates`, and the workspace-wide native
|
||||||
|
build (`--workspace`) already compiles it, so nothing in the nix files changes. The contract
|
||||||
|
is `crates/zset-ffi/include/zset_ffi.h`; this file records the decisions behind it. The
|
||||||
|
evidence is in `context/test-evidence/zset-ffi.md`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
`cargo build -p zset-ffi --release` writes `target/release/libzset_ffi.{so,dylib}` (the
|
||||||
|
`cdylib`), `libzset_ffi.a` (the `staticlib`) and the rlib. The `rlib` is what the Rust tests
|
||||||
|
link, so they call the `extern "C"` functions as plain functions.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Monomorphic.** Keys are byte strings `(ptr, len)`, weights are `int64_t`. The generic
|
||||||
|
`ZSet<K>` is instantiated once, at `Vec<u8>`. A caller encodes whatever it keys by.
|
||||||
|
2. **Opaque handles, explicit create and free.** `ZsZSet`, `ZsCircuit` and `ZsRebuild` are
|
||||||
|
`Box::into_raw` pointers; `zs_X_new(&out)` makes one and `zs_X_free` releases it. Freeing
|
||||||
|
null is a no-op. A double free or a use after free is undefined, as in C. Circuit streams,
|
||||||
|
inputs and sinks are `uint32_t` ids local to their circuit, not handles: they own nothing.
|
||||||
|
3. **Error codes, never unwinding.** Every function but the frees and two accessors returns
|
||||||
|
`int32_t`: 0, or a `ZS_ERR_*`. Every entry point body runs in `catch_unwind` (`guard`).
|
||||||
|
A `zset` weight-overflow panic is recognised by its message prefix and becomes
|
||||||
|
`ZS_ERR_OVERFLOW`; any other panic is `ZS_ERR_PANIC`. Results come back through
|
||||||
|
out-pointers written only on success. `zs_last_error()` is a thread-local, NUL-terminated
|
||||||
|
message, never null, valid until the next failure on that thread.
|
||||||
|
The Rust panic hook still prints the overflow message to stderr before the code returns,
|
||||||
|
because replacing a process-wide hook from a library is not this library's call. A host that
|
||||||
|
wants silence installs its own hook, or avoids the overflow.
|
||||||
|
4. **Failure leaves the Z-set unchanged.** `add`, `sub`, `neg`, `scale` and `decode_add`
|
||||||
|
compute a new value and then assign it, so an overflow half way through cannot leave a
|
||||||
|
half-applied update (`zs_zset_insert` is one addition, so it has no half).
|
||||||
|
5. **A failed step poisons the circuit.** A panic in `step` leaves operator state half
|
||||||
|
updated, and the circuit has no rollback (ADR 009 clause 11 is the host's; `zset.md`). So the first
|
||||||
|
failure in a step, or in a rebuild step, marks the circuit poisoned, and every later call
|
||||||
|
that uses it returns `ZS_ERR_POISONED`. A caller drops it and builds another. Ids that do
|
||||||
|
not exist, and malformed arguments, are refused before any state changes and do not poison.
|
||||||
|
6. **Buffers.** The caller owns every buffer it passes and may reuse it once the call
|
||||||
|
returns; the library copies. The library hands out no memory except handles. Output of
|
||||||
|
variable size uses a two-call protocol: `zs_zset_encode(z, buf, cap, &needed)` always sets
|
||||||
|
`needed`, and writes only if `cap >= needed` (else `ZS_ERR_BUFFER_TOO_SMALL`).
|
||||||
|
7. **Wire shape.** A Z-set encodes as entries ascending by key, each `[u32 LE key length][key]
|
||||||
|
[i64 LE weight]`. A keyed element is `pair(k, v) = [u32 LE length of k][k][v]`. These are
|
||||||
|
this crate's own, not clause 9's wire form (that lives in `improvise-protocol`).
|
||||||
|
8. **What the circuit offers.** Input, output sinks, `delay`, `integrate`, `differentiate`,
|
||||||
|
`distinct`, `plus`, `filter` and `flat_map` (C callbacks), `index_by` (a callback emits the
|
||||||
|
one group key), `join` over two pair streams, and `aggregate` with count, sum, min and max
|
||||||
|
over `int64` values. `map` is `flat_map` that emits once. `aggregate`'s fold is fixed to
|
||||||
|
those four because a C callback cannot return an `Option` of a group result without a
|
||||||
|
second encoding; richer folds belong to a caller that wants one. The `Trace` interface and
|
||||||
|
the `_in` constructors are Rust-side seams and are not exposed.
|
||||||
|
9. **Callbacks** are `extern "C"` function pointers with a `void *ctx`; the library never
|
||||||
|
dereferences `ctx`, and keeps both for the circuit's life. A callback reports failure by
|
||||||
|
its return value (`flat_map`/`index_by` non-zero, `keep` and `expired` negative), and the
|
||||||
|
step then fails with `ZS_ERR_CALLBACK`. A callback must return normally. A Rust panic across
|
||||||
|
`extern "C"` aborts, and a Lisp `handler-case` outside `zs_circuit_step` that unwinds
|
||||||
|
through the Rust frames skips destructors and leaves a `RefCell` inside `zset` borrowed.
|
||||||
|
The ABI cannot stop the unwinding, so it detects it: `ZsCircuit::in_step` is set for the
|
||||||
|
duration of `step` and `rebuild_step`, and cleared only when they return. Every entry
|
||||||
|
point first reads it, so a circuit whose frames were skipped answers `ZS_ERR_POISONED`
|
||||||
|
forever, and a callback that re-enters its own circuit gets `ZS_ERR_POISONED` without the
|
||||||
|
library touching the engine. `zs_rebuild_step` reads `circuit(c)` before it borrows the job,
|
||||||
|
so re-entry from an `expired` callback is refused first. `zs_circuit_free` on a mid-step
|
||||||
|
circuit and `zs_rebuild_free` on a job being stepped do nothing: the object is leaked, not
|
||||||
|
freed under the frames that are running. The first fault recorded in a step is the one
|
||||||
|
reported (`note`), so a failing `index_by` callback is `ZS_ERR_CALLBACK`, not the
|
||||||
|
consequent `BAD_VALUE`. The CL rule is to put `handler-case` *inside* the callback and
|
||||||
|
return the failure value (`tests/smoke.lisp` does). `index_by` needs exactly one emitted
|
||||||
|
key and a keyed stream valid pairs, else `ZS_ERR_BAD_VALUE`; these faults are recorded in a
|
||||||
|
shared cell rather than panicking.
|
||||||
|
10. **Thread confinement.** A circuit and every rebuild job made from it share an `Rc`
|
||||||
|
(`Input`), so they are one group: keep the group on one thread at a time. Z-sets are
|
||||||
|
separate handles. The error message is per thread.
|
||||||
|
11. **The header is hand-written** (cbindgen is not a dependency of this repo). `tests/header.rs`
|
||||||
|
keeps it honest. It maps each Rust parameter and return type of every exported function
|
||||||
|
(including the `unary_op!` expansions) to its C spelling, through a table that panics on
|
||||||
|
an unlisted type, and compares them with the header's declarations. Every `Code` is
|
||||||
|
declared by the `codes!` macro, which generates the enum, `Code::ALL` and the header
|
||||||
|
name, so a new code cannot exist without a `#define` line and a matching value.
|
||||||
|
12. **Versioning.** `zs_abi_version()` and `ZS_ABI_VERSION` are 1. A change that breaks a
|
||||||
|
signature or a code bumps it.
|
||||||
|
|
||||||
|
A rebuild is one logical tick however many `zs_rebuild_step` calls it takes (`zset.md`,
|
||||||
|
decision 8): a `delay` fed by it shows the previous tick once, and `done` is 1 as soon as the
|
||||||
|
last chunk ran, whatever the deadline callback says.
|
||||||
|
|
||||||
|
## Ids, handles and unsafety
|
||||||
|
|
||||||
|
All exported functions are `pub unsafe extern "C" fn`: the rlib is public API, and a safe
|
||||||
|
Rust caller could otherwise pass any pointer. Output pointers are checked before any work
|
||||||
|
(`with_out`): a refused call changes nothing and leaks nothing. Stream ids and sink ids are
|
||||||
|
different spaces (sink ids carry the top bit). A `ZsRebuild` records its circuit's id (a process-wide counter, not an address, which can be
|
||||||
|
reused) and is refused on another. A `Circuit::step` between two `zs_rebuild_step` calls is
|
||||||
|
`ZS_ERR_INTERRUPTED`, without poisoning: zset's `Rebuild::interrupted`, checked first. An id from another circuit is not detected. `zs_zset_add(a, a)` is
|
||||||
|
defined: the sum is computed through shared references and then stored. Keys of 4 GiB or
|
||||||
|
longer are `ZS_ERR_BAD_VALUE`, not truncated. A non-empty aggregate group always has a row,
|
||||||
|
even when its count or sum is zero.
|
||||||
|
|
||||||
|
## Miri
|
||||||
|
|
||||||
|
Miri checks the aliasing, leak and null rules the C compiler cannot. It runs `tests/abi.rs`
|
||||||
|
on a nightly built through the flake's `rust-overlay` input. The recipe below is **not
|
||||||
|
pinned**: `builtins.getFlake "github:oxalica/rust-overlay"` and `<nixpkgs>` (hence `--impure`)
|
||||||
|
resolve to whatever is current, and only the nightly date is fixed. It passed at the date of
|
||||||
|
this commit (about 12s):
|
||||||
|
|
||||||
|
```
|
||||||
|
nix shell --impure --expr 'let p = import <nixpkgs> { overlays = [ (builtins.getFlake "github:oxalica/rust-overlay").overlays.default ]; }; in p.rust-bin.nightly."2026-09-24".default.override { extensions = [ "miri" "rust-src" ]; }' --command cargo miri test -p zset-ffi --lib --test abi
|
||||||
|
```
|
||||||
|
|
||||||
|
It is not a CI job. A proposed one: a `miri` job in `.github/workflows/ci.yml` that takes the
|
||||||
|
nightly from the flake's own pinned `rust-overlay` input in `nix develop .#ci`.
|
||||||
|
|
||||||
|
## Calling it from Common Lisp
|
||||||
|
|
||||||
|
`crates/zset-ffi/tests/smoke.lisp` is a runnable CFFI program (`defcfun` for each call, a
|
||||||
|
foreign string for keys, `with-foreign-object` for out-parameters, the overflow code checked
|
||||||
|
and the message read back with `zs-last-error`). It loads the built library and passes. It is
|
||||||
|
**not a CI gate**: `sbcl` and `cffi` come from the user's profile and Quicklisp here, and are
|
||||||
|
not in the nix dev shell. The command to run it is in the file's header. Sketch of the
|
||||||
|
definitions it uses:
|
||||||
|
|
||||||
|
```lisp
|
||||||
|
(defcfun "zs_zset_new" :int32 (out :pointer))
|
||||||
|
(defcfun "zs_zset_insert" :int32 (z :pointer) (key :pointer) (len :size) (weight :int64))
|
||||||
|
(defcfun "zs_zset_weight" :int32 (z :pointer) (key :pointer) (len :size) (out :pointer))
|
||||||
|
(defcfun "zs_last_error" :string)
|
||||||
|
(defcfun "zs_zset_free" :void (z :pointer))
|
||||||
|
```
|
||||||
|
|
||||||
|
A callback (`filter`, `flat_map`) is a `defcallback` passed with `(callback name)`, with a
|
||||||
|
`handler-case` inside it that returns the failure value; its `emit` argument is a foreign
|
||||||
|
function pointer to call with `foreign-funcall-pointer`.
|
||||||
@@ -359,14 +359,15 @@ The three repo-map sections of these names, as they stood before the map was con
|
|||||||
## Wasm
|
## Wasm
|
||||||
|
|
||||||
The browser bundle is `improvise-core`, `improvise-formula`, `improvise-io`,
|
The browser bundle is `improvise-core`, `improvise-formula`, `improvise-io`,
|
||||||
`improvise-protocol`, `improvise-command`, `improvise-wasm-client` and
|
`improvise-protocol`, `improvise-command`, `improvise-wasm-client`,
|
||||||
`improvise-worker-server`. All seven
|
`improvise-worker-server`, and (ADR 005, ADR 009) `numtower`, `zset` and
|
||||||
|
`improvise-incremental`, the last three because `improvise-protocol` and
|
||||||
|
`improvise-command` depend on them. Each
|
||||||
`cargo check --target wasm32-unknown-unknown` clean with no feature flags
|
`cargo check --target wasm32-unknown-unknown` clean with no feature flags
|
||||||
(improvise-ywd), gated by the `wasm` job in `.github/workflows/ci.yml`. That
|
(improvise-ywd); `wasmCrates` in `nix/ci.nix` lists the set the CI gate checks, and
|
||||||
job gates **eight** crates, not seven: `improvise-gp` was added (improvise-z5eg)
|
it also holds `improvise-gp` (improvise-z5eg), a candidate rather than a member,
|
||||||
explicitly *not* as a bundle member but as a candidate, because staying
|
because staying wasm-clean is free now and expensive to retrofit. `zset-ffi` is
|
||||||
wasm-clean is free now and expensive to retrofit — so the bundle is seven and
|
native only and not in that list.
|
||||||
the gate is eight, and neither number is the other one's typo.
|
|
||||||
**A feature that adds a dependency to a bundle crate must be checked with `cargo tree --edges normal`** — the default output includes **dev** edges, so a plain `cargo tree -p improvise-protocol --target wasm32-unknown-unknown` reports a dev-only `serde_json` as *present* and reads exactly like a leak. Check per-crate too, because the `wasm` CI job invokes each crate separately and feature unification cannot reach across that (improvise-26dm added a `test-support` feature on `improvise-protocol` on those terms; it is off in every shipped build).
|
**A feature that adds a dependency to a bundle crate must be checked with `cargo tree --edges normal`** — the default output includes **dev** edges, so a plain `cargo tree -p improvise-protocol --target wasm32-unknown-unknown` reports a dev-only `serde_json` as *present* and reads exactly like a leak. Check per-crate too, because the `wasm` CI job invokes each crate separately and feature unification cannot reach across that (improvise-26dm added a `test-support` feature on `improvise-protocol` on those terms; it is off in every shipped build).
|
||||||
|
|
||||||
`improvise-ws-server` is the host and is native only — tokio, tungstenite and
|
`improvise-ws-server` is the host and is native only — tokio, tungstenite and
|
||||||
|
|||||||
+4
-2
@@ -71,7 +71,7 @@ Dated claims and counts inside an entry are snapshots: recount rather than cite.
|
|||||||
39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43).
|
39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43).
|
||||||
**Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind.
|
**Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind.
|
||||||
|
|
||||||
40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. **`view_shape` hashes what is drawn, not occupancy** (its doc comment: "Drawn, not configured") — axes, the drawn item names minus hidden ones, folds, page selections and the `prune_empty` *flag* — so an occupancy write does **not** bump `layout_version`: a `CellData`-only batch that prunes a row out of a view moves the cursor epoch (the frame observation) and not `layout_version`, as `context/code/command.md` records (the question that raised was improvise-6nsd, closed). The arming is wider than the tier (`touches_model` takes the shape snapshot), but a bump needs a shape hash to have moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run.
|
40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. **`view_shape` hashes what is drawn, not occupancy** (its doc comment: "Drawn, not configured") — axes, the drawn item names minus hidden ones, folds, page selections and the `prune_empty` *flag* — so an occupancy write does **not** bump `layout_version`: a `CellData`-only batch that prunes a row out of a view moves the cursor epoch (the frame observation) and not `layout_version`, as `context/code/command.md` records (the question that raised was improvise-6nsd, closed). The arming is wider than the tier (`touches_model` takes the shape snapshot), but a bump needs a shape hash to have moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run. **Superseded (ADR 009 step 7):** the record tier is no longer fingerprinted. `apply_effects` accumulates the store's change log (`DataStore::record_changes`/`drain_changes`) into one consolidated batch `CellDelta` (`improvise_incremental::cell_delta`) and `bump_moved_records` bumps every record in `moved_records(delta)`; `record_fingerprints` and the text above naming it are historical.
|
||||||
|
|
||||||
41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed.
|
41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed.
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Dated claims and counts inside an entry are snapshots: recount rather than cite.
|
|||||||
|
|
||||||
45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist.
|
45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist.
|
||||||
|
|
||||||
**Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag from the effect's `EffectKind`, which makes omitting it unrepresentable rather than validated — **declared** for `WholeWorkbook` (a replaced document differs from the file its path names, so a `LoadModel` that cannot read its path still raises it, costing one redundant write) and **observed** for `CellData` and `LayoutGeometry`, where `document_content`/`document_config` fingerprint the document before and after, so a write that leaves it as it was raises nothing (improvise-748f). The observation is read inside the loop, and `Save` clears the flag in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, and neither does a write that changes nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command.
|
**Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag from the effect's `EffectKind`, which makes omitting it unrepresentable rather than validated — **declared** for `WholeWorkbook` (a replaced document differs from the file its path names, so a `LoadModel` that cannot read its path still raises it, costing one redundant write) and **observed** for `CellData` and `LayoutGeometry`, where `document_content`/`document_config` fingerprint the document before and after, so a write that leaves it as it was raises nothing (improvise-748f). The observation is read inside the loop, and `Save` clears the flag in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, and neither does a write that changes nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command. **Superseded (ADR 009 step 7):** `document_content` is gone. For `CellData` the flag is `reordered || !delta.is_zero() || document_shape moved` computed from **each `CellData` effect's own delta** (drained after every such effect, so ordering with `Save` holds; writes that cancel across effects still leave it set); only the record tier (`bump_moved_records`) uses the consolidated batch delta; `LayoutGeometry` still compares `document_config`. The text above naming `document_content` is historical.
|
||||||
|
|
||||||
**The async seam does not survive being boxed.** `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` are on the struct, not on `Storage`. `App::storage` is a `Box<dyn Storage>` and a trait object can name none of the four, so **handing the backend to the `App` puts the async seam permanently out of reach**; the host must hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. That is `impl<T: Storage + ?Sized> Storage for Rc<T>`, and it lives in **`improvise-io`** rather than in each worker (improvise-hmws): `impl Storage for Rc<OpfsFs>` is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other thread. **The flat root is no longer load-bearing**: `OpfsFs::list` joins `dir` like the other two backends, so `storage_contract` takes `dir` as a parameter and the three agree at *every* directory rather than only at `Path::new("")` where joining is the identity (improvise-vhcz). What the index holds is **documents, not entries**, and the split between its three writers is the rule: `attach` indexes nothing (what it creates is zero bytes), `Storage::write` indexes the name and un-queues any pending delete of it, and `refresh` rebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3).
|
**The async seam does not survive being boxed.** `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` are on the struct, not on `Storage`. `App::storage` is a `Box<dyn Storage>` and a trait object can name none of the four, so **handing the backend to the `App` puts the async seam permanently out of reach**; the host must hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. That is `impl<T: Storage + ?Sized> Storage for Rc<T>`, and it lives in **`improvise-io`** rather than in each worker (improvise-hmws): `impl Storage for Rc<OpfsFs>` is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other thread. **The flat root is no longer load-bearing**: `OpfsFs::list` joins `dir` like the other two backends, so `storage_contract` takes `dir` as a parameter and the three agree at *every* directory rather than only at `Path::new("")` where joining is the identity (improvise-vhcz). What the index holds is **documents, not entries**, and the split between its three writers is the rule: `attach` indexes nothing (what it creates is zero bytes), `Storage::write` indexes the name and un-queues any pending delete of it, and `refresh` rebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3).
|
||||||
|
|
||||||
@@ -136,4 +136,6 @@ Dated claims and counts inside an entry are snapshots: recount rather than cite.
|
|||||||
|
|
||||||
52. **A date column imports as one hierarchical category, and the import refuses what it cannot keep (improvise-8jk6.3, 8jk6.6, d0n6).** Reasoning: `context/code/formula-and-io.md` ("Import: …") and `cli.md` §13. The default `DateShape` nests Year > Quarter > Month (the chosen levels) in ONE category named after the column and opens folded to its years; `DateShape::Split` is the old flat split, chosen by `--split-date <field>` headless or `s` on the wizard's date step. `Plan::spelling_clash` refuses a raw, unparsed date spelled like a generated item or group (`2024`, `2024-01`) because the two would silently merge — asked at the wizard's ConfigureDates step **and** again at build time, so a headless import cannot reach it either. Headless, `--strict` turns a partial skip into a refusal before the build, and a folder whose CSVs are all header-only (no records) is refused as an import that kept no record.
|
52. **A date column imports as one hierarchical category, and the import refuses what it cannot keep (improvise-8jk6.3, 8jk6.6, d0n6).** Reasoning: `context/code/formula-and-io.md` ("Import: …") and `cli.md` §13. The default `DateShape` nests Year > Quarter > Month (the chosen levels) in ONE category named after the column and opens folded to its years; `DateShape::Split` is the old flat split, chosen by `--split-date <field>` headless or `s` on the wizard's date step. `Plan::spelling_clash` refuses a raw, unparsed date spelled like a generated item or group (`2024`, `2024-01`) because the two would silently merge — asked at the wizard's ConfigureDates step **and** again at build time, so a headless import cannot reach it either. Headless, `--strict` turns a partial skip into a refusal before the build, and a folder whose CSVs are all header-only (no records) is refused as an import that kept no record.
|
||||||
|
|
||||||
|
53. **Delivery: a watermark is a `Stamp`; a correction is `target - held`, applied only at the watermark it starts from.** `through` is not `at` (the watermark advances only to `through`), a refused frame restores `Delivery` with the app snapshot, and a lost last notice is found only by `poll`. Axis changes refetch the window until ADR 008's render model. The argument is `context/code/delivery.md` (sections 3, 4 and 6); a refused frame restoring `Delivery` is §6, Rollback.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+20
-8
@@ -6,8 +6,12 @@ Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
|
|||||||
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
||||||
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
||||||
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
|
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
|
||||||
- `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `Projection`, `ViewState`, the per-widget caches, `reduce_view`, key types. Depends only on `improvise-core` + serde. No IO, no command layer, no terminal.
|
- `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `Projection`, `ViewState`, the per-widget caches, `reduce_view`, key types, and `delivery/{wire,notice,exchange,client}.rs` (notices, watermarks, corrections). Depends on `improvise-core`, `zset`, `numtower` + serde. No IO, no command layer, no terminal.
|
||||||
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends on `improvise-protocol`. No ratatui/crossterm.
|
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs, delivery.rs). Depends on `improvise-protocol`, `improvise-gp`, `zset`, `improvise-incremental`. No ratatui/crossterm.
|
||||||
|
- `numtower` — exact decimal arithmetic tower (`Num`, canonical text), ADR 005. Pure Rust, no workspace deps; `improvise-protocol` uses it for the wire form. Long form `context/code/numtower.md`.
|
||||||
|
- `zset` — std-only Z-sets (non-zero checked `i64` weights, panic on overflow) plus `Circuit`, operators, `Trace`, `Rebuild` (ADR 009 clause 1). No dependencies. `context/code/zset.md`.
|
||||||
|
- `zset-ffi` — C ABI over `zset` (`cdylib`/`staticlib`/`rlib`) for CFFI; `include/zset_ffi.h`. Native only, **not** in `wasmCrates`. `context/code/zset-ffi.md`.
|
||||||
|
- `improvise-incremental` — the `Derived` trait, `Reading`, `Eager`, `differential_in`/`drawn_probes`, and `cell_delta`/`DeltaValue`/`moved_records` (the store's `CellChange` log to a `ZSet`). Depends on `improvise-core` + `zset`; `core` never depends on `zset`. `context/code/improvise-incremental.md`.
|
||||||
- `gp-tree` — problem-agnostic genetic-programming regression trees (`Tree`, `Spec`, `breed`, `simplify`, `render`). Knows nothing about improvise.
|
- `gp-tree` — problem-agnostic genetic-programming regression trees (`Tree`, `Spec`, `breed`, `simplify`, `render`). Knows nothing about improvise.
|
||||||
- `improvise-gp` — the one seam between the two: emits a fitted `gp_tree::Tree` as improvise formula text (`formula_text` / `fitted_formula`). Depends on `gp-tree`, `improvise-formula` **and `improvise-core`** — `dataset.rs` builds a feature matrix from a `View`'s records (improvise-4s8) and `fitted_formula` takes the `&Model` it must resolve against (improvise-8xhy). Nothing depends on it yet.
|
- `improvise-gp` — the one seam between the two: emits a fitted `gp_tree::Tree` as improvise formula text (`formula_text` / `fitted_formula`). Depends on `gp-tree`, `improvise-formula` **and `improvise-core`** — `dataset.rs` builds a feature matrix from a `View`'s records (improvise-4s8) and `fitted_formula` takes the `&Model` it must resolve against (improvise-8xhy). Nothing depends on it yet.
|
||||||
- `improvise-ws-server` — the host: tokio + tungstenite, a `Registry` of `Host`s keyed by `DocumentId` (multi-document, ADR 001), `Sessions` fan-out, and the socket framing (`wire.rs`). Native only.
|
- `improvise-ws-server` — the host: tokio + tungstenite, a `Registry` of `Host`s keyed by `DocumentId` (multi-document, ADR 001), `Sessions` fan-out, and the socket framing (`wire.rs`). Native only.
|
||||||
@@ -44,6 +48,12 @@ Architectural intent lives in `context/design-principles.md` — read that for t
|
|||||||
| Change how a name is quoted anywhere | `improvise-formula/src/quote.rs` — the one home, both halves |
|
| Change how a name is quoted anywhere | `improvise-formula/src/quote.rs` — the one home, both halves |
|
||||||
| Change import-wizard keys/steps | `command/cmd/wizard.rs` (keys) + `import/wizard.rs` (steps) |
|
| Change import-wizard keys/steps | `command/cmd/wizard.rs` (keys) + `import/wizard.rs` (steps) |
|
||||||
| Change frame layout | `draw.rs` → `draw()` |
|
| Change frame layout | `draw.rs` → `draw()` |
|
||||||
|
| Change an incremental operator or the circuit | `crates/zset/src/{ops,circuit}.rs` |
|
||||||
|
| Change the C ABI | `crates/zset-ffi/src/lib.rs` + `crates/zset-ffi/include/zset_ffi.h` |
|
||||||
|
| Change the wire form of a value or delta | `improvise-protocol/src/delivery/wire.rs` |
|
||||||
|
| Change what the host publishes/answers | `improvise-command/src/ui/delivery.rs` |
|
||||||
|
| Change what counts as a document or record write | `improvise-command/src/ui/app.rs::apply_effects` (reads the batch cell delta) |
|
||||||
|
| Ask "does this grid draw column totals" | `GridLayout::draws_totals` (`view/layout.rs`) — the one condition |
|
||||||
| Change how wide the TUI grid is beside a side panel | `improvise-command/src/ui/viewport.rs` → `grid_width` (reached through `App::grid_width`); `draw.rs::draw_content` splits on it and `App::cmd_context` counts `visible_cols` on it (improvise-gtn7.1) |
|
| Change how wide the TUI grid is beside a side panel | `improvise-command/src/ui/viewport.rs` → `grid_width` (reached through `App::grid_width`); `draw.rs::draw_content` splits on it and `App::cmd_context` counts `visible_cols` on it (improvise-gtn7.1) |
|
||||||
| Change what the browser page draws | `improvise-wasm-client/src/render.rs` → `RenderModel` |
|
| Change what the browser page draws | `improvise-wasm-client/src/render.rs` → `RenderModel` |
|
||||||
| Change a browser↔server frame | `improvise-protocol/src/frame.rs` → `ClientFrame`/`ServerFrame`. The **one** pair; both ends `pub use` it and neither defines one (improvise-xks6) |
|
| Change a browser↔server frame | `improvise-protocol/src/frame.rs` → `ClientFrame`/`ServerFrame`. The **one** pair; both ends `pub use` it and neither defines one (improvise-xks6) |
|
||||||
@@ -202,12 +212,12 @@ the incidents and the ticket ids.** Read the long form of every gotcha your diff
|
|||||||
37. **`gp-tree` returns data and never prints; a fit needs seed *and* `FitConfig`.** A scorer takes `Row<T>` slices. `fitted_formula` refuses features the document cannot name; categories are forced `Categorical` (zv8t).
|
37. **`gp-tree` returns data and never prints; a fit needs seed *and* `FitConfig`.** A scorer takes `Row<T>` slices. `fitted_formula` refuses features the document cannot name; categories are forced `Categorical` (zv8t).
|
||||||
38. **The wire vocabulary is derived twice** (`Command` view half, `ModelCommand` model half). Completeness is checked (`vocabulary_is_complete.rs`). `ApplyAndClearDrill` and `AcceptWizardModel` are deliberately off the wire (session-local operands). Recount, don't cite.
|
38. **The wire vocabulary is derived twice** (`Command` view half, `ModelCommand` model half). Completeness is checked (`vocabulary_is_complete.rs`). `ApplyAndClearDrill` and `AcceptWizardModel` are deliberately off the wire (session-local operands). Recount, don't cite.
|
||||||
39. **`Applied` is an enum (`Diff | Rebased | Rejected`)** because a document swap is not a diff; the trigger is the generation moving, not the variant. A swap fixture must keep the view *name*.
|
39. **`Applied` is an enum (`Diff | Rebased | Rejected`)** because a document swap is not a diff; the trigger is the generation moving, not the variant. A swap fixture must keep the view *name*.
|
||||||
40. **A batch's basis is three tiers** (`document_generation`, `layout_version`, `record_versions`); the unit is the record; lower tiers are *observed*, not read off the effect.
|
40. **A batch's basis is three tiers** (`document_generation`, `layout_version`, `record_versions`); the unit is the record; lower tiers are *observed*, not read off the effect. The record tier is the consolidated batch cell delta (`cell_delta` over the store's change log, `moved_records`); `record_fingerprints` is gone.
|
||||||
41. **`Storage` is not a VFS;** SQLite was declined on measurement (a second serialization is a second grammar).
|
41. **`Storage` is not a VFS;** SQLite was declined on measurement (a second serialization is a second grammar).
|
||||||
42. **Browser render decisions are made in Rust** (`RenderModel`: `editable` from the `CellId` variant, selection reconciled with offsets, `flatten_cat_tree`). `improvise_protocol::frame` is the one envelope pair for both deployments.
|
42. **Browser render decisions are made in Rust** (`RenderModel`: `editable` from the `CellId` variant, selection reconciled with offsets, `flatten_cat_tree`). `improvise_protocol::frame` is the one envelope pair for both deployments.
|
||||||
43. **A batch carries its basis, and the server publishes it** (`Projection::Basis`). `Basis` has no `Default` or public constructor; `Checked`/`CheckedBatch` make "ignoring the basis" unrepresentable; `Rejection` keeps `RecordGone` / `RecordMoved` / `RecordUnstated` apart. Anti-staleness, not security. The check is a property of the session door (`apply_command` does not check), and only two of the three tiers hoist per frame — the record tier is asked per command (`records_named`) against the frame's basis. Read gotcha 43 before touching it.
|
43. **A batch carries its basis, and the server publishes it** (`Projection::Basis`). `Basis` has no `Default` or public constructor; `Checked`/`CheckedBatch` make "ignoring the basis" unrepresentable; `Rejection` keeps `RecordGone` / `RecordMoved` / `RecordUnstated` apart. Anti-staleness, not security. The check is a property of the session door (`apply_command` does not check), and only two of the three tiers hoist per frame — the record tier is asked per command (`records_named`) against the frame's basis. Read gotcha 43 before touching it.
|
||||||
44. **The TUI grid draws from `GridCache`**, which frames it too (`AxisFrame`; width rule `header_level_widths`). Use the one-pass accessors, not per-position scans. `RecordHash` is document-scoped, `AggregateHash` layout-scoped.
|
44. **The TUI grid draws from `GridCache`**, which frames it too (`AxisFrame`; width rule `header_level_widths`). Use the one-pass accessors, not per-position scans. `RecordHash` is document-scoped, `AggregateHash` layout-scoped.
|
||||||
45. **One session loop (`Host`), two transports.** Saving is a request frame (`ClientFrame::Save`); dirtiness is *declared* for `WholeWorkbook` and *observed* for `CellData`/`LayoutGeometry` (`MarkDirty` is gone). `impl Storage for Rc<T>` lives in `improvise-io`. `harness/` runs `OpfsFs` for real; Safari is unseen.
|
45. **One session loop (`Host`), two transports.** Saving is a request frame (`ClientFrame::Save`); dirtiness is *declared* for `WholeWorkbook` and *observed* for `CellData`/`LayoutGeometry` (`MarkDirty` is gone); for `CellData` the flag is each effect's own delta (drained after every `CellData` effect, so it keeps its order with `Save`) plus reorders, formulas and config, not a document fingerprint; writes that cancel across effects still leave it set. `impl Storage for Rc<T>` lives in `improvise-io`. `harness/` runs `OpfsFs` for real; Safari is unseen.
|
||||||
46. **The page picks a peer:** `?ws=` → socket, else a module `Worker`. `#status` composes two facts; the first-frame watchdog is per-peer-agnostic. The demo model is built so every cell is a `Record`.
|
46. **The page picks a peer:** `?ws=` → socket, else a module `Worker`. `#status` composes two facts; the first-frame watchdog is per-peer-agnostic. The demo model is built so every cell is a `Record`.
|
||||||
47. **Keys become commands in the browser;** the server must not know the keymap. `GridExtent`, `set_viewport`, `Commands(Vec)`, `resolve::NEEDS_THE_HOST` and the five client rules (`projected_at`, `opened`, `refused_here`, `Step::Declined`, `notify`).
|
47. **Keys become commands in the browser;** the server must not know the keymap. `GridExtent`, `set_viewport`, `Commands(Vec)`, `resolve::NEEDS_THE_HOST` and the five client rules (`projected_at`, `opened`, `refused_here`, `Step::Declined`, `notify`).
|
||||||
48. **The browser JS is partly executed** (`harness/tests/shell.spec.js`); changes to `web/*.js` belong there. Serialize frames on one tail promise; disarm, don't pre-empt, a watchdog. Evidence headings may be module-qualified (and must be when the leaf collides).
|
48. **The browser JS is partly executed** (`harness/tests/shell.spec.js`); changes to `web/*.js` belong there. Serialize frames on one tail promise; disarm, don't pre-empt, a watchdog. Evidence headings may be module-qualified (and must be when the leaf collides).
|
||||||
@@ -215,6 +225,7 @@ the incidents and the ticket ids.** Read the long form of every gotcha your diff
|
|||||||
50. **A composed TUI row measures in cells and names which side yields** (`fill_line` + `Keeps`). Keep whichever key table is *derived from what it describes*. Scrolls are measured where drawn (`grid_width`).
|
50. **A composed TUI row measures in cells and names which side yields** (`fill_line` + `Keeps`). Keep whichever key table is *derived from what it describes*. Scrolls are measured where drawn (`grid_width`).
|
||||||
51. **The command language reads pipes, refuses surplus, and checks names when applied** (`only_when_omitted`; `RequireKnown`/`unless_unknown`).
|
51. **The command language reads pipes, refuses surplus, and checks names when applied** (`only_when_omitted`; `RequireKnown`/`unless_unknown`).
|
||||||
52. **A date column imports as one hierarchical category;** `--split-date` restores flat. `Plan::spelling_clash` refuses what would silently merge; `--strict` refuses partial skips.
|
52. **A date column imports as one hierarchical category;** `--split-date` restores flat. `Plan::spelling_clash` refuses what would silently merge; `--strict` refuses partial skips.
|
||||||
|
53. **Delivery: a watermark is a `Stamp`; a correction is `target - held`, applied only at the watermark it starts from.** `through` is not `at` (the watermark advances only to `through`), a refused frame restores `Delivery` with the app snapshot, and a lost last notice is found only by `poll`. Axis changes refetch the window until ADR 008's render model. Long form `context/code/delivery.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,15 +250,16 @@ Per-crate entry points. Per-file notes, test counts and a dated line-count snaps
|
|||||||
| Browser harness | `harness/` | Playwright (`tests/shell.spec.js`, `tests/opfs.spec.js`), `opfs-contract/`, `build.sh` — not a workspace member; its own CI job |
|
| Browser harness | `harness/` | Playwright (`tests/shell.spec.js`, `tests/opfs.spec.js`), `opfs-contract/`, `build.sh` — not a workspace member; its own CI job |
|
||||||
| Examples | `examples/`, `crates/*/examples/` | `gen-grammar.rs`, `pretty-print.rs`, `category_width_bench.rs`, `fit_csv.rs` |
|
| Examples | `examples/`, `crates/*/examples/` | `gen-grammar.rs`, `pretty-print.rs`, `category_width_bench.rs`, `fit_csv.rs` |
|
||||||
| Scripts | `scripts/` | `checkers.sh` (every `*-test.sh` suite), `check-comment-runs.py`, `check-ci-graph.py`, `bd-audit-ids`, `kb`, `pr-watch`, `pr-reviews`, `build-doc-site.mjs` |
|
| Scripts | `scripts/` | `checkers.sh` (every `*-test.sh` suite), `check-comment-runs.py`, `check-ci-graph.py`, `bd-audit-ids`, `kb`, `pr-watch`, `pr-reviews`, `build-doc-site.mjs` |
|
||||||
| Context docs | `context/` | `design-principles.md`, `gotchas.md`, `file-inventory.md`, `code/<area>.md` (arguments), `test-evidence/` (bug evidence by test name), `adr/` (001–004, 007), `cloud-agents.md`; `prolog-sessions/kb.pl` is the KB; `docs/design-notes.md` is product vision |
|
| Context docs | `context/` | `design-principles.md`, `gotchas.md`, `file-inventory.md`, `code/<area>.md` (arguments), `test-evidence/` (bug evidence by test name), `adr/` (001–005, 007, 009; no 006/008 file yet), `cloud-agents.md`; `prolog-sessions/kb.pl` is the KB; `docs/design-notes.md` is product vision |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Wasm
|
## Wasm
|
||||||
|
|
||||||
The browser bundle is `improvise-{core,formula,io,protocol,command,wasm-client,worker-server}`; all seven
|
The browser bundle is `improvise-{core,formula,io,protocol,command,wasm-client,worker-server,incremental}`
|
||||||
`cargo check --target wasm32-unknown-unknown` clean with no feature flags, and the `wasm` CI job also gates
|
plus `zset` and `numtower`; each `cargo check --target wasm32-unknown-unknown` clean with no feature flags
|
||||||
`improvise-gp` (a candidate, not a member). `improvise-ws-server` and the root crate are native-only.
|
(`wasmCrates` in `nix/ci.nix`, which also gates `improvise-gp`, a candidate). `improvise-ws-server`,
|
||||||
|
`zset-ffi` and the root crate are native-only.
|
||||||
Check new bundle dependencies with `cargo tree --edges normal` (the default output includes dev edges),
|
Check new bundle dependencies with `cargo tree --edges normal` (the default output includes dev edges),
|
||||||
per crate. `cargo check` cannot see runtime hazards: `Instant::now()` panics on wasm (`web-time`), and
|
per crate. `cargo check` cannot see runtime hazards: `Instant::now()` panics on wasm (`web-time`), and
|
||||||
`std::fs` compiles but fails in a browser. Long form: `context/file-inventory.md`, appendix.
|
`std::fs` compiles but fails in a browser. Long form: `context/file-inventory.md`, appendix.
|
||||||
|
|||||||
@@ -460,7 +460,6 @@ What each fault was and the test that names it:
|
|||||||
`a_cell_edit_evaluates_the_one_stem_that_reads_it_whatever_the_table_size`,
|
`a_cell_edit_evaluates_the_one_stem_that_reads_it_whatever_the_table_size`,
|
||||||
`every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager` and six others;
|
`every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager` and six others;
|
||||||
- a stem that no cell gives keeps its cells: `a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away`;
|
- a stem that no cell gives keeps its cells: `a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away`;
|
||||||
- reads skip the formulas a name targets: `formulas::tests::what_a_formula_reads_includes_what_the_formulas_it_names_read`;
|
|
||||||
- reads skip the filters' categories: `a_filter_on_a_category_the_stem_has_reads_the_cells_of_other_stems`;
|
- reads skip the filters' categories: `a_filter_on_a_category_the_stem_has_reads_the_cells_of_other_stems`;
|
||||||
- reads skip the formula's own target name:
|
- reads skip the formula's own target name:
|
||||||
`a_new_aggregation_for_the_measure_a_filtered_formula_falls_back_to_changes_it`;
|
`a_new_aggregation_for_the_measure_a_filtered_formula_falls_back_to_changes_it`;
|
||||||
@@ -488,7 +487,10 @@ What each fault was and the test that names it:
|
|||||||
the cache: `every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager`,
|
the cache: `every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager`,
|
||||||
`incremental_matches_eager_on_a_pooled_total_of_a_ratio`.
|
`incremental_matches_eager_on_a_pooled_total_of_a_ratio`.
|
||||||
|
|
||||||
**Faults that cannot be seen, and why.** A born stem is found again by the lookup of the cell that
|
**Faults that cannot be seen, and why.** Reads that skip the formulas a name targets change no answer
|
||||||
|
either (only the unit test `formulas::tests::what_a_formula_reads_includes_what_the_formulas_it_names_read`
|
||||||
|
fails): a stem is evaluated whole, so whatever the skipped formula would have added is
|
||||||
|
re-evaluated because the formula itself dirties the stem. A born stem is found again by the lookup of the cell that
|
||||||
gave it, since the cell agrees with the stem outside the target category, so skipping the extra
|
gave it, since the cell agrees with the stem outside the target category, so skipping the extra
|
||||||
"born means dirty" step changes nothing. The target category being in a formula's reads cannot
|
"born means dirty" step changes nothing. The target category being in a formula's reads cannot
|
||||||
matter either: stems are derived by removing it, so no stem has it to drop.
|
matter either: stems are derived by removing it, so no stem has it to drop.
|
||||||
@@ -633,6 +635,27 @@ Both zeros in both orders. Fails on `Max` negative if any zero is.
|
|||||||
binaries (`tests/corpus_eager.rs`, `tests/maintained.rs`): `oracle.rs` ran 2.45 to 3.04 s in loaded full
|
binaries (`tests/corpus_eager.rs`, `tests/maintained.rs`): `oracle.rs` ran 2.45 to 3.04 s in loaded full
|
||||||
runs because its two heaviest tests shared it. The tests are unchanged.
|
runs because its two heaviest tests shared it. The tests are unchanged.
|
||||||
|
|
||||||
|
### Review of PR #351: aggregation edits, zero signs (improvise-shbc)
|
||||||
|
|
||||||
|
### a_new_aggregation_reaches_the_stems_of_a_formula_that_targets_another_category
|
||||||
|
|
||||||
|
`R0 = East + West` on Region, Time folded, `SetMeasureAgg Revenue Max`: eager reads 13 (the maximum of
|
||||||
|
East plus the maximum of West), the first version kept 14. Fails when a new aggregation dirties only
|
||||||
|
the stems of formulas that name the measure, and when `Stems::carrying` ignores the measure.
|
||||||
|
|
||||||
|
### a_new_aggregation_reaches_a_product_targeted_filtered_sum
|
||||||
|
|
||||||
|
The reviewer's second repro, a Product-targeted `SUM(F0 WHERE Region = East)`. It already agreed on this
|
||||||
|
fixture and stays as the second shape of the same hazard.
|
||||||
|
|
||||||
|
### ordered_evaluation_keeps_the_zero_eager_settles_on
|
||||||
|
|
||||||
|
A stored `0.0` at `F1`, then `F3 = F1`, `F4 = F3 ^ -1`, then `F1 = -0`: eager settles on `F3 = 0.0` and `F4 = inf`
|
||||||
|
because it calls `-0.0` equal to the `0.0` it first read; the first ordered evaluation gave `-inf`. Fails
|
||||||
|
(only `Ordered`) when a stem with a zero is not re-evaluated by the passes. The ordering property's pool
|
||||||
|
now holds `-0`, `Units * -1` and `-Cost`, and compares after every edit, but did not find this fault
|
||||||
|
alone in 1,500 cases: this test is the pin.
|
||||||
|
|
||||||
## Step 6: Sum, Avg and totals through an exact sum (improvise-hrjz)
|
## Step 6: Sum, Avg and totals through an exact sum (improvise-hrjz)
|
||||||
|
|
||||||
**How the faults were injected.** Eleven faults went into `improvise-core/src/model/types.rs`,
|
**How the faults were injected.** Eleven faults went into `improvise-core/src/model/types.rs`,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Test evidence: `zset-ffi`
|
||||||
|
|
||||||
|
What each test guards and where a rival failed. Keyed by test name; the reasoning is in
|
||||||
|
`context/code/zset-ffi.md`. The tests are `crates/zset-ffi/tests/{abi,header,dylib}.rs`.
|
||||||
|
|
||||||
|
**Injected in the source**, one at a time, each reverted and the suite rerun (a build that
|
||||||
|
printed `Running` was confirmed each time). The failing tests, by name:
|
||||||
|
- `guard` without `catch_unwind`: the overflow tests abort the test binary (`SIGABRT`, a panic
|
||||||
|
across `extern "C"`), which is the failure this crate exists to prevent;
|
||||||
|
- `zs_zset_add` that adds in place: `overflow_is_an_error_code_and_leaves_the_set_unchanged`;
|
||||||
|
- every panic classed `ZS_ERR_PANIC`: `overflow_is_an_error_code_and_leaves_the_set_unchanged`,
|
||||||
|
`a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit`,
|
||||||
|
`a_loaded_library_adds_queries_frees_and_reports_overflow`;
|
||||||
|
- a failed step that does not poison: `a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit`;
|
||||||
|
- a recorded fault that does not poison: `a_malformed_pair_and_an_unknown_id_are_codes`;
|
||||||
|
- `rebuild_step` that ignores the deadline: `a_rebuild_resumes_across_calls_and_matches_one_step`;
|
||||||
|
- `Min` that reads non-positive weights: `min_and_max_read_only_values_of_positive_weight`
|
||||||
|
(the first version of the suite did not catch this; the test was added for it);
|
||||||
|
- a header declaration with a parameter dropped or retyped (`int64_t weight` as `int32_t`):
|
||||||
|
`the_header_declares_exactly_the_exported_functions_with_their_types`;
|
||||||
|
- a header code renumbered, and a new `Code` variant with no header line:
|
||||||
|
`the_header_constants_are_the_rust_constants`;
|
||||||
|
- `zs_zset_encode` that reports the wrong size: eight ABI tests, among them
|
||||||
|
`a_small_buffer_reports_the_size_it_needs`.
|
||||||
|
|
||||||
|
### overflow_is_an_error_code_and_leaves_the_set_unchanged
|
||||||
|
|
||||||
|
`a` holds `k = MAX` and `j = 1`; adding a set that overflows `k` must return the overflow code
|
||||||
|
and leave **both** keys as they were. The key `j` sorts before `k`, so a partial in-place add
|
||||||
|
changes `j` first; a fixture where the overflowing key came first would pass that rival.
|
||||||
|
|
||||||
|
### a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit
|
||||||
|
|
||||||
|
A weight reaches `MAX` in an `integrate`, then one more overflows inside `step`. The step
|
||||||
|
returns the overflow code, the next step and a new operator return the poisoned code.
|
||||||
|
|
||||||
|
### a_rebuild_resumes_across_calls_and_matches_one_step
|
||||||
|
|
||||||
|
Nine keys, chunks of two, a deadline callback that is always expired: the job needs five
|
||||||
|
calls' worth of chunks (the callback is counted: five, one per chunk), and the sum of what
|
||||||
|
the sink yields equals one uninterrupted step on a second circuit. The loop is bounded at 20.
|
||||||
|
|
||||||
|
### min_and_max_read_only_values_of_positive_weight
|
||||||
|
|
||||||
|
Weights of mixed sign in one group, where the smallest value has a negative weight, so
|
||||||
|
`> 0` and `!= 0` give different minima.
|
||||||
|
|
||||||
|
### a_loaded_library_adds_queries_frees_and_reports_overflow
|
||||||
|
|
||||||
|
The built `libzset_ffi.{so,dylib}` is opened with `libloading` and driven through symbol
|
||||||
|
lookups: the contract a CFFI caller meets, including the exported names, not the rlib.
|
||||||
|
|
||||||
|
### the_header_declares_exactly_the_exported_functions_with_their_types
|
||||||
|
|
||||||
|
Parses `src/lib.rs` and the header (comments stripped), maps each Rust type to its C spelling
|
||||||
|
and compares names, parameter types and return types; asserts the scanner saw more than 30
|
||||||
|
functions, so an empty parse cannot pass. The first version compared only arity and passed a
|
||||||
|
retyped parameter.
|
||||||
|
|
||||||
|
### the_header_constants_are_the_rust_constants
|
||||||
|
|
||||||
|
Every `ZS_ERR_*`, `ZS_ABI_VERSION`, `ZS_OK` and the four `ZS_AGG_*` equal the Rust values; the
|
||||||
|
the header's `ZS_ERR_*` lines are counted against `Code::ALL`, which the `codes!` macro
|
||||||
|
generates, so a new variant without a line fails (it did, injected as `Novel = 9`).
|
||||||
|
|
||||||
|
### a_chunked_rebuild_through_delay_is_one_tick
|
||||||
|
|
||||||
|
After #345's review the rebuild is one logical tick. A snapshot of two keys, chunks of one,
|
||||||
|
an always-expired deadline: the first call is `done == 0`, the second `done == 1` (the last
|
||||||
|
chunk is Done despite the deadline), and a `delay` sink shows the previous tick's key once.
|
||||||
|
Under the old per-chunk ticking the delay showed the first chunk instead.
|
||||||
|
|
||||||
|
## Review of #347 (8a76469b)
|
||||||
|
|
||||||
|
**Injected afterwards**, one at a time, each failing by name: a null buffer written through
|
||||||
|
(the test binary aborts, which is the failure); `zs_circuit_input` adding the input before
|
||||||
|
checking `out`, and `zs_circuit_take` clearing the sink first:
|
||||||
|
`a_null_out_pointer_is_refused_before_any_state_changes`; a rebuild stepped on another circuit:
|
||||||
|
`a_rebuild_is_stepped_only_on_its_own_circuit`; the sink bit unchecked in `take`:
|
||||||
|
`stream_ids_and_sink_ids_are_not_interchangeable`; a `flat_map` or `keep` failure ignored:
|
||||||
|
`a_callback_that_reports_failure_fails_the_step_and_kills_the_circuit`; re-entry undetected, or
|
||||||
|
`step` not setting `in_step`:
|
||||||
|
`a_callback_that_calls_back_into_its_circuit_gets_poisoned_and_changes_nothing`
|
||||||
|
(and the unit test below for the first); a count that drops its zero row:
|
||||||
|
`a_count_or_sum_that_cancels_to_zero_keeps_its_row`; `len32` truncating:
|
||||||
|
`a_length_of_4_gib_or_more_is_refused_not_truncated`.
|
||||||
|
The aliasing finding (`zs_zset_add(a, a)`) is not an assertion failure on a native build:
|
||||||
|
only Miri finds it, and `a_set_added_to_or_subtracted_from_itself_is_defined` is the test
|
||||||
|
Miri failed before the fix and passes after (recipe in `zset-ffi.md`).
|
||||||
|
|
||||||
|
### a_set_added_to_or_subtracted_from_itself_is_defined
|
||||||
|
|
||||||
|
Self-add doubles, self-sub empties, and a self-add that overflows leaves the set unchanged.
|
||||||
|
|
||||||
|
### a_null_buffer_is_refused_before_it_is_written
|
||||||
|
|
||||||
|
`cap >= needed` with a null buffer is `ZS_ERR_NULL_ARGUMENT`; a null buffer with too small a
|
||||||
|
`cap` still probes the size.
|
||||||
|
|
||||||
|
### a_null_out_pointer_is_refused_before_any_state_changes
|
||||||
|
|
||||||
|
The next input id, the sink's contents and the job's progress are observed after a refused
|
||||||
|
call with a null out-pointer; each is as before.
|
||||||
|
|
||||||
|
### a_rebuild_is_stepped_only_on_its_own_circuit
|
||||||
|
|
||||||
|
A job made from one circuit stepped on another is `ZS_ERR_BAD_HANDLE` and neither sink moves.
|
||||||
|
|
||||||
|
### stream_ids_and_sink_ids_are_not_interchangeable
|
||||||
|
|
||||||
|
A stream id given to `take`, and a sink id given to `delay`, `output` and `push`, are refused.
|
||||||
|
|
||||||
|
### a_callback_that_reports_failure_fails_the_step_and_kills_the_circuit
|
||||||
|
|
||||||
|
`keep`, `flat_map` and `expired` callbacks each return their failure value: `ZS_ERR_CALLBACK`,
|
||||||
|
then `ZS_ERR_POISONED`.
|
||||||
|
|
||||||
|
### a_callback_that_calls_back_into_its_circuit_gets_poisoned_and_changes_nothing
|
||||||
|
|
||||||
|
A filter callback calls `zs_circuit_input` on its own circuit: it gets `ZS_ERR_POISONED`, the
|
||||||
|
outer step completes, and no input was added.
|
||||||
|
|
||||||
|
### tests::a_circuit_left_mid_step_by_a_skipped_frame_answers_poisoned_to_everything
|
||||||
|
|
||||||
|
What a Lisp non-local exit through `zs_circuit_step` leaves behind is the `in_step` flag set;
|
||||||
|
every later call is `ZS_ERR_POISONED` and writes nothing.
|
||||||
|
|
||||||
|
### a_count_or_sum_that_cancels_to_zero_keeps_its_row
|
||||||
|
|
||||||
|
Codex's finding: `Some(0)` was filtered away for a non-empty group.
|
||||||
|
|
||||||
|
### a_length_of_4_gib_or_more_is_refused_not_truncated
|
||||||
|
|
||||||
|
`len32` refuses `1 << 32` and accepts `u32::MAX`.
|
||||||
|
|
||||||
|
## Delta review of #347 (9bcfa9d1)
|
||||||
|
|
||||||
|
**Injected**, one at a time: a fault that overwrites the first (`note` replaced by a plain
|
||||||
|
`set`) fails `an_index_by_callback_that_fails_reports_the_callback_code_not_a_later_fault`;
|
||||||
|
`zs_rebuild_step` borrowing the job before checking the circuit (Miri only), and a `free` that ignores
|
||||||
|
`in_step` or `busy`, fail `a_callback_cannot_step_or_free_the_job_or_circuit_it_runs_under`
|
||||||
|
(and under Miri, as use-after-free); an owner compared by address instead of id fails
|
||||||
|
`a_circuit_freed_and_another_made_does_not_inherit_its_jobs`; the interrupted check removed
|
||||||
|
fails `a_host_step_between_rebuild_calls_is_interrupted_not_a_poison`; a retyped
|
||||||
|
`ZsKeepFn` fails `the_header_callback_typedefs_match_the_rust_callback_types`.
|
||||||
|
|
||||||
|
### an_index_by_callback_that_fails_reports_the_callback_code_not_a_later_fault
|
||||||
|
|
||||||
|
The callback fails (returns 3) and emits nothing, so `index_by` also sees zero keys. The step
|
||||||
|
must report `ZS_ERR_CALLBACK`, the first fault, not the consequent `BAD_VALUE`.
|
||||||
|
|
||||||
|
### a_callback_cannot_step_or_free_the_job_or_circuit_it_runs_under
|
||||||
|
|
||||||
|
An `expired` callback steps its own job, frees the job and frees the circuit. The step is
|
||||||
|
refused with `ZS_ERR_POISONED`, both frees do nothing, and the job then finishes normally.
|
||||||
|
|
||||||
|
### a_circuit_freed_and_another_made_does_not_inherit_its_jobs
|
||||||
|
|
||||||
|
A job outlives its circuit; sixty-four new circuits (some at the old address) all refuse it.
|
||||||
|
|
||||||
|
### a_host_step_between_rebuild_calls_is_interrupted_not_a_poison
|
||||||
|
|
||||||
|
`ZS_ERR_INTERRUPTED`, and the circuit still steps afterwards.
|
||||||
|
|
||||||
|
### the_header_callback_typedefs_match_the_rust_callback_types
|
||||||
|
|
||||||
|
Retyping `ZsKeepFn`'s return in the header fails. The `unary_op!` signatures are now read
|
||||||
|
from the macro body rather than written into the test.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
use improvise_core::formula::ast::Expr;
|
use improvise_core::formula::ast::Expr;
|
||||||
use improvise_core::model::Model;
|
use improvise_core::model::Model;
|
||||||
|
use improvise_core::model::category::MEASURE_CATEGORY;
|
||||||
use improvise_core::model::cell::{CellChange, CellKey};
|
use improvise_core::model::cell::{CellChange, CellKey};
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
|
||||||
@@ -255,6 +256,15 @@ impl Stems {
|
|||||||
self.live.keys()
|
self.live.keys()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The live stems whose own `_Measure` coordinate is `measure`: a fold under a None category
|
||||||
|
/// takes its aggregation from that coordinate, whether or not a formula names the measure.
|
||||||
|
pub fn carrying(&self, measure: &str) -> impl Iterator<Item = CellKey> + '_ {
|
||||||
|
let measure = measure.to_string();
|
||||||
|
self.live()
|
||||||
|
.filter(move |s| s.get(MEASURE_CATEGORY) == Some(measure.as_str()))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether any formula reads a cell under `name`, directly or through another formula.
|
/// Whether any formula reads a cell under `name`, directly or through another formula.
|
||||||
pub fn reads_name(&self, name: &str) -> bool {
|
pub fn reads_name(&self, name: &str) -> bool {
|
||||||
self.reads.iter().any(|r| r.names.contains(name))
|
self.reads.iter().any(|r| r.names.contains(name))
|
||||||
|
|||||||
@@ -203,10 +203,11 @@ impl Incremental {
|
|||||||
self.clear_stem(stem);
|
self.clear_stem(stem);
|
||||||
}
|
}
|
||||||
let mut dirty = moved.dirty;
|
let mut dirty = moved.dirty;
|
||||||
if let Edit::SetMeasureAgg { measure, .. } = edit
|
if let Edit::SetMeasureAgg { measure, .. } = edit {
|
||||||
&& self.stems.reads_name(measure)
|
dirty.extend(self.stems.carrying(measure));
|
||||||
{
|
if self.stems.reads_name(measure) {
|
||||||
dirty.extend(self.stems.live().cloned());
|
dirty.extend(self.stems.live().cloned());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.evaluate(dirty);
|
self.evaluate(dirty);
|
||||||
}
|
}
|
||||||
@@ -252,7 +253,14 @@ impl Incremental {
|
|||||||
let value = self.evaluate_cell(&keys[i]);
|
let value = self.evaluate_cell(&keys[i]);
|
||||||
self.model.set_formula_cell(keys[i].clone(), value);
|
self.model.set_formula_cell(keys[i].clone(), value);
|
||||||
}
|
}
|
||||||
self.mark_nan_circular(&keys);
|
if self.holds_a_zero(&keys) {
|
||||||
|
for key in &keys {
|
||||||
|
self.model.set_formula_cell(key.clone(), None);
|
||||||
|
}
|
||||||
|
self.passes(&keys);
|
||||||
|
} else {
|
||||||
|
self.mark_nan_circular(&keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => self.passes(&keys),
|
_ => self.passes(&keys),
|
||||||
}
|
}
|
||||||
@@ -285,6 +293,13 @@ impl Incremental {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eager stops on `==`, which calls `-0.0` equal to the `0.0` an early pass read, so a stem
|
||||||
|
/// with a zero cell can settle on a sign the dependency order never sees: it runs the passes.
|
||||||
|
fn holds_a_zero(&self, keys: &[CellKey]) -> bool {
|
||||||
|
let zero = |k: &CellKey| matches!(self.model.formula_cell(k), Some(CellValue::Number(n)) if *n == 0.0);
|
||||||
|
keys.iter().any(zero)
|
||||||
|
}
|
||||||
|
|
||||||
/// A NaN is not equal to itself, so eager's passes never see it settle and call it circular
|
/// A NaN is not equal to itself, so eager's passes never see it settle and call it circular
|
||||||
/// once they run out; the cells that read it keep what they computed from the NaN.
|
/// once they run out; the cells that read it keep what they computed from the NaN.
|
||||||
fn mark_nan_circular(&mut self, keys: &[CellKey]) {
|
fn mark_nan_circular(&mut self, keys: &[CellKey]) {
|
||||||
|
|||||||
@@ -292,3 +292,109 @@ fn a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away() {
|
|||||||
];
|
];
|
||||||
same_model(&wb.model, &none, &edits, Evaluation::Ordered).unwrap();
|
same_model(&wb.model, &none, &edits, Evaluation::Ordered).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Region {East, West}, a Time category folded away, Revenue by quarter: `R0 = East + West`
|
||||||
|
/// targets Region, so its stems carry the measure but no formula names it.
|
||||||
|
fn region_target_model() -> Model {
|
||||||
|
let mut model = Model::new("regions");
|
||||||
|
for (cat, items) in [("Region", vec!["East", "West"]), ("Time", vec!["Q1", "Q2"])] {
|
||||||
|
model.add_category(cat).unwrap();
|
||||||
|
for item in items {
|
||||||
|
model.category_mut(cat).unwrap().add_item(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.category_mut("_Measure").unwrap().add_item("Revenue");
|
||||||
|
for (region, quarter, v) in [
|
||||||
|
("East", "Q1", 1.0),
|
||||||
|
("East", "Q2", 3.0),
|
||||||
|
("West", "Q1", 10.0),
|
||||||
|
] {
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Region".into(), region.into()),
|
||||||
|
("Time".into(), quarter.into()),
|
||||||
|
("_Measure".into(), "Revenue".into()),
|
||||||
|
]);
|
||||||
|
model.set_cell(key, CellValue::Number(v));
|
||||||
|
}
|
||||||
|
model.add_formula(parse_formula("R0 = East + West", "Region").unwrap());
|
||||||
|
model
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_aggregation_reaches_the_stems_of_a_formula_that_targets_another_category() {
|
||||||
|
let model = region_target_model();
|
||||||
|
let none = vec!["Time".to_string()];
|
||||||
|
let edits = [Edit::SetMeasureAgg {
|
||||||
|
measure: "Revenue".into(),
|
||||||
|
func: improvise_core::formula::ast::AggFunc::Max,
|
||||||
|
}];
|
||||||
|
for by in [Evaluation::Ordered, Evaluation::Plain] {
|
||||||
|
same_model(&model, &none, &edits, by).unwrap();
|
||||||
|
}
|
||||||
|
let mut backend = Incremental::new(model, none);
|
||||||
|
backend.apply(&edits[0]);
|
||||||
|
let at = CellKey::new(vec![
|
||||||
|
("Region".into(), "R0".into()),
|
||||||
|
("_Measure".into(), "Revenue".into()),
|
||||||
|
]);
|
||||||
|
let want = Some(CellValue::Number(13.0));
|
||||||
|
assert_eq!(
|
||||||
|
backend.value(&at, Aggregation::OverHidden),
|
||||||
|
want,
|
||||||
|
"Max 3 + Max 10"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_aggregation_reaches_a_product_targeted_filtered_sum() {
|
||||||
|
let mut model = Model::new("products");
|
||||||
|
for (cat, items) in [("Region", vec!["East", "West"]), ("Product", vec!["F0"])] {
|
||||||
|
model.add_category(cat).unwrap();
|
||||||
|
for item in items {
|
||||||
|
model.category_mut(cat).unwrap().add_item(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||||
|
for (region, v) in [("East", 4.0), ("West", 9.0)] {
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Region".into(), region.into()),
|
||||||
|
("Product".into(), "F0".into()),
|
||||||
|
("_Measure".into(), "Cost".into()),
|
||||||
|
]);
|
||||||
|
model.set_cell(key, CellValue::Number(v));
|
||||||
|
}
|
||||||
|
model.add_formula(parse_formula("P0 = SUM(F0 WHERE Region = East)", "Product").unwrap());
|
||||||
|
let none = vec![];
|
||||||
|
let edits = [Edit::SetMeasureAgg {
|
||||||
|
measure: "Cost".into(),
|
||||||
|
func: improvise_core::formula::ast::AggFunc::Min,
|
||||||
|
}];
|
||||||
|
for by in [Evaluation::Ordered, Evaluation::Plain] {
|
||||||
|
same_model(&model, &none, &edits, by).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored `0.0` at `F1`, then `F3 = F1`, `F4 = F3 ^ -1` and last `F1 = -0`: eager's first
|
||||||
|
/// pass reads the stored zero, and its later passes call `-0.0` equal to it and stop.
|
||||||
|
#[test]
|
||||||
|
fn ordered_evaluation_keeps_the_zero_eager_settles_on() {
|
||||||
|
let mut model = Model::new("zeros");
|
||||||
|
model.add_category("Region").unwrap();
|
||||||
|
model.category_mut("Region").unwrap().add_item("East");
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Region".into(), "East".into()),
|
||||||
|
("_Measure".into(), "F1".into()),
|
||||||
|
]);
|
||||||
|
model.set_cell(key, CellValue::Number(0.0));
|
||||||
|
let edits: Vec<Edit> = [
|
||||||
|
("F3 = F1", "_Measure"),
|
||||||
|
("F4 = F3 ^ -1", "_Measure"),
|
||||||
|
("F1 = -0", "_Measure"),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|(raw, cat)| Edit::AddFormula(parse_formula(raw, cat).unwrap()))
|
||||||
|
.collect();
|
||||||
|
for by in [Evaluation::Ordered, Evaluation::Plain] {
|
||||||
|
same_model(&model, &[], &edits, by).unwrap_or_else(|e| panic!("{by:?}: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ pub fn hidden_counted_workbook() -> improvise_core::workbook::Workbook {
|
|||||||
|
|
||||||
/// Formulas the random tests add and remove, as `(text, target category)`: chains, a cycle,
|
/// Formulas the random tests add and remove, as `(text, target category)`: chains, a cycle,
|
||||||
/// aggregates over expressions, filters, a ratio, and two that target Region.
|
/// aggregates over expressions, filters, a ratio, and two that target Region.
|
||||||
pub const FORMULA_POOL: [(&str, &str); 18] = [
|
pub const FORMULA_POOL: [(&str, &str); 21] = [
|
||||||
("Profit = Revenue - Cost", "_Measure"),
|
("Profit = Revenue - Cost", "_Measure"),
|
||||||
("Margin = Profit / Revenue", "_Measure"),
|
("Margin = Profit / Revenue", "_Measure"),
|
||||||
("Weighted = SUM(Revenue * Units)", "_Measure"),
|
("Weighted = SUM(Revenue * Units)", "_Measure"),
|
||||||
@@ -257,6 +257,9 @@ pub const FORMULA_POOL: [(&str, &str); 18] = [
|
|||||||
("EastRev = Revenue", "Region"),
|
("EastRev = Revenue", "Region"),
|
||||||
("Recip = Units ^ -1", "_Measure"),
|
("Recip = Units ^ -1", "_Measure"),
|
||||||
("Pa = A + Revenue", "_Measure"),
|
("Pa = A + Revenue", "_Measure"),
|
||||||
|
("NegZero = -0", "_Measure"),
|
||||||
|
("Flip = Units * -1", "_Measure"),
|
||||||
|
("Negate = -Cost", "_Measure"),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn formula_edit(index: usize) -> Edit {
|
pub fn formula_edit(index: usize) -> Edit {
|
||||||
@@ -372,23 +375,24 @@ pub fn any_formula_edit() -> impl Strategy<Value = Edit> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same edits told to a backend that evaluates in dependency order and to one that always
|
/// The same edits told to a backend that evaluates in dependency order and to one that runs
|
||||||
/// runs eager's passes: their models, formula cache included, must print alike.
|
/// eager's passes: models, cache and zero signs must print alike after every edit.
|
||||||
pub fn same_orders(model: &Model, none: &[String], edits: &[Edit]) -> Result<(), String> {
|
pub fn same_orders(model: &Model, none: &[String], edits: &[Edit]) -> Result<(), String> {
|
||||||
let side = |by| Incremental::new(model.clone(), none.to_vec()).with_evaluation(by);
|
let side = |by| Incremental::new(model.clone(), none.to_vec()).with_evaluation(by);
|
||||||
let (mut ordered, mut plain) = (side(Evaluation::Ordered), side(Evaluation::Plain));
|
let (mut ordered, mut plain) = (side(Evaluation::Ordered), side(Evaluation::Plain));
|
||||||
for edit in edits {
|
for (n, edit) in edits.iter().enumerate() {
|
||||||
ordered.apply(edit);
|
ordered.apply(edit);
|
||||||
plain.apply(edit);
|
plain.apply(edit);
|
||||||
|
let (want, got) = (
|
||||||
|
format!("{:?}", plain.model()),
|
||||||
|
format!("{:?}", ordered.model()),
|
||||||
|
);
|
||||||
|
if want != got {
|
||||||
|
let told = &edits[..=n];
|
||||||
|
return Err(format!("after {told:?}:\n plain {want}\n ordered {got}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let (want, got) = (
|
Ok(())
|
||||||
format!("{:?}", plain.model()),
|
|
||||||
format!("{:?}", ordered.model()),
|
|
||||||
);
|
|
||||||
match want == got {
|
|
||||||
true => Ok(()),
|
|
||||||
false => Err(format!("after {edits:?}:\n plain {want}\n ordered {got}")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An `Eager` rebuilt from scratch on every edit: maintaining one in place
|
/// An `Eager` rebuilt from scratch on every edit: maintaining one in place
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "zset-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
description = "A C ABI over zset (byte-string keys, i64 weights) for CFFI callers: cdylib, staticlib and rlib."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# Native only, and deliberately not in the wasm bundle: `zset` stays std-only. The
|
||||||
|
# library is named zset_ffi, so the artifacts are libzset_ffi.{so,dylib,a}.
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zset = { path = "../zset" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
libloading = "0.8"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* zset_ffi.h: the C ABI of crates/zset-ffi (libzset_ffi.{so,dylib,a}).
|
||||||
|
* Hand-written; tests/header.rs checks it against src/lib.rs.
|
||||||
|
* Reasoning: context/code/zset-ffi.md.
|
||||||
|
*
|
||||||
|
* Conventions
|
||||||
|
* - Every function but the frees, zs_abi_version and zs_last_error returns int32_t: 0 is
|
||||||
|
* success, anything else is a ZS_ERR_* code.
|
||||||
|
* Results come back through out-pointers, which are checked FIRST (a NULL out is
|
||||||
|
* ZS_ERR_NULL_ARGUMENT with no state changed) and written only on success.
|
||||||
|
* On failure zs_last_error() describes it (per thread, valid until the next failure).
|
||||||
|
* - Handles (ZsZSet, ZsCircuit, ZsRebuild) are opaque. Each is created by a function that
|
||||||
|
* takes ZsX **out and is released by exactly one zs_X_free. Freeing NULL is a no-op;
|
||||||
|
* freeing twice or using a freed handle is undefined.
|
||||||
|
* - Keys are byte strings: (pointer, length). The library copies them; the caller keeps
|
||||||
|
* ownership of every buffer it passes, and may reuse it after the call returns.
|
||||||
|
* A zero length accepts a NULL pointer.
|
||||||
|
* - The library never returns memory the caller must free, except handles.
|
||||||
|
* - Weights are int64_t. A weight that would leave int64_t is ZS_ERR_OVERFLOW, never a
|
||||||
|
* wrong value, and never an unwinding exception.
|
||||||
|
* - Not thread-safe. A ZsCircuit and every ZsRebuild made from it form ONE group (they share
|
||||||
|
* a reference count): keep the whole group on one thread at a time. Z-sets are separate
|
||||||
|
* handles. Streams, inputs and sinks are uint32_t ids local to their circuit; a sink id is
|
||||||
|
* never a stream id (they are refused as ZS_ERR_BAD_HANDLE), but an id from another circuit
|
||||||
|
* is not detected. A ZsRebuild is stepped only on the circuit that made it (else
|
||||||
|
* ZS_ERR_BAD_HANDLE), even if another circuit now lives at the old address.
|
||||||
|
* - Keys of 4 GiB or longer are refused with ZS_ERR_BAD_VALUE: the wire form has u32 lengths.
|
||||||
|
*/
|
||||||
|
#ifndef ZSET_FFI_H
|
||||||
|
#define ZSET_FFI_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define ZS_ABI_VERSION 1
|
||||||
|
|
||||||
|
#define ZS_OK 0
|
||||||
|
#define ZS_ERR_NULL_ARGUMENT 1
|
||||||
|
#define ZS_ERR_OVERFLOW 2
|
||||||
|
#define ZS_ERR_PANIC 3
|
||||||
|
#define ZS_ERR_BAD_HANDLE 4
|
||||||
|
#define ZS_ERR_BUFFER_TOO_SMALL 5
|
||||||
|
#define ZS_ERR_BAD_VALUE 6
|
||||||
|
#define ZS_ERR_POISONED 7
|
||||||
|
#define ZS_ERR_CALLBACK 8
|
||||||
|
#define ZS_ERR_INTERRUPTED 9
|
||||||
|
|
||||||
|
#define ZS_AGG_COUNT 0
|
||||||
|
#define ZS_AGG_SUM 1
|
||||||
|
#define ZS_AGG_MIN 2
|
||||||
|
#define ZS_AGG_MAX 3
|
||||||
|
|
||||||
|
typedef struct ZsZSet ZsZSet;
|
||||||
|
typedef struct ZsCircuit ZsCircuit;
|
||||||
|
typedef struct ZsRebuild ZsRebuild;
|
||||||
|
|
||||||
|
/* Callbacks run inside zs_circuit_step and zs_rebuild_step.
|
||||||
|
* - The function pointer and its ctx are kept by the circuit and must stay valid until
|
||||||
|
* zs_circuit_free. emit and emit_ctx are valid only during the callback that received them.
|
||||||
|
* - A callback emits images of a key by calling emit(emit_ctx, bytes, len); the bytes are
|
||||||
|
* copied during the call.
|
||||||
|
* - A callback reports failure by its return value: ZsFlatMapFn non-zero, ZsKeepFn and
|
||||||
|
* ZsExpiredFn negative. The step then fails with ZS_ERR_CALLBACK and the circuit is dead.
|
||||||
|
* - A callback MUST return normally: no longjmp, no C++ exception, no Lisp non-local exit
|
||||||
|
* through it (wrap the body in handler-case and return the failure value). If one does,
|
||||||
|
* the circuit is left marked mid-step and every later call on it is ZS_ERR_POISONED.
|
||||||
|
* - A callback that calls the library with its own circuit (or the rebuild job being
|
||||||
|
* stepped) gets ZS_ERR_POISONED and nothing changes. zs_circuit_free on a circuit that is
|
||||||
|
* mid-step, and zs_rebuild_free on a job being stepped, do nothing (the object is leaked
|
||||||
|
* rather than freed under the running frames); free after the call returns.
|
||||||
|
* A circuit whose step was left by a non-local exit stays mid-step, so its free leaks too. */
|
||||||
|
typedef void (*ZsEmit)(void *emit_ctx, const uint8_t *bytes, size_t len);
|
||||||
|
typedef int32_t (*ZsFlatMapFn)(void *ctx, const uint8_t *key, size_t len, ZsEmit emit, void *emit_ctx);
|
||||||
|
/* 1 keep, 0 drop, negative fail. */
|
||||||
|
typedef int32_t (*ZsKeepFn)(void *ctx, const uint8_t *key, size_t len);
|
||||||
|
/* 1 expired, 0 not yet, negative fail. */
|
||||||
|
typedef int32_t (*ZsExpiredFn)(void *ctx);
|
||||||
|
|
||||||
|
uint32_t zs_abi_version(void);
|
||||||
|
const char *zs_last_error(void);
|
||||||
|
|
||||||
|
/* Z-sets. add/sub/neg/scale/decode_add leave the set unchanged when they fail. add and sub
|
||||||
|
* accept the same handle as both arguments. */
|
||||||
|
int32_t zs_zset_new(ZsZSet **out);
|
||||||
|
void zs_zset_free(ZsZSet *z);
|
||||||
|
int32_t zs_zset_clone(const ZsZSet *z, ZsZSet **out);
|
||||||
|
int32_t zs_zset_insert(ZsZSet *z, const uint8_t *key, size_t len, int64_t weight);
|
||||||
|
int32_t zs_zset_weight(const ZsZSet *z, const uint8_t *key, size_t len, int64_t *out);
|
||||||
|
int32_t zs_zset_len(const ZsZSet *z, size_t *out);
|
||||||
|
int32_t zs_zset_add(ZsZSet *a, const ZsZSet *b);
|
||||||
|
int32_t zs_zset_sub(ZsZSet *a, const ZsZSet *b);
|
||||||
|
int32_t zs_zset_neg(ZsZSet *a);
|
||||||
|
int32_t zs_zset_scale(ZsZSet *a, int64_t n);
|
||||||
|
int32_t zs_zset_distinct(const ZsZSet *z, ZsZSet **out);
|
||||||
|
|
||||||
|
/* Encoding: entries ascending by key, each [u32 LE key length][key][i64 LE weight].
|
||||||
|
* *needed is set whenever z is valid; the bytes are written only if cap >= *needed, else
|
||||||
|
* ZS_ERR_BUFFER_TOO_SMALL (call again with a larger buffer). buf may be NULL only when
|
||||||
|
* *needed is zero or to probe with cap too small; otherwise ZS_ERR_NULL_ARGUMENT. */
|
||||||
|
int32_t zs_zset_encode(const ZsZSet *z, uint8_t *buf, size_t cap, size_t *needed);
|
||||||
|
int32_t zs_zset_decode_add(ZsZSet *z, const uint8_t *buf, size_t len);
|
||||||
|
|
||||||
|
/* Circuits. Every stream carries a Z-set of byte-string keys. */
|
||||||
|
int32_t zs_circuit_new(ZsCircuit **out);
|
||||||
|
void zs_circuit_free(ZsCircuit *c);
|
||||||
|
int32_t zs_circuit_input(ZsCircuit *c, uint32_t *out);
|
||||||
|
int32_t zs_circuit_output(ZsCircuit *c, uint32_t stream, uint32_t *out);
|
||||||
|
int32_t zs_circuit_delay(ZsCircuit *c, uint32_t stream, uint32_t *out);
|
||||||
|
int32_t zs_circuit_integrate(ZsCircuit *c, uint32_t stream, uint32_t *out);
|
||||||
|
int32_t zs_circuit_differentiate(ZsCircuit *c, uint32_t stream, uint32_t *out);
|
||||||
|
int32_t zs_circuit_distinct(ZsCircuit *c, uint32_t stream, uint32_t *out);
|
||||||
|
int32_t zs_circuit_plus(ZsCircuit *c, uint32_t a, uint32_t b, uint32_t *out);
|
||||||
|
int32_t zs_circuit_filter(ZsCircuit *c, uint32_t stream, ZsKeepFn keep, void *ctx, uint32_t *out);
|
||||||
|
int32_t zs_circuit_flat_map(ZsCircuit *c, uint32_t stream, ZsFlatMapFn f, void *ctx, uint32_t *out);
|
||||||
|
|
||||||
|
/* index_by: f emits exactly one group key g per element; the output element is
|
||||||
|
* pair(g, element), pair(k, v) = [u32 LE length of k][k][v]. */
|
||||||
|
int32_t zs_circuit_index_by(ZsCircuit *c, uint32_t stream, ZsFlatMapFn f, void *ctx, uint32_t *out);
|
||||||
|
|
||||||
|
/* join: both inputs carry pair(k, v); the output element is pair(k, pair(a, b)). */
|
||||||
|
int32_t zs_circuit_join(ZsCircuit *c, uint32_t a, uint32_t b, uint32_t *out);
|
||||||
|
|
||||||
|
/* aggregate: the input carries pair(k, v) with v an 8-byte little-endian int64 (any bytes
|
||||||
|
* for COUNT); the output is pair(k, 8-byte little-endian int64). COUNT is the total weight,
|
||||||
|
* SUM is the weighted sum (a non-empty group always has a row, even at zero), MIN and MAX read the values of positive weight. */
|
||||||
|
int32_t zs_circuit_aggregate(ZsCircuit *c, uint32_t stream, int32_t kind, uint32_t *out);
|
||||||
|
|
||||||
|
int32_t zs_circuit_push(ZsCircuit *c, uint32_t input, const ZsZSet *z);
|
||||||
|
|
||||||
|
/* One clock tick. After a failure in a step the circuit is poisoned: every later call that
|
||||||
|
* uses it returns ZS_ERR_POISONED, except the free. */
|
||||||
|
int32_t zs_circuit_step(ZsCircuit *c);
|
||||||
|
int32_t zs_circuit_take(ZsCircuit *c, uint32_t sink, ZsZSet **out);
|
||||||
|
|
||||||
|
/* Resumable snapshot load: chunk keys per piece (0 reads as 1). The whole load is one
|
||||||
|
* logical tick, and done is 1 as soon as the last chunk ran. While a job is pending it
|
||||||
|
* owns the clock: a zs_circuit_step in between makes the next zs_rebuild_step return
|
||||||
|
* ZS_ERR_INTERRUPTED (the circuit stays usable; drop the job). */
|
||||||
|
int32_t zs_rebuild_new(ZsCircuit *c, uint32_t input, const ZsZSet *snapshot, size_t chunk, ZsRebuild **out);
|
||||||
|
int32_t zs_rebuild_step(ZsRebuild *job, ZsCircuit *c, ZsExpiredFn expired, void *ctx, int32_t *done);
|
||||||
|
void zs_rebuild_free(ZsRebuild *job);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
//! The byte-keyed circuit behind the C ABI, as plain `Result`-returning Rust; panics from
|
||||||
|
//! `zset` are caught and classified here. See `context/code/zset-ffi.md`.
|
||||||
|
|
||||||
|
use std::any::Any;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use zset::{Circuit, Input, Output, Progress, Rebuild, Stream, ZSet};
|
||||||
|
|
||||||
|
pub type Bytes = Vec<u8>;
|
||||||
|
pub type BZ = ZSet<Bytes>;
|
||||||
|
|
||||||
|
/// Declares the error codes once: the enum, the list of all of them and the header's name.
|
||||||
|
macro_rules! codes {
|
||||||
|
($($variant:ident = $n:literal => $name:literal,)*) => {
|
||||||
|
/// The error codes of the ABI; 0 is success and is not a variant.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(i32)]
|
||||||
|
pub enum Code { $($variant = $n,)* }
|
||||||
|
|
||||||
|
impl Code {
|
||||||
|
pub const ALL: &[Code] = &[$(Code::$variant,)*];
|
||||||
|
|
||||||
|
/// The `#define` this code has in the header.
|
||||||
|
pub fn header_name(self) -> &'static str {
|
||||||
|
match self { $(Code::$variant => $name,)* }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
codes! {
|
||||||
|
NullArgument = 1 => "ZS_ERR_NULL_ARGUMENT",
|
||||||
|
Overflow = 2 => "ZS_ERR_OVERFLOW",
|
||||||
|
Panic = 3 => "ZS_ERR_PANIC",
|
||||||
|
BadHandle = 4 => "ZS_ERR_BAD_HANDLE",
|
||||||
|
BufferTooSmall = 5 => "ZS_ERR_BUFFER_TOO_SMALL",
|
||||||
|
BadValue = 6 => "ZS_ERR_BAD_VALUE",
|
||||||
|
Poisoned = 7 => "ZS_ERR_POISONED",
|
||||||
|
Callback = 8 => "ZS_ERR_CALLBACK",
|
||||||
|
Interrupted = 9 => "ZS_ERR_INTERRUPTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Fail {
|
||||||
|
pub code: Code,
|
||||||
|
pub msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fail {
|
||||||
|
pub fn new(code: Code, msg: &str) -> Fail {
|
||||||
|
Fail {
|
||||||
|
code,
|
||||||
|
msg: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Res<T> = Result<T, Fail>;
|
||||||
|
|
||||||
|
const OVERFLOW_PREFIX: &str = "zset: weight overflow";
|
||||||
|
|
||||||
|
/// Turns a caught panic into an error: weight overflow is `Overflow`, anything else `Panic`.
|
||||||
|
pub fn classify(payload: Box<dyn Any + Send>) -> Fail {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "panic with a non-string payload".to_string());
|
||||||
|
let code = if msg.starts_with(OVERFLOW_PREFIX) {
|
||||||
|
Code::Overflow
|
||||||
|
} else {
|
||||||
|
Code::Panic
|
||||||
|
};
|
||||||
|
Fail { code, msg }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A length as the wire's `u32`; a longer one is refused rather than truncated.
|
||||||
|
pub fn len32(n: usize) -> Res<u32> {
|
||||||
|
u32::try_from(n).map_err(|_| Fail::new(Code::BadValue, "a key is 4 GiB or longer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(key, value)` as one byte string: a little-endian `u32` key length, the key, the value.
|
||||||
|
pub fn pair(k: &[u8], v: &[u8]) -> Res<Bytes> {
|
||||||
|
let mut out = Vec::with_capacity(4 + k.len() + v.len());
|
||||||
|
out.extend_from_slice(&len32(k.len())?.to_le_bytes());
|
||||||
|
out.extend_from_slice(k);
|
||||||
|
out.extend_from_slice(v);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unpair(b: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||||
|
let n = u32::from_le_bytes(b.get(..4)?.try_into().ok()?) as usize;
|
||||||
|
let rest = &b[4..];
|
||||||
|
(rest.len() >= n).then(|| rest.split_at(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries as `[u32 LE key length][key][i64 LE weight]`, ascending by key.
|
||||||
|
pub fn encode(z: &BZ) -> Res<Bytes> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (k, w) in z.iter() {
|
||||||
|
out.extend_from_slice(&len32(k.len())?.to_le_bytes());
|
||||||
|
out.extend_from_slice(k);
|
||||||
|
out.extend_from_slice(&w.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of `encode`; equal keys sum, and an overflow panics like any addition.
|
||||||
|
pub fn decode(mut b: &[u8]) -> Res<BZ> {
|
||||||
|
let bad = || Fail::new(Code::BadValue, "malformed zset encoding");
|
||||||
|
let mut z = ZSet::new();
|
||||||
|
while !b.is_empty() {
|
||||||
|
let n = u32::from_le_bytes(b.get(..4).ok_or_else(bad)?.try_into().unwrap()) as usize;
|
||||||
|
let key = b.get(4..4 + n).ok_or_else(bad)?;
|
||||||
|
let w = b.get(4 + n..12 + n).ok_or_else(bad)?;
|
||||||
|
z.insert(key.to_vec(), i64::from_le_bytes(w.try_into().unwrap()));
|
||||||
|
b = &b[12 + n..];
|
||||||
|
}
|
||||||
|
Ok(z)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Emit = extern "C" fn(*mut c_void, *const u8, usize);
|
||||||
|
/// Returns 0, or non-zero to fail the step.
|
||||||
|
pub type FlatMapFn = extern "C" fn(*mut c_void, *const u8, usize, Emit, *mut c_void) -> i32;
|
||||||
|
/// Returns 0 (drop), 1 (keep), or negative to fail the step.
|
||||||
|
pub type KeepFn = extern "C" fn(*mut c_void, *const u8, usize) -> i32;
|
||||||
|
/// Returns 0 (still time), 1 (expired), or negative to fail the step.
|
||||||
|
pub type ExpiredFn = extern "C" fn(*mut c_void) -> i32;
|
||||||
|
|
||||||
|
/// A caller's context pointer; the circuit never dereferences it.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Ctx(*mut c_void);
|
||||||
|
|
||||||
|
type Fault = Rc<Cell<Option<(Code, &'static str)>>>;
|
||||||
|
|
||||||
|
/// Records a fault unless one is already recorded: the first one is the cause.
|
||||||
|
fn note(fault: &Fault, code: Code, msg: &'static str) {
|
||||||
|
if let Some(first) = fault.take() {
|
||||||
|
fault.set(Some(first));
|
||||||
|
} else {
|
||||||
|
fault.set(Some((code, msg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn collect(ctx: *mut c_void, p: *const u8, n: usize) {
|
||||||
|
// SAFETY: `ctx` is the `Vec` `images` passes below, alive for the callback's duration.
|
||||||
|
let out = unsafe { &mut *(ctx as *mut Vec<Bytes>) };
|
||||||
|
let bytes = if n == 0 || p.is_null() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
// SAFETY: the caller of `emit` promises `n` readable bytes at `p`.
|
||||||
|
unsafe { std::slice::from_raw_parts(p, n) }.to_vec()
|
||||||
|
};
|
||||||
|
out.push(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The images a callback emits for `key`; a failing callback records a fault and emits none.
|
||||||
|
fn images(f: FlatMapFn, ctx: Ctx, key: &Bytes, fault: &Fault) -> Vec<Bytes> {
|
||||||
|
let mut out: Vec<Bytes> = Vec::new();
|
||||||
|
let rc = f(
|
||||||
|
ctx.0,
|
||||||
|
key.as_ptr(),
|
||||||
|
key.len(),
|
||||||
|
collect,
|
||||||
|
&mut out as *mut Vec<Bytes> as *mut c_void,
|
||||||
|
);
|
||||||
|
if rc != 0 {
|
||||||
|
note(fault, Code::Callback, "a callback reported failure");
|
||||||
|
out.clear();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Agg {
|
||||||
|
Count,
|
||||||
|
Sum,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Agg {
|
||||||
|
pub fn from_code(n: i32) -> Res<Agg> {
|
||||||
|
Ok(match n {
|
||||||
|
0 => Agg::Count,
|
||||||
|
1 => Agg::Sum,
|
||||||
|
2 => Agg::Min,
|
||||||
|
3 => Agg::Max,
|
||||||
|
_ => return Err(Fail::new(Code::BadValue, "unknown aggregate kind")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked(v: Option<i64>) -> i64 {
|
||||||
|
v.unwrap_or_else(|| panic!("{OVERFLOW_PREFIX} in aggregate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sink ids carry this bit; a stream id is an index far below it, so `streams.get` refuses a
|
||||||
|
/// sink id and `take` refuses a stream id.
|
||||||
|
const SINK_BIT: u32 = 1 << 31;
|
||||||
|
|
||||||
|
/// The circuit, its streams by id, and the first fault a closure recorded.
|
||||||
|
pub struct Engine {
|
||||||
|
circuit: Circuit,
|
||||||
|
streams: Vec<Stream<BZ>>,
|
||||||
|
inputs: Vec<Option<Input<Bytes>>>,
|
||||||
|
sinks: Vec<Output<Bytes>>,
|
||||||
|
fault: Fault,
|
||||||
|
poisoned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Engine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn new() -> Engine {
|
||||||
|
Engine {
|
||||||
|
circuit: Circuit::new(),
|
||||||
|
streams: Vec::new(),
|
||||||
|
inputs: Vec::new(),
|
||||||
|
sinks: Vec::new(),
|
||||||
|
fault: Rc::default(),
|
||||||
|
poisoned: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, id: u32) -> Res<Stream<BZ>> {
|
||||||
|
self.streams
|
||||||
|
.get(id as usize)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| Fail::new(Code::BadHandle, "no such stream"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self, s: Stream<BZ>, input: Option<Input<Bytes>>) -> u32 {
|
||||||
|
self.streams.push(s);
|
||||||
|
self.inputs.push(input);
|
||||||
|
(self.streams.len() - 1) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alive(&self) -> Res<()> {
|
||||||
|
if self.poisoned {
|
||||||
|
return Err(Fail::new(
|
||||||
|
Code::Poisoned,
|
||||||
|
"the circuit failed in a step and cannot be used again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input(&mut self) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let (i, s) = self.circuit.input::<Bytes>();
|
||||||
|
Ok(self.register(s, Some(i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output(&mut self, s: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
self.sinks.push(self.circuit.output(&s));
|
||||||
|
Ok((self.sinks.len() - 1) as u32 | SINK_BIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delay(&mut self, s: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let out = self.circuit.delay(&s);
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn integrate(&mut self, s: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let out = self.circuit.integrate(&s);
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn differentiate(&mut self, s: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let out = self.circuit.differentiate(&s);
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn distinct(&mut self, s: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let out = self.circuit.distinct(&s);
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plus(&mut self, a: u32, b: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let (a, b) = (self.stream(a)?, self.stream(b)?);
|
||||||
|
let out = self.circuit.plus(&a, &b);
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter(&mut self, s: u32, keep: KeepFn, ctx: *mut c_void) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let (ctx, fault) = (Ctx(ctx), Rc::clone(&self.fault));
|
||||||
|
let out = self.circuit.filter(&s, move |k: &Bytes| {
|
||||||
|
match keep(ctx.0, k.as_ptr(), k.len()) {
|
||||||
|
0 => false,
|
||||||
|
n if n > 0 => true,
|
||||||
|
_ => {
|
||||||
|
note(&fault, Code::Callback, "a callback reported failure");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flat_map(&mut self, s: u32, f: FlatMapFn, ctx: *mut c_void) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let (ctx, fault) = (Ctx(ctx), Rc::clone(&self.fault));
|
||||||
|
let out = self
|
||||||
|
.circuit
|
||||||
|
.flat_map(&s, move |k: &Bytes| images(f, ctx, k, &fault));
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each element becomes `pair(g, element)` where the callback emits exactly one `g`.
|
||||||
|
pub fn index_by(&mut self, s: u32, f: FlatMapFn, ctx: *mut c_void) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let (ctx, fault) = (Ctx(ctx), Rc::clone(&self.fault));
|
||||||
|
let out = self.circuit.flat_map(&s, move |k: &Bytes| {
|
||||||
|
let mut gs = images(f, ctx, k, &fault);
|
||||||
|
if gs.len() != 1 {
|
||||||
|
note(
|
||||||
|
&fault,
|
||||||
|
Code::BadValue,
|
||||||
|
"index_by: the key callback must emit exactly one key",
|
||||||
|
);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
match pair(&gs.remove(0), k) {
|
||||||
|
Ok(p) => vec![p],
|
||||||
|
Err(_) => {
|
||||||
|
note(&fault, Code::BadValue, "a key is 4 GiB or longer");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits `pair(k, v)` elements; a malformed one records a fault and is dropped.
|
||||||
|
fn keyed(&mut self, s: &Stream<BZ>) -> Stream<ZSet<(Bytes, Bytes)>> {
|
||||||
|
let fault = Rc::clone(&self.fault);
|
||||||
|
self.circuit.flat_map(s, move |b: &Bytes| match unpair(b) {
|
||||||
|
Some((k, v)) => vec![(k.to_vec(), v.to_vec())],
|
||||||
|
None => {
|
||||||
|
note(
|
||||||
|
&fault,
|
||||||
|
Code::BadValue,
|
||||||
|
"a keyed stream carried an element that is not a pair",
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins two streams of `pair(k, v)`; the output is `pair(k, pair(a, b))`.
|
||||||
|
pub fn join(&mut self, a: u32, b: u32) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let (a, b) = (self.stream(a)?, self.stream(b)?);
|
||||||
|
let (ka, kb) = (self.keyed(&a), self.keyed(&b));
|
||||||
|
let fault = Rc::clone(&self.fault);
|
||||||
|
let j = self
|
||||||
|
.circuit
|
||||||
|
.join_indexed(&ka, &kb, move |k: &Bytes, x: &Bytes, y: &Bytes| {
|
||||||
|
pair(x, y).and_then(|xy| pair(k, &xy)).unwrap_or_else(|_| {
|
||||||
|
note(&fault, Code::BadValue, "a key is 4 GiB or longer");
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Ok(self.register(j, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Groups a stream of `pair(k, v)` (v is 8 bytes, little-endian `i64`, except for
|
||||||
|
/// `Count`); the output is `pair(k, 8 bytes)`. A non-empty group always has a row.
|
||||||
|
pub fn aggregate(&mut self, s: u32, kind: Agg) -> Res<u32> {
|
||||||
|
self.alive()?;
|
||||||
|
let s = self.stream(s)?;
|
||||||
|
let keyed = self.keyed(&s);
|
||||||
|
let fault = Rc::clone(&self.fault);
|
||||||
|
let folded = self
|
||||||
|
.circuit
|
||||||
|
.aggregate(&keyed, move |_, vals: &[(Bytes, i64)]| {
|
||||||
|
if kind == Agg::Count {
|
||||||
|
let n = vals.iter().try_fold(0i64, |a, (_, w)| a.checked_add(*w));
|
||||||
|
return Some(checked(n));
|
||||||
|
}
|
||||||
|
let mut nums = Vec::with_capacity(vals.len());
|
||||||
|
for (v, w) in vals {
|
||||||
|
match <[u8; 8]>::try_from(v.as_slice()) {
|
||||||
|
Ok(b) => nums.push((i64::from_le_bytes(b), *w)),
|
||||||
|
Err(_) => {
|
||||||
|
note(&fault, Code::BadValue, "an aggregated value is not 8 bytes");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
Agg::Sum => {
|
||||||
|
let t = nums.iter().try_fold(0i64, |a, (v, w)| {
|
||||||
|
v.checked_mul(*w).and_then(|p| a.checked_add(p))
|
||||||
|
});
|
||||||
|
Some(checked(t))
|
||||||
|
}
|
||||||
|
Agg::Min => nums.iter().filter(|(_, w)| *w > 0).map(|(v, _)| *v).min(),
|
||||||
|
Agg::Max | Agg::Count => {
|
||||||
|
nums.iter().filter(|(_, w)| *w > 0).map(|(v, _)| *v).max()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let fault = Rc::clone(&self.fault);
|
||||||
|
let out = self
|
||||||
|
.circuit
|
||||||
|
.flat_map(&folded, move |(k, r): &(Bytes, i64)| {
|
||||||
|
match pair(k, &r.to_le_bytes()) {
|
||||||
|
Ok(p) => vec![p],
|
||||||
|
Err(_) => {
|
||||||
|
note(&fault, Code::BadValue, "a key is 4 GiB or longer");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(self.register(out, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_of(&self, id: u32) -> Res<Input<Bytes>> {
|
||||||
|
self.inputs
|
||||||
|
.get(id as usize)
|
||||||
|
.and_then(|i| i.clone())
|
||||||
|
.ok_or_else(|| Fail::new(Code::BadHandle, "that stream is not an input"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, input: u32, z: &BZ) -> Res<()> {
|
||||||
|
self.alive()?;
|
||||||
|
self.input_of(input)?.push(z.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` on the circuit; a panic or a recorded fault poisons the engine.
|
||||||
|
fn guarded<T>(&mut self, f: impl FnOnce(&mut Circuit) -> T) -> Res<T> {
|
||||||
|
self.alive()?;
|
||||||
|
self.fault.set(None);
|
||||||
|
match catch_unwind(AssertUnwindSafe(|| f(&mut self.circuit))) {
|
||||||
|
Err(payload) => {
|
||||||
|
self.poisoned = true;
|
||||||
|
Err(classify(payload))
|
||||||
|
}
|
||||||
|
Ok(v) => match self.fault.take() {
|
||||||
|
Some((code, msg)) => {
|
||||||
|
self.poisoned = true;
|
||||||
|
Err(Fail::new(code, msg))
|
||||||
|
}
|
||||||
|
None => Ok(v),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step(&mut self) -> Res<()> {
|
||||||
|
self.guarded(|c| c.step())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take(&mut self, sink: u32) -> Res<BZ> {
|
||||||
|
self.alive()?;
|
||||||
|
self.sinks
|
||||||
|
.get((sink & !SINK_BIT) as usize)
|
||||||
|
.filter(|_| sink & SINK_BIT != 0)
|
||||||
|
.map(|s| s.take())
|
||||||
|
.ok_or_else(|| Fail::new(Code::BadHandle, "no such output"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_rebuild(&mut self, input: u32, snapshot: BZ, chunk: usize) -> Res<Rebuild<Bytes>> {
|
||||||
|
self.alive()?;
|
||||||
|
Ok(Rebuild::new(self.input_of(input)?, snapshot, chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the snapshot is consumed. With no `expired`, runs to completion.
|
||||||
|
pub fn rebuild_step(
|
||||||
|
&mut self,
|
||||||
|
job: &mut Rebuild<Bytes>,
|
||||||
|
expired: Option<(ExpiredFn, *mut c_void)>,
|
||||||
|
) -> Res<bool> {
|
||||||
|
self.alive()?;
|
||||||
|
if job.interrupted(&self.circuit) {
|
||||||
|
return Err(Fail::new(
|
||||||
|
Code::Interrupted,
|
||||||
|
"the rebuild was interrupted by a circuit step before it finished",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fault = Rc::clone(&self.fault);
|
||||||
|
let progress = self.guarded(|c| {
|
||||||
|
job.step(c, || match expired {
|
||||||
|
None => false,
|
||||||
|
Some((f, ctx)) => match f(ctx) {
|
||||||
|
0 => false,
|
||||||
|
n if n > 0 => true,
|
||||||
|
_ => {
|
||||||
|
note(&fault, Code::Callback, "a callback reported failure");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
Ok(progress == Progress::Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
//! The C ABI over `zset`: byte keys, `i64` weights, opaque handles, `i32` codes.
|
||||||
|
//! Ownership and errors: `context/code/zset-ffi.md` and `include/zset_ffi.h`.
|
||||||
|
|
||||||
|
pub mod engine;
|
||||||
|
|
||||||
|
use engine::{
|
||||||
|
Agg, BZ, Bytes, Code, Engine, ExpiredFn, Fail, FlatMapFn, KeepFn, classify, decode, encode,
|
||||||
|
};
|
||||||
|
use std::cell::{Cell, RefCell, UnsafeCell};
|
||||||
|
use std::ffi::{CString, c_char, c_void};
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use zset::{Rebuild, ZSet};
|
||||||
|
|
||||||
|
pub const ZS_ABI_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// An opaque Z-set of byte-string keys.
|
||||||
|
pub struct ZsZSet(BZ);
|
||||||
|
|
||||||
|
/// An opaque circuit. `in_step` is set while the engine may be running callbacks; it is
|
||||||
|
/// still set if a caller's non-local exit skipped our frames, and then the circuit is dead.
|
||||||
|
pub struct ZsCircuit {
|
||||||
|
engine: UnsafeCell<Engine>,
|
||||||
|
in_step: Cell<bool>,
|
||||||
|
id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
static NEXT_CIRCUIT_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// An opaque resumable snapshot load, bound to the circuit that made it.
|
||||||
|
pub struct ZsRebuild {
|
||||||
|
job: Rebuild<Bytes>,
|
||||||
|
owner: u64,
|
||||||
|
busy: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_ERROR: RefCell<CString> = RefCell::new(CString::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember(msg: &str) {
|
||||||
|
let clean: String = msg.chars().filter(|c| *c != '\0').collect();
|
||||||
|
LAST_ERROR.with(|e| *e.borrow_mut() = CString::new(clean).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs an entry point body: no panic crosses the ABI, and every failure is a code.
|
||||||
|
fn guard(f: impl FnOnce() -> Result<(), Fail>) -> i32 {
|
||||||
|
let fail = match catch_unwind(AssertUnwindSafe(f)) {
|
||||||
|
Ok(Ok(())) => return 0,
|
||||||
|
Ok(Err(fail)) => fail,
|
||||||
|
Err(payload) => classify(payload),
|
||||||
|
};
|
||||||
|
remember(&fail.msg);
|
||||||
|
fail.code as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn null() -> Fail {
|
||||||
|
Fail::new(Code::NullArgument, "a required argument was null")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `p` is null or points to a live `T` that nothing else is using.
|
||||||
|
unsafe fn as_mut<'a, T>(p: *mut T) -> Result<&'a mut T, Fail> {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe { p.as_mut() }.ok_or_else(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `p` is null or points to a live `T` that nothing is mutating.
|
||||||
|
unsafe fn as_ref<'a, T>(p: *const T) -> Result<&'a T, Fail> {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe { p.as_ref() }.ok_or_else(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `p` is null (with `n` zero) or has `n` readable bytes.
|
||||||
|
unsafe fn bytes<'a>(p: *const u8, n: usize) -> Result<&'a [u8], Fail> {
|
||||||
|
match (p.is_null(), n) {
|
||||||
|
(_, 0) => Ok(&[]),
|
||||||
|
(true, _) => Err(null()),
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
(false, _) => Ok(unsafe { std::slice::from_raw_parts(p, n) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `out` is null or valid for a write of `T`.
|
||||||
|
unsafe fn with_out<T>(out: *mut T, f: impl FnOnce() -> Result<T, Fail>) -> Result<(), Fail> {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let out = unsafe { as_mut(out)? };
|
||||||
|
*out = f()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is null or a live circuit handle. Callbacks may call back in: this only reads `in_step`.
|
||||||
|
unsafe fn circuit<'a>(c: *const ZsCircuit) -> Result<&'a ZsCircuit, Fail> {
|
||||||
|
// SAFETY: the caller's contract; only a shared reference is made.
|
||||||
|
let c = unsafe { as_ref(c)? };
|
||||||
|
if c.in_step.get() {
|
||||||
|
return Err(Fail::new(
|
||||||
|
Code::Poisoned,
|
||||||
|
"the circuit is mid-step: a callback re-entered it, or left it without returning",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZsCircuit {
|
||||||
|
/// Only `circuit` hands out `&ZsCircuit` while no step is running, and the handle is used
|
||||||
|
/// from one thread, so nothing else holds the engine.
|
||||||
|
#[allow(clippy::mut_from_ref)]
|
||||||
|
fn engine(&self) -> &mut Engine {
|
||||||
|
// SAFETY: see above.
|
||||||
|
unsafe { &mut *self.engine.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` with `in_step` set; a frame skipped by a caller's non-local exit leaves it set.
|
||||||
|
fn stepping<T>(&self, f: impl FnOnce(&mut Engine) -> T) -> T {
|
||||||
|
self.in_step.set(true);
|
||||||
|
let v = f(self.engine());
|
||||||
|
self.in_step.set(false);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `p` is null, or came from this library's constructor and is freed once.
|
||||||
|
unsafe fn release<T>(p: *mut T) {
|
||||||
|
if !p.is_null() {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
drop(unsafe { Box::from_raw(p) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boxed<T>(v: T) -> Result<*mut T, Fail> {
|
||||||
|
Ok(Box::into_raw(Box::new(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn zs_abi_version() -> u32 {
|
||||||
|
ZS_ABI_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message of the last failure on this thread; never null, valid until the next failure.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn zs_last_error() -> *const c_char {
|
||||||
|
LAST_ERROR.with(|e| e.borrow().as_ptr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `out` is null or valid for a write.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_new(out: *mut *mut ZsZSet) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || boxed(ZsZSet(ZSet::new()))) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is null or a live Z-set handle, freed once.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_free(z: *mut ZsZSet) {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe { release(z) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `out` is null or valid for a write.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_clone(z: *const ZsZSet, out: *mut *mut ZsZSet) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || boxed(ZsZSet(as_ref(z)?.0.clone()))) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `key` has `len` readable bytes.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_insert(
|
||||||
|
z: *mut ZsZSet,
|
||||||
|
key: *const u8,
|
||||||
|
len: usize,
|
||||||
|
weight: i64,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let (z, key) = unsafe { (as_mut(z)?, bytes(key, len)?) };
|
||||||
|
z.0.insert(key.to_vec(), weight);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `key` has `len` readable bytes; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_weight(
|
||||||
|
z: *const ZsZSet,
|
||||||
|
key: *const u8,
|
||||||
|
len: usize,
|
||||||
|
out: *mut i64,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe { with_out(out, || Ok(as_ref(z)?.0.weight(&bytes(key, len)?.to_vec()))) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_len(z: *const ZsZSet, out: *mut usize) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || Ok(as_ref(z)?.0.len())) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Both are live handles or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_add(a: *mut ZsZSet, b: *const ZsZSet) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract; the shared borrows end before the mutable one.
|
||||||
|
let sum = unsafe { &as_ref(a)?.0 + &as_ref(b)?.0 };
|
||||||
|
unsafe { as_mut(a)? }.0 = sum;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Both are live handles or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_sub(a: *mut ZsZSet, b: *const ZsZSet) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: as `zs_zset_add`.
|
||||||
|
let diff = unsafe { &as_ref(a)?.0 - &as_ref(b)?.0 };
|
||||||
|
unsafe { as_mut(a)? }.0 = diff;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `a` is a live handle or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_neg(a: *mut ZsZSet) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let a = unsafe { as_mut(a)? };
|
||||||
|
a.0 = -&a.0;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `a` is a live handle or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_scale(a: *mut ZsZSet, n: i64) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let a = unsafe { as_mut(a)? };
|
||||||
|
a.0 = a.0.scale(n);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_distinct(z: *const ZsZSet, out: *mut *mut ZsZSet) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || boxed(ZsZSet(as_ref(z)?.0.distinct()))) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `buf` is null or has `cap` writable bytes; `needed` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_encode(
|
||||||
|
z: *const ZsZSet,
|
||||||
|
buf: *mut u8,
|
||||||
|
cap: usize,
|
||||||
|
needed: *mut usize,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let (z, needed) = unsafe { (as_ref(z)?, as_mut(needed)?) };
|
||||||
|
let enc = encode(&z.0)?;
|
||||||
|
*needed = enc.len();
|
||||||
|
if enc.len() > cap {
|
||||||
|
return Err(Fail::new(
|
||||||
|
Code::BufferTooSmall,
|
||||||
|
"the buffer is smaller than *needed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !enc.is_empty() {
|
||||||
|
if buf.is_null() {
|
||||||
|
return Err(null());
|
||||||
|
}
|
||||||
|
// SAFETY: `buf` is non-null and has `cap >= enc.len()` writable bytes.
|
||||||
|
unsafe { std::ptr::copy_nonoverlapping(enc.as_ptr(), buf, enc.len()) };
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `z` is a live handle or null; `buf` has `len` readable bytes.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_zset_decode_add(z: *mut ZsZSet, buf: *const u8, len: usize) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let (zr, buf) = unsafe { (as_ref(z)?, bytes(buf, len)?) };
|
||||||
|
let sum = &zr.0 + &decode(buf)?;
|
||||||
|
unsafe { as_mut(z)? }.0 = sum;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_new(out: *mut *mut ZsCircuit) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
boxed(ZsCircuit {
|
||||||
|
engine: UnsafeCell::new(Engine::new()),
|
||||||
|
in_step: Cell::new(false),
|
||||||
|
id: NEXT_CIRCUIT_ID.fetch_add(1, Ordering::Relaxed),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is null, or a live circuit freed once and not mid-step.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_free(c: *mut ZsCircuit) {
|
||||||
|
// SAFETY: the caller's contract; a mid-step circuit is leaked, never freed under its frames.
|
||||||
|
if !c.is_null() && unsafe { &*c }.in_step.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsafe { release(c) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a live circuit or null; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_input(c: *mut ZsCircuit, out: *mut u32) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || circuit(c)?.engine().input()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_output(c: *mut ZsCircuit, stream: u32, out: *mut u32) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || circuit(c)?.engine().output(stream)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! unary_op {
|
||||||
|
($name:ident, $method:ident) => {
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn $name(c: *mut ZsCircuit, stream: u32, out: *mut u32) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || circuit(c)?.engine().$method(stream)) })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unary_op!(zs_circuit_delay, delay);
|
||||||
|
unary_op!(zs_circuit_integrate, integrate);
|
||||||
|
unary_op!(zs_circuit_differentiate, differentiate);
|
||||||
|
unary_op!(zs_circuit_distinct, distinct);
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_plus(c: *mut ZsCircuit, a: u32, b: u32, out: *mut u32) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || circuit(c)?.engine().plus(a, b)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_filter(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
stream: u32,
|
||||||
|
keep: Option<KeepFn>,
|
||||||
|
ctx: *mut c_void,
|
||||||
|
out: *mut u32,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
circuit(c)?
|
||||||
|
.engine()
|
||||||
|
.filter(stream, keep.ok_or_else(null)?, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_flat_map(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
stream: u32,
|
||||||
|
f: Option<FlatMapFn>,
|
||||||
|
ctx: *mut c_void,
|
||||||
|
out: *mut u32,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
circuit(c)?
|
||||||
|
.engine()
|
||||||
|
.flat_map(stream, f.ok_or_else(null)?, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_index_by(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
stream: u32,
|
||||||
|
f: Option<FlatMapFn>,
|
||||||
|
ctx: *mut c_void,
|
||||||
|
out: *mut u32,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
circuit(c)?
|
||||||
|
.engine()
|
||||||
|
.index_by(stream, f.ok_or_else(null)?, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_join(c: *mut ZsCircuit, a: u32, b: u32, out: *mut u32) -> i32 {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
guard(|| unsafe { with_out(out, || circuit(c)?.engine().join(a, b)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// As `zs_circuit_input`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_aggregate(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
stream: u32,
|
||||||
|
kind: i32,
|
||||||
|
out: *mut u32,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
circuit(c)?
|
||||||
|
.engine()
|
||||||
|
.aggregate(stream, Agg::from_code(kind)?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` and `z` are live handles or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_push(c: *mut ZsCircuit, input: u32, z: *const ZsZSet) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let (c, z) = unsafe { (circuit(c)?, as_ref(z)?) };
|
||||||
|
c.engine().push(input, &z.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a live circuit or null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_step(c: *mut ZsCircuit) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
let c = unsafe { circuit(c)? };
|
||||||
|
c.stepping(|e| e.step())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a live circuit or null; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_circuit_take(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
sink: u32,
|
||||||
|
out: *mut *mut ZsZSet,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe { with_out(out, || boxed(ZsZSet(circuit(c)?.engine().take(sink)?))) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `c` and `snapshot` are live handles or null; `out` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_rebuild_new(
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
input: u32,
|
||||||
|
snapshot: *const ZsZSet,
|
||||||
|
chunk: usize,
|
||||||
|
out: *mut *mut ZsRebuild,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(out, || {
|
||||||
|
let (cr, snap) = (circuit(c)?, as_ref(snapshot)?);
|
||||||
|
let job = cr.engine().start_rebuild(input, snap.0.clone(), chunk)?;
|
||||||
|
boxed(ZsRebuild {
|
||||||
|
job,
|
||||||
|
owner: cr.id,
|
||||||
|
busy: Cell::new(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `job` and `c` are live handles or null; `done` is null or writable.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_rebuild_step(
|
||||||
|
job: *mut ZsRebuild,
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
expired: Option<ExpiredFn>,
|
||||||
|
ctx: *mut c_void,
|
||||||
|
done: *mut i32,
|
||||||
|
) -> i32 {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: the caller's contract.
|
||||||
|
unsafe {
|
||||||
|
with_out(done, || {
|
||||||
|
let cr = circuit(c)?;
|
||||||
|
if job.is_null() {
|
||||||
|
return Err(null());
|
||||||
|
}
|
||||||
|
// Field places only, never a `&mut ZsRebuild`: a callback reads `busy` through
|
||||||
|
// `zs_rebuild_free` while `job.job` is mutably borrowed here.
|
||||||
|
if (*job).owner != cr.id {
|
||||||
|
return Err(Fail::new(
|
||||||
|
Code::BadHandle,
|
||||||
|
"the rebuild belongs to another circuit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(*job).busy.set(true);
|
||||||
|
let finished =
|
||||||
|
cr.stepping(|e| e.rebuild_step(&mut (*job).job, expired.map(|f| (f, ctx))));
|
||||||
|
(*job).busy.set(false);
|
||||||
|
let finished = finished?;
|
||||||
|
Ok(i32::from(finished))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `job` is null, or a live job freed once.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn zs_rebuild_free(job: *mut ZsRebuild) {
|
||||||
|
// SAFETY: the caller's contract; a job being stepped is leaked, not freed under its frame.
|
||||||
|
if !job.is_null() && unsafe { (*job).busy.get() } {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsafe { release(job) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_circuit_left_mid_step_by_a_skipped_frame_answers_poisoned_to_everything() {
|
||||||
|
let mut c = std::ptr::null_mut();
|
||||||
|
assert_eq!(unsafe { zs_circuit_new(&mut c) }, 0);
|
||||||
|
let mut id = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_input(c, &mut id) }, 0);
|
||||||
|
// What a non-local exit out of a callback leaves behind: the flag never cleared.
|
||||||
|
unsafe { &*c }.in_step.set(true);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c) }, Code::Poisoned as i32);
|
||||||
|
id = 77;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_input(c, &mut id) },
|
||||||
|
Code::Poisoned as i32
|
||||||
|
);
|
||||||
|
assert_eq!(id, 77, "nothing was written");
|
||||||
|
unsafe { &*c }.in_step.set(false);
|
||||||
|
unsafe { zs_circuit_free(c) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
//! The ABI exercised through the `extern "C"` functions themselves, as a C caller would.
|
||||||
|
//! Evidence: `context/test-evidence/zset-ffi.md`.
|
||||||
|
|
||||||
|
use std::ffi::{CStr, c_void};
|
||||||
|
use std::ptr::{null, null_mut};
|
||||||
|
use zset_ffi::engine::Code;
|
||||||
|
use zset_ffi::*;
|
||||||
|
|
||||||
|
const OK: i32 = 0;
|
||||||
|
|
||||||
|
fn pair(k: &[u8], v: &[u8]) -> Vec<u8> {
|
||||||
|
zset_ffi::engine::pair(k, v).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zset(entries: &[(&[u8], i64)]) -> *mut ZsZSet {
|
||||||
|
let mut z = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_zset_new(&mut z) }, OK);
|
||||||
|
for (k, w) in entries {
|
||||||
|
assert_eq!(unsafe { zs_zset_insert(z, k.as_ptr(), k.len(), *w) }, OK);
|
||||||
|
}
|
||||||
|
z
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weight(z: *const ZsZSet, k: &[u8]) -> i64 {
|
||||||
|
let mut w = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_weight(z, k.as_ptr(), k.len(), &mut w) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
w
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(z: *const ZsZSet) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
assert_eq!(unsafe { zs_zset_len(z, &mut n) }, OK);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoded(z: *const ZsZSet) -> Vec<u8> {
|
||||||
|
let mut needed = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(z, null_mut(), 0, &mut needed) },
|
||||||
|
if needed == 0 {
|
||||||
|
OK
|
||||||
|
} else {
|
||||||
|
Code::BufferTooSmall as i32
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let mut buf = vec![0u8; needed];
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(z, buf.as_mut_ptr(), buf.len(), &mut needed) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_error() -> String {
|
||||||
|
unsafe { CStr::from_ptr(zs_last_error()) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct C(*mut ZsCircuit);
|
||||||
|
|
||||||
|
impl C {
|
||||||
|
fn new() -> C {
|
||||||
|
let mut c = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_circuit_new(&mut c) }, OK);
|
||||||
|
C(c)
|
||||||
|
}
|
||||||
|
fn input(&self) -> u32 {
|
||||||
|
let mut s = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_input(self.0, &mut s) }, OK);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
fn sink(&self, s: u32) -> u32 {
|
||||||
|
let mut o = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_output(self.0, s, &mut o) }, OK);
|
||||||
|
o
|
||||||
|
}
|
||||||
|
fn push(&self, input: u32, entries: &[(&[u8], i64)]) {
|
||||||
|
let z = zset(entries);
|
||||||
|
assert_eq!(unsafe { zs_circuit_push(self.0, input, z) }, OK);
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
/// Steps and takes the sink as sorted `(key, weight)` pairs.
|
||||||
|
fn run(&self, sink: u32) -> Vec<(Vec<u8>, i64)> {
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(self.0) }, OK);
|
||||||
|
self.take(sink)
|
||||||
|
}
|
||||||
|
fn take(&self, sink: u32) -> Vec<(Vec<u8>, i64)> {
|
||||||
|
let mut z = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_circuit_take(self.0, sink, &mut z) }, OK);
|
||||||
|
let enc = encoded(z);
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
decode_pairs(&enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for C {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { zs_circuit_free(self.0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_pairs(mut b: &[u8]) -> Vec<(Vec<u8>, i64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while !b.is_empty() {
|
||||||
|
let n = u32::from_le_bytes(b[..4].try_into().unwrap()) as usize;
|
||||||
|
let w = i64::from_le_bytes(b[4 + n..12 + n].try_into().unwrap());
|
||||||
|
out.push((b[4..4 + n].to_vec(), w));
|
||||||
|
b = &b[12 + n..];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one(entries: &[(&[u8], i64)]) -> Vec<(Vec<u8>, i64)> {
|
||||||
|
entries.iter().map(|(k, w)| (k.to_vec(), *w)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn le(n: i64) -> [u8; 8] {
|
||||||
|
n.to_le_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zset_adds_queries_and_frees() {
|
||||||
|
let (a, b) = (
|
||||||
|
zset(&[(b"x", 2), (b"y", -1)]),
|
||||||
|
zset(&[(b"x", -2), (b"z", 5)]),
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_zset_add(a, b) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
(weight(a, b"x"), weight(a, b"y"), weight(a, b"z"), len(a)),
|
||||||
|
(0, -1, 5, 2)
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_zset_neg(a) }, OK);
|
||||||
|
assert_eq!(unsafe { zs_zset_scale(a, 3) }, OK);
|
||||||
|
assert_eq!((weight(a, b"y"), weight(a, b"z")), (3, -15));
|
||||||
|
assert_eq!(unsafe { zs_zset_sub(a, a) }, OK);
|
||||||
|
assert_eq!(len(a), 0);
|
||||||
|
for z in [a, b] {
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
unsafe { zs_zset_free(null_mut()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_are_byte_strings_including_nul_and_empty() {
|
||||||
|
let z = zset(&[(b"a\0b", 1), (b"", 2), (b"a", 3)]);
|
||||||
|
assert_eq!(
|
||||||
|
(weight(z, b"a\0b"), weight(z, b""), weight(z, b"a"), len(z)),
|
||||||
|
(1, 2, 3, 3)
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_zset_insert(z, null(), 0, 4) }, OK);
|
||||||
|
assert_eq!(weight(z, b""), 6);
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_clone_and_encoding_round_trip() {
|
||||||
|
let z = zset(&[(b"k1", 5), (b"k2", -2), (b"k3", 1)]);
|
||||||
|
let mut d = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_zset_distinct(z, &mut d) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
(weight(d, b"k1"), weight(d, b"k2"), weight(d, b"k3")),
|
||||||
|
(1, 0, 1)
|
||||||
|
);
|
||||||
|
let mut copy = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_zset_clone(z, &mut copy) }, OK);
|
||||||
|
let enc = encoded(z);
|
||||||
|
let mut back = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_zset_new(&mut back) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_decode_add(back, enc.as_ptr(), enc.len()) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(encoded(back), encoded(copy));
|
||||||
|
assert_eq!(encoded(back), enc);
|
||||||
|
for h in [z, d, copy, back] {
|
||||||
|
unsafe { zs_zset_free(h) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_small_buffer_reports_the_size_it_needs() {
|
||||||
|
let z = zset(&[(b"abc", 1)]);
|
||||||
|
let (mut needed, mut small) = (0, [0u8; 3]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(z, small.as_mut_ptr(), small.len(), &mut needed) },
|
||||||
|
Code::BufferTooSmall as i32
|
||||||
|
);
|
||||||
|
assert_eq!(needed, 4 + 3 + 8);
|
||||||
|
assert_eq!(small, [0; 3], "nothing written on failure");
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_encoding_is_refused_and_adds_nothing() {
|
||||||
|
let z = zset(&[(b"a", 1)]);
|
||||||
|
let mut enc = encoded(z);
|
||||||
|
enc.push(7);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_decode_add(z, enc.as_ptr(), enc.len()) },
|
||||||
|
Code::BadValue as i32
|
||||||
|
);
|
||||||
|
assert_eq!((weight(z, b"a"), len(z)), (1, 1));
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_is_an_error_code_and_leaves_the_set_unchanged() {
|
||||||
|
let (a, b) = (
|
||||||
|
zset(&[(b"k", i64::MAX), (b"j", 1)]),
|
||||||
|
zset(&[(b"j", 1), (b"k", 1)]),
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_zset_add(a, b) }, Code::Overflow as i32);
|
||||||
|
assert_eq!(
|
||||||
|
(weight(a, b"k"), weight(a, b"j")),
|
||||||
|
(i64::MAX, 1),
|
||||||
|
"a is unchanged, even for the key before the overflow"
|
||||||
|
);
|
||||||
|
assert!(last_error().contains("weight overflow"), "{}", last_error());
|
||||||
|
assert_eq!(unsafe { zs_zset_scale(a, 2) }, Code::Overflow as i32);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_insert(a, b"k".as_ptr(), 1, 1) },
|
||||||
|
Code::Overflow as i32
|
||||||
|
);
|
||||||
|
let m = zset(&[(b"k", i64::MIN)]);
|
||||||
|
assert_eq!(unsafe { zs_zset_neg(m) }, Code::Overflow as i32);
|
||||||
|
assert_eq!(weight(m, b"k"), i64::MIN);
|
||||||
|
assert_eq!(unsafe { zs_zset_sub(a, a) }, OK);
|
||||||
|
for z in [a, b, m] {
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_argument_is_a_code_not_a_crash() {
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_new(null_mut()) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_len(null(), &mut 0) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
let z = zset(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_insert(z, null(), 3, 1) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_zset_add(z, null()) }, Code::NullArgument as i32);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_filter(null_mut(), 0, None, null_mut(), &mut 0) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
assert_ne!(zs_abi_version(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_circuit_runs_distinct_and_integrate() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut d, mut int) = (0, 0);
|
||||||
|
assert_eq!(unsafe { zs_circuit_distinct(c.0, i, &mut d) }, OK);
|
||||||
|
assert_eq!(unsafe { zs_circuit_integrate(c.0, d, &mut int) }, OK);
|
||||||
|
let (sd, sint) = (c.sink(d), c.sink(int));
|
||||||
|
c.push(i, &[(b"a", 3), (b"b", -1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
assert_eq!(c.take(sd), one(&[(b"a", 1)]));
|
||||||
|
assert_eq!(c.take(sint), one(&[(b"a", 1)]));
|
||||||
|
c.push(i, &[(b"a", -3), (b"b", 1)]);
|
||||||
|
assert_eq!(c.run(sd), one(&[(b"a", -1)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn upper(
|
||||||
|
_: *mut c_void,
|
||||||
|
k: *const u8,
|
||||||
|
n: usize,
|
||||||
|
emit: engine::Emit,
|
||||||
|
e: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
let up: Vec<u8> = unsafe { std::slice::from_raw_parts(k, n) }.to_ascii_uppercase();
|
||||||
|
emit(e, up.as_ptr(), up.len());
|
||||||
|
emit(e, b"!".as_ptr(), 1);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn even_len(_: *mut c_void, _: *const u8, n: usize) -> i32 {
|
||||||
|
i32::from(n.is_multiple_of(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn first_byte(
|
||||||
|
_: *mut c_void,
|
||||||
|
k: *const u8,
|
||||||
|
n: usize,
|
||||||
|
emit: engine::Emit,
|
||||||
|
e: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
emit(e, k, n.min(1));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callbacks_filter_and_flat_map() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut f, mut m) = (0, 0);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_filter(c.0, i, Some(even_len), null_mut(), &mut f) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_flat_map(c.0, f, Some(upper), null_mut(), &mut m) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
let s = c.sink(m);
|
||||||
|
c.push(i, &[(b"ab", 2), (b"abc", 5), (b"cd", -1)]);
|
||||||
|
assert_eq!(c.run(s), one(&[(b"!", 1), (b"AB", 2), (b"CD", -1)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_by_join_and_aggregate_follow_the_pair_encoding() {
|
||||||
|
let c = C::new();
|
||||||
|
let (orders, prices) = (c.input(), c.input());
|
||||||
|
let (mut by_item, mut joined) = (0, 0);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_index_by(c.0, orders, Some(first_byte), null_mut(), &mut by_item) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_join(c.0, by_item, prices, &mut joined) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
let sink = c.sink(joined);
|
||||||
|
c.push(orders, &[(b"apple", 1)]);
|
||||||
|
c.push(prices, &[(&pair(b"a", b"3"), 1)]);
|
||||||
|
let want = pair(b"a", &pair(b"apple", b"3"));
|
||||||
|
assert_eq!(c.run(sink), one(&[(&want, 1)]));
|
||||||
|
c.push(prices, &[(&pair(b"a", b"3"), -1)]);
|
||||||
|
assert_eq!(c.run(sink), one(&[(&want, -1)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregates_count_sum_min_and_max() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let outs: Vec<u32> = (0..4)
|
||||||
|
.map(|kind| {
|
||||||
|
let mut o = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_aggregate(c.0, i, kind, &mut o) }, OK);
|
||||||
|
c.sink(o)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
c.push(
|
||||||
|
i,
|
||||||
|
&[
|
||||||
|
(&pair(b"g", &le(5)), 1),
|
||||||
|
(&pair(b"g", &le(2)), 2),
|
||||||
|
(&pair(b"h", &le(9)), 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
let val = |k: &[u8], n: i64| (pair(k, &le(n)), 1);
|
||||||
|
let want = [
|
||||||
|
[val(b"g", 3), val(b"h", 1)],
|
||||||
|
[val(b"g", 9), val(b"h", 9)],
|
||||||
|
[val(b"g", 2), val(b"h", 9)],
|
||||||
|
[val(b"g", 5), val(b"h", 9)],
|
||||||
|
];
|
||||||
|
for (sink, want) in outs.iter().zip(want) {
|
||||||
|
let got = c.take(*sink);
|
||||||
|
let want: Vec<(Vec<u8>, i64)> = want.into_iter().collect();
|
||||||
|
let mut sorted = want.clone();
|
||||||
|
sorted.sort();
|
||||||
|
assert_eq!(got, sorted, "kind of sink {sink}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut int, mut again) = (0, 0);
|
||||||
|
assert_eq!(unsafe { zs_circuit_integrate(c.0, i, &mut int) }, OK);
|
||||||
|
c.push(i, &[(b"k", i64::MAX)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
c.push(i, &[(b"k", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Overflow as i32);
|
||||||
|
assert!(last_error().contains("weight overflow"));
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Poisoned as i32);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_distinct(c.0, i, &mut again) },
|
||||||
|
Code::Poisoned as i32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_pair_and_an_unknown_id_are_codes() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut j = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_join(c.0, i, 99, &mut j) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_circuit_join(c.0, i, i, &mut j) }, OK);
|
||||||
|
c.push(i, &[(b"\xff\xff\xff\xff", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::BadValue as i32);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Poisoned as i32);
|
||||||
|
let mut a = 0;
|
||||||
|
let c2 = C::new();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_aggregate(c2.0, 0, 9, &mut a) },
|
||||||
|
Code::BadValue as i32
|
||||||
|
);
|
||||||
|
let mut z = null_mut();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_take(c2.0, 5, &mut z) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
let empty = zset(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_push(c2.0, 0, empty) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
unsafe { zs_zset_free(empty) };
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn out_of_time(ctx: *mut c_void) -> i32 {
|
||||||
|
let calls = unsafe { &mut *(ctx as *mut u32) };
|
||||||
|
*calls += 1;
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rebuild_resumes_across_calls_and_matches_one_step() {
|
||||||
|
let keys: Vec<Vec<u8>> = (0u8..9).map(|n| vec![b'k', n]).collect();
|
||||||
|
let entries: Vec<(&[u8], i64)> = keys.iter().map(|k| (k.as_slice(), 1)).collect();
|
||||||
|
let snapshot = zset(&entries);
|
||||||
|
|
||||||
|
let whole = C::new();
|
||||||
|
let wi = whole.input();
|
||||||
|
let mut wd = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_distinct(whole.0, wi, &mut wd) }, OK);
|
||||||
|
let ws = whole.sink(wd);
|
||||||
|
assert_eq!(unsafe { zs_circuit_push(whole.0, wi, snapshot) }, OK);
|
||||||
|
let want = whole.run(ws);
|
||||||
|
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut d = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_distinct(c.0, i, &mut d) }, OK);
|
||||||
|
let s = c.sink(d);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 2, &mut job) }, OK);
|
||||||
|
let (mut calls, mut got, mut done) = (0u32, Vec::new(), 0);
|
||||||
|
for _ in 0..20 {
|
||||||
|
assert_eq!(
|
||||||
|
unsafe {
|
||||||
|
zs_rebuild_step(
|
||||||
|
job,
|
||||||
|
c.0,
|
||||||
|
Some(out_of_time),
|
||||||
|
(&mut calls as *mut u32).cast(),
|
||||||
|
&mut done,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
got.extend(c.take(s));
|
||||||
|
if done == 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(done, 1, "the job finished within its bound");
|
||||||
|
assert_eq!(
|
||||||
|
calls, 4,
|
||||||
|
"ceil(9 / 2) = 5 chunks; the last is Done before any check"
|
||||||
|
);
|
||||||
|
got.sort();
|
||||||
|
assert_eq!(got, want);
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rebuild_without_a_deadline_runs_to_completion() {
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1), (b"c", 1)]);
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let s = c.sink(i);
|
||||||
|
let mut job = null_mut();
|
||||||
|
let mut done = 0;
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 1, &mut job) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, None, null_mut(), &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(done, 1);
|
||||||
|
assert_eq!(c.take(s).len(), 3);
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_and_max_read_only_values_of_positive_weight() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut lo, mut hi) = (0, 0);
|
||||||
|
assert_eq!(unsafe { zs_circuit_aggregate(c.0, i, 2, &mut lo) }, OK);
|
||||||
|
assert_eq!(unsafe { zs_circuit_aggregate(c.0, i, 3, &mut hi) }, OK);
|
||||||
|
let (slo, shi) = (c.sink(lo), c.sink(hi));
|
||||||
|
c.push(
|
||||||
|
i,
|
||||||
|
&[
|
||||||
|
(&pair(b"g", &le(5)), 1),
|
||||||
|
(&pair(b"g", &le(1)), -1),
|
||||||
|
(&pair(b"g", &le(9)), -1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
assert_eq!(c.take(slo), one(&[(&pair(b"g", &le(5)), 1)]));
|
||||||
|
assert_eq!(c.take(shi), one(&[(&pair(b"g", &le(5)), 1)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_index_by_callback_that_emits_nothing_is_a_bad_value() {
|
||||||
|
extern "C" fn nothing(
|
||||||
|
_: *mut c_void,
|
||||||
|
_: *const u8,
|
||||||
|
_: usize,
|
||||||
|
_: engine::Emit,
|
||||||
|
_: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut o = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_index_by(c.0, i, Some(nothing), null_mut(), &mut o) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
c.push(i, &[(b"x", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::BadValue as i32);
|
||||||
|
assert!(last_error().contains("exactly one key"), "{}", last_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chunked_rebuild_through_delay_is_one_tick() {
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1)]);
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut d = 0;
|
||||||
|
assert_eq!(unsafe { zs_circuit_delay(c.0, i, &mut d) }, OK);
|
||||||
|
let s = c.sink(d);
|
||||||
|
c.push(i, &[(b"p", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
c.take(s);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 1, &mut job) }, OK);
|
||||||
|
let (mut calls, mut done) = (0u32, 0);
|
||||||
|
let ctx = (&mut calls as *mut u32).cast();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, Some(out_of_time), ctx, &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(done, 0);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, Some(out_of_time), ctx, &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
done, 1,
|
||||||
|
"the last chunk is Done though the deadline expired"
|
||||||
|
);
|
||||||
|
assert_eq!(c.take(s), one(&[(b"p", 1)]));
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_set_added_to_or_subtracted_from_itself_is_defined() {
|
||||||
|
let a = zset(&[(b"x", 2), (b"y", -3)]);
|
||||||
|
assert_eq!(unsafe { zs_zset_add(a, a) }, OK);
|
||||||
|
assert_eq!((weight(a, b"x"), weight(a, b"y")), (4, -6));
|
||||||
|
assert_eq!(unsafe { zs_zset_sub(a, a) }, OK);
|
||||||
|
assert_eq!(len(a), 0);
|
||||||
|
let m = zset(&[(b"k", i64::MAX)]);
|
||||||
|
assert_eq!(unsafe { zs_zset_add(m, m) }, Code::Overflow as i32);
|
||||||
|
assert_eq!(
|
||||||
|
weight(m, b"k"),
|
||||||
|
i64::MAX,
|
||||||
|
"the failed self-add changed nothing"
|
||||||
|
);
|
||||||
|
unsafe { zs_zset_free(a) };
|
||||||
|
unsafe { zs_zset_free(m) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_buffer_is_refused_before_it_is_written() {
|
||||||
|
let z = zset(&[(b"abc", 1)]);
|
||||||
|
let mut needed = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(z, null_mut(), 100, &mut needed) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(z, null_mut(), 0, &mut needed) },
|
||||||
|
Code::BufferTooSmall as i32,
|
||||||
|
"a null buffer still probes the size"
|
||||||
|
);
|
||||||
|
assert_eq!(needed, 15);
|
||||||
|
let empty = zset(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_encode(empty, null_mut(), 0, &mut needed) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
unsafe { zs_zset_free(z) };
|
||||||
|
unsafe { zs_zset_free(empty) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_out_pointer_is_refused_before_any_state_changes() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_input(c.0, null_mut()) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
assert_eq!(c.input(), i + 1, "the refused call added no input");
|
||||||
|
let s = c.sink(i);
|
||||||
|
c.push(i, &[(b"k", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_take(c.0, s, null_mut()) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.take(s),
|
||||||
|
one(&[(b"k", 1)]),
|
||||||
|
"the refused take left the sink alone"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_zset_new(null_mut()) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1)]);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 1, &mut job) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, None, null_mut(), null_mut()) },
|
||||||
|
Code::NullArgument as i32
|
||||||
|
);
|
||||||
|
let mut done = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, None, null_mut(), &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(c.take(s).len(), 2, "the refused step consumed nothing");
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rebuild_is_stepped_only_on_its_own_circuit() {
|
||||||
|
let (c1, c2) = (C::new(), C::new());
|
||||||
|
let (i1, i2) = (c1.input(), c2.input());
|
||||||
|
let (s1, s2) = (c1.sink(i1), c2.sink(i2));
|
||||||
|
let snapshot = zset(&[(b"a", 1)]);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_new(c1.0, i1, snapshot, 1, &mut job) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
let mut done = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c2.0, None, null_mut(), &mut done) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
assert_eq!((c2.take(s2).len(), c1.take(s1).len()), (0, 0));
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c1.0, None, null_mut(), &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(c1.take(s1), one(&[(b"a", 1)]));
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_ids_and_sink_ids_are_not_interchangeable() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let s = c.sink(i);
|
||||||
|
let mut z = null_mut();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_take(c.0, i, &mut z) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
let mut o = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_delay(c.0, s, &mut o) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_output(c.0, s, &mut o) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
let empty = zset(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_push(c.0, s, empty) },
|
||||||
|
Code::BadHandle as i32
|
||||||
|
);
|
||||||
|
unsafe { zs_zset_free(empty) };
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn fails(_: *mut c_void, _: *const u8, _: usize) -> i32 {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn flat_fails(
|
||||||
|
_: *mut c_void,
|
||||||
|
_: *const u8,
|
||||||
|
_: usize,
|
||||||
|
_: engine::Emit,
|
||||||
|
_: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
7
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn expire_fails(_: *mut c_void) -> i32 {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_callback_that_reports_failure_fails_the_step_and_kills_the_circuit() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut f, mut m) = (0, 0);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_filter(c.0, i, Some(fails), null_mut(), &mut f) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
c.push(i, &[(b"k", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Callback as i32);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Poisoned as i32);
|
||||||
|
let d = C::new();
|
||||||
|
let j = d.input();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_flat_map(d.0, j, Some(flat_fails), null_mut(), &mut m) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
d.push(j, &[(b"k", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(d.0) }, Code::Callback as i32);
|
||||||
|
let e = C::new();
|
||||||
|
let k = e.input();
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1)]);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(e.0, k, snapshot, 1, &mut job) }, OK);
|
||||||
|
let mut done = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, e.0, Some(expire_fails), null_mut(), &mut done) },
|
||||||
|
Code::Callback as i32
|
||||||
|
);
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn reenters(ctx: *mut c_void, _: *const u8, _: usize) -> i32 {
|
||||||
|
let (c, seen) = unsafe { &mut *(ctx as *mut (*mut ZsCircuit, i32)) };
|
||||||
|
let mut id = 0;
|
||||||
|
*seen = unsafe { zs_circuit_input(*c, &mut id) };
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_callback_that_calls_back_into_its_circuit_gets_poisoned_and_changes_nothing() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut state: (*mut ZsCircuit, i32) = (c.0, 0);
|
||||||
|
let mut f = 0;
|
||||||
|
let ctx = (&mut state as *mut (*mut ZsCircuit, i32)).cast();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_filter(c.0, i, Some(reenters), ctx, &mut f) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
c.push(i, &[(b"k", 1)]);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_step(c.0) },
|
||||||
|
OK,
|
||||||
|
"the outer step completes"
|
||||||
|
);
|
||||||
|
assert_eq!(state.1, Code::Poisoned as i32);
|
||||||
|
assert_eq!(c.input(), f + 1, "the re-entrant call added no input");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_count_or_sum_that_cancels_to_zero_keeps_its_row() {
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let (mut n, mut t) = (0, 0);
|
||||||
|
assert_eq!(unsafe { zs_circuit_aggregate(c.0, i, 0, &mut n) }, OK);
|
||||||
|
assert_eq!(unsafe { zs_circuit_aggregate(c.0, i, 1, &mut t) }, OK);
|
||||||
|
let (sn, st) = (c.sink(n), c.sink(t));
|
||||||
|
c.push(
|
||||||
|
i,
|
||||||
|
&[
|
||||||
|
(&pair(b"g", &le(5)), 1),
|
||||||
|
(&pair(b"g", &le(-5)), 1),
|
||||||
|
(&pair(b"h", &le(1)), -1),
|
||||||
|
(&pair(b"h", &le(2)), 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
c.take(st),
|
||||||
|
one(&[(&pair(b"g", &le(0)), 1), (&pair(b"h", &le(1)), 1)])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.take(sn),
|
||||||
|
one(&[(&pair(b"g", &le(2)), 1), (&pair(b"h", &le(0)), 1)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_pointer_width = "64")]
|
||||||
|
#[test]
|
||||||
|
fn a_length_of_4_gib_or_more_is_refused_not_truncated() {
|
||||||
|
assert_eq!(engine::len32(u32::MAX as usize), Ok(u32::MAX));
|
||||||
|
assert_eq!(
|
||||||
|
engine::len32(1usize << 32).unwrap_err().code,
|
||||||
|
Code::BadValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_index_by_callback_that_fails_reports_the_callback_code_not_a_later_fault() {
|
||||||
|
extern "C" fn fails_after_emitting(
|
||||||
|
_: *mut c_void,
|
||||||
|
_: *const u8,
|
||||||
|
_: usize,
|
||||||
|
_: engine::Emit,
|
||||||
|
_: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let mut o = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_index_by(c.0, i, Some(fails_after_emitting), null_mut(), &mut o) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
c.push(i, &[(b"x", 1)]);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, Code::Callback as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reenter {
|
||||||
|
c: *mut ZsCircuit,
|
||||||
|
job: *mut ZsRebuild,
|
||||||
|
seen: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn expired_reenters(ctx: *mut c_void) -> i32 {
|
||||||
|
let r = unsafe { &mut *(ctx as *mut Reenter) };
|
||||||
|
let mut done = 0;
|
||||||
|
r.seen
|
||||||
|
.push(unsafe { zs_rebuild_step(r.job, r.c, None, null_mut(), &mut done) });
|
||||||
|
unsafe { zs_rebuild_free(r.job) };
|
||||||
|
unsafe { zs_circuit_free(r.c) };
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_callback_cannot_step_or_free_the_job_or_circuit_it_runs_under() {
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1), (b"c", 1)]);
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let s = c.sink(i);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 1, &mut job) }, OK);
|
||||||
|
let mut r = Reenter {
|
||||||
|
c: c.0,
|
||||||
|
job,
|
||||||
|
seen: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut done = 0;
|
||||||
|
let ctx = (&mut r as *mut Reenter).cast();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, Some(expired_reenters), ctx, &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.seen,
|
||||||
|
vec![Code::Poisoned as i32],
|
||||||
|
"stepping from inside is refused"
|
||||||
|
);
|
||||||
|
assert_eq!(done, 0);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, None, null_mut(), &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(done, c.take(s).len()),
|
||||||
|
(1, 3),
|
||||||
|
"the job and circuit survived the frees"
|
||||||
|
);
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_circuit_freed_and_another_made_does_not_inherit_its_jobs() {
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1)]);
|
||||||
|
let c1 = C::new();
|
||||||
|
let i1 = c1.input();
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_new(c1.0, i1, snapshot, 1, &mut job) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
drop(c1);
|
||||||
|
for _ in 0..64 {
|
||||||
|
let c2 = C::new();
|
||||||
|
let _ = c2.input();
|
||||||
|
let mut done = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c2.0, None, null_mut(), &mut done) },
|
||||||
|
Code::BadHandle as i32,
|
||||||
|
"a new circuit, even at the old address, is not the job's"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_step_between_rebuild_calls_is_interrupted_not_a_poison() {
|
||||||
|
let snapshot = zset(&[(b"a", 1), (b"b", 1)]);
|
||||||
|
let c = C::new();
|
||||||
|
let i = c.input();
|
||||||
|
let s = c.sink(i);
|
||||||
|
let mut job = null_mut();
|
||||||
|
assert_eq!(unsafe { zs_rebuild_new(c.0, i, snapshot, 1, &mut job) }, OK);
|
||||||
|
let (mut calls, mut done) = (0u32, 0);
|
||||||
|
let ctx = (&mut calls as *mut u32).cast();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, Some(out_of_time), ctx, &mut done) },
|
||||||
|
OK
|
||||||
|
);
|
||||||
|
assert_eq!(unsafe { zs_circuit_step(c.0) }, OK);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_rebuild_step(job, c.0, None, null_mut(), &mut done) },
|
||||||
|
Code::Interrupted as i32
|
||||||
|
);
|
||||||
|
assert!(last_error().contains("interrupted"), "{}", last_error());
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { zs_circuit_step(c.0) },
|
||||||
|
OK,
|
||||||
|
"the circuit is still usable"
|
||||||
|
);
|
||||||
|
let _ = c.take(s);
|
||||||
|
unsafe { zs_rebuild_free(job) };
|
||||||
|
unsafe { zs_zset_free(snapshot) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! The built shared library, loaded the way a CFFI caller loads it.
|
||||||
|
//! Evidence: `context/test-evidence/zset-ffi.md`.
|
||||||
|
|
||||||
|
use libloading::{Library, Symbol};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::ptr::null_mut;
|
||||||
|
|
||||||
|
type Opaque = std::ffi::c_void;
|
||||||
|
|
||||||
|
/// Cargo puts the cdylib in `target/<profile>/deps` beside this test binary's own directory.
|
||||||
|
fn library() -> PathBuf {
|
||||||
|
let exe = std::env::current_exe().unwrap();
|
||||||
|
let deps = exe.parent().unwrap();
|
||||||
|
let ext = if cfg!(target_os = "macos") {
|
||||||
|
"dylib"
|
||||||
|
} else {
|
||||||
|
"so"
|
||||||
|
};
|
||||||
|
let name = format!("{}zset_ffi.{ext}", if cfg!(windows) { "" } else { "lib" });
|
||||||
|
[deps.join(&name), deps.parent().unwrap().join(&name)]
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.exists())
|
||||||
|
.unwrap_or_else(|| panic!("{name} was not built next to {}", deps.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_loaded_library_adds_queries_frees_and_reports_overflow() {
|
||||||
|
// SAFETY: loads this workspace's own library; the symbols below have the header's types.
|
||||||
|
unsafe {
|
||||||
|
let lib = Library::new(library()).unwrap();
|
||||||
|
let version: Symbol<extern "C" fn() -> u32> = lib.get(b"zs_abi_version").unwrap();
|
||||||
|
let new: Symbol<extern "C" fn(*mut *mut Opaque) -> i32> = lib.get(b"zs_zset_new").unwrap();
|
||||||
|
let free: Symbol<extern "C" fn(*mut Opaque)> = lib.get(b"zs_zset_free").unwrap();
|
||||||
|
let insert: Symbol<extern "C" fn(*mut Opaque, *const u8, usize, i64) -> i32> =
|
||||||
|
lib.get(b"zs_zset_insert").unwrap();
|
||||||
|
let weight: Symbol<extern "C" fn(*const Opaque, *const u8, usize, *mut i64) -> i32> =
|
||||||
|
lib.get(b"zs_zset_weight").unwrap();
|
||||||
|
let last: Symbol<extern "C" fn() -> *const std::ffi::c_char> =
|
||||||
|
lib.get(b"zs_last_error").unwrap();
|
||||||
|
assert_eq!(version(), 1);
|
||||||
|
let mut z = null_mut();
|
||||||
|
assert_eq!(new(&mut z), 0);
|
||||||
|
assert_eq!(insert(z, b"k".as_ptr(), 1, 40), 0);
|
||||||
|
assert_eq!(insert(z, b"k".as_ptr(), 1, 2), 0);
|
||||||
|
let mut w = 0;
|
||||||
|
assert_eq!(weight(z, b"k".as_ptr(), 1, &mut w), 0);
|
||||||
|
assert_eq!(w, 42);
|
||||||
|
assert_eq!(
|
||||||
|
insert(z, b"k".as_ptr(), 1, i64::MAX),
|
||||||
|
2,
|
||||||
|
"overflow is ZS_ERR_OVERFLOW"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::ffi::CStr::from_ptr(last())
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("overflow")
|
||||||
|
);
|
||||||
|
assert_eq!(weight(z, b"k".as_ptr(), 1, &mut w), 0);
|
||||||
|
assert_eq!(w, 42, "the failed insert changed nothing");
|
||||||
|
free(z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! The checked-in C header matches the exported functions, their parameter and return
|
||||||
|
//! types, and the constants. Evidence: `context/test-evidence/zset-ffi.md`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use zset_ffi::ZS_ABI_VERSION;
|
||||||
|
use zset_ffi::engine::Code;
|
||||||
|
|
||||||
|
const SRC: &str = include_str!("../src/lib.rs");
|
||||||
|
const HEADER: &str = include_str!("../include/zset_ffi.h");
|
||||||
|
|
||||||
|
fn squash(s: &str) -> String {
|
||||||
|
s.split_whitespace().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits at the commas that are not inside brackets.
|
||||||
|
fn split_params(params: &str) -> Vec<String> {
|
||||||
|
let (mut depth, mut cur, mut out) = (0i32, String::new(), Vec::new());
|
||||||
|
for c in params.chars() {
|
||||||
|
match c {
|
||||||
|
'(' | '<' => depth += 1,
|
||||||
|
')' | '>' => depth -= 1,
|
||||||
|
',' if depth == 0 => {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
if !cur.trim().is_empty() {
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The C type a Rust ABI type must be declared as. Anything unlisted fails the test, so a
|
||||||
|
/// new type needs a line here and a conscious choice.
|
||||||
|
fn c_type(rust: &str) -> String {
|
||||||
|
let t = squash(rust);
|
||||||
|
let c = match t.as_str() {
|
||||||
|
"*mut*mutZsZSet" => "ZsZSet **",
|
||||||
|
"*mutZsZSet" => "ZsZSet *",
|
||||||
|
"*constZsZSet" => "const ZsZSet *",
|
||||||
|
"*mutZsCircuit" => "ZsCircuit *",
|
||||||
|
"*mut*mutZsCircuit" => "ZsCircuit **",
|
||||||
|
"*mutZsRebuild" => "ZsRebuild *",
|
||||||
|
"*mut*mutZsRebuild" => "ZsRebuild **",
|
||||||
|
"*constu8" => "const uint8_t *",
|
||||||
|
"*mutu8" => "uint8_t *",
|
||||||
|
"*mutc_void" => "void *",
|
||||||
|
"*mutu32" => "uint32_t *",
|
||||||
|
"*muti64" => "int64_t *",
|
||||||
|
"*mutusize" => "size_t *",
|
||||||
|
"*muti32" => "int32_t *",
|
||||||
|
"*constc_char" => "const char *",
|
||||||
|
"usize" => "size_t",
|
||||||
|
"i64" => "int64_t",
|
||||||
|
"u32" => "uint32_t",
|
||||||
|
"i32" => "int32_t",
|
||||||
|
"Option<KeepFn>" => "ZsKeepFn",
|
||||||
|
"Option<FlatMapFn>" => "ZsFlatMapFn",
|
||||||
|
"Option<ExpiredFn>" => "ZsExpiredFn",
|
||||||
|
"Emit" => "ZsEmit",
|
||||||
|
other => panic!("no C type is declared for the Rust type `{other}`"),
|
||||||
|
};
|
||||||
|
squash(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name -> (return type, parameter types)` from the Rust source.
|
||||||
|
fn exported() -> BTreeMap<String, (String, Vec<String>)> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
let fns = SRC
|
||||||
|
.split("extern \"C\" fn ")
|
||||||
|
.skip(1)
|
||||||
|
.filter(|p| !p.starts_with('$'));
|
||||||
|
for part in fns {
|
||||||
|
let name = part.split('(').next().unwrap().to_string();
|
||||||
|
let rest = &part[name.len() + 1..];
|
||||||
|
let mut depth = 1;
|
||||||
|
let end = rest
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, c)| {
|
||||||
|
depth += i32::from(*c == '(') - i32::from(*c == ')');
|
||||||
|
depth == 0
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let params: Vec<String> = split_params(&rest[..end])
|
||||||
|
.iter()
|
||||||
|
.map(|p| c_type(p.split_once(':').unwrap().1))
|
||||||
|
.collect();
|
||||||
|
let ret = rest[end + 1..].split('{').next().unwrap().trim();
|
||||||
|
let ret = match ret.strip_prefix("->") {
|
||||||
|
Some(r) => c_type(r),
|
||||||
|
None => "void".to_string(),
|
||||||
|
};
|
||||||
|
out.insert(name, (ret, params));
|
||||||
|
}
|
||||||
|
// The `unary_op!` expansions share the macro body's signature.
|
||||||
|
let body = &SRC[SRC.find("macro_rules! unary_op").unwrap()..];
|
||||||
|
let sig = body.split("extern \"C\" fn $name(").nth(1).unwrap();
|
||||||
|
let (params, tail) = sig.split_once(')').unwrap();
|
||||||
|
let unary_params: Vec<String> = split_params(params)
|
||||||
|
.iter()
|
||||||
|
.map(|p| c_type(p.split_once(':').unwrap().1))
|
||||||
|
.collect();
|
||||||
|
let unary_ret = c_type(
|
||||||
|
tail.split('{')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("->"),
|
||||||
|
);
|
||||||
|
for line in SRC.lines().filter(|l| l.starts_with("unary_op!(")) {
|
||||||
|
let name = line["unary_op!(".len()..].split(',').next().unwrap();
|
||||||
|
out.insert(name.to_string(), (unary_ret.clone(), unary_params.clone()));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_comments(text: &str) -> String {
|
||||||
|
let mut flat = text.replace('\n', " ");
|
||||||
|
while let Some(open) = flat.find("/*") {
|
||||||
|
let close = flat[open..].find("*/").expect("an unterminated comment") + open + 2;
|
||||||
|
flat.replace_range(open..close, " ");
|
||||||
|
}
|
||||||
|
flat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header parameter without its name: `const ZsZSet *z` is `const ZsZSet *`.
|
||||||
|
fn param_type(p: &str) -> String {
|
||||||
|
let p = p.trim();
|
||||||
|
let name_start = p
|
||||||
|
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
if name_start == 0 {
|
||||||
|
return squash(p);
|
||||||
|
}
|
||||||
|
squash(&p[..name_start])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declared() -> BTreeMap<String, (String, Vec<String>)> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
for decl in strip_comments(HEADER).split(';') {
|
||||||
|
let Some(open) = decl.find('(') else { continue };
|
||||||
|
let before = &decl[..open];
|
||||||
|
let name_at = before
|
||||||
|
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
let name = &before[name_at..];
|
||||||
|
if !name.starts_with("zs_") || before.contains("typedef") || before.contains("(*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ret = squash(&before[..name_at]);
|
||||||
|
let params = decl[open + 1..].rsplit_once(')').unwrap().0;
|
||||||
|
let params = if params.trim() == "void" {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
split_params(params).iter().map(|p| param_type(p)).collect()
|
||||||
|
};
|
||||||
|
out.insert(name.to_string(), (ret, params));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defines() -> BTreeMap<String, i64> {
|
||||||
|
HEADER
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.strip_prefix("#define "))
|
||||||
|
.filter_map(|l| {
|
||||||
|
let (k, v) = l.split_once(' ')?;
|
||||||
|
Some((k.to_string(), v.trim().parse().ok()?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_header_declares_exactly_the_exported_functions_with_their_types() {
|
||||||
|
let (src, hdr) = (exported(), declared());
|
||||||
|
assert!(
|
||||||
|
src.len() > 30,
|
||||||
|
"the scanner saw {} functions, it is not reading the source",
|
||||||
|
src.len()
|
||||||
|
);
|
||||||
|
assert_eq!(src, hdr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_header_constants_are_the_rust_constants() {
|
||||||
|
let d = defines();
|
||||||
|
for code in Code::ALL {
|
||||||
|
assert_eq!(
|
||||||
|
d[code.header_name()],
|
||||||
|
*code as i64,
|
||||||
|
"{}",
|
||||||
|
code.header_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let err_lines = d.keys().filter(|k| k.starts_with("ZS_ERR_")).count();
|
||||||
|
assert_eq!(
|
||||||
|
err_lines,
|
||||||
|
Code::ALL.len(),
|
||||||
|
"a header code without a Rust variant"
|
||||||
|
);
|
||||||
|
assert_eq!(d["ZS_ABI_VERSION"], i64::from(ZS_ABI_VERSION));
|
||||||
|
assert_eq!(d["ZS_OK"], 0);
|
||||||
|
assert_eq!(
|
||||||
|
[
|
||||||
|
d["ZS_AGG_COUNT"],
|
||||||
|
d["ZS_AGG_SUM"],
|
||||||
|
d["ZS_AGG_MIN"],
|
||||||
|
d["ZS_AGG_MAX"]
|
||||||
|
],
|
||||||
|
[0, 1, 2, 3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `typedef R (*Name)(params)` from the header, and `pub type X = extern "C" fn(..) -> R` from
|
||||||
|
/// `engine.rs`, as `name -> (return, params)`.
|
||||||
|
type Sigs = BTreeMap<String, (String, Vec<String>)>;
|
||||||
|
|
||||||
|
fn callback_types() -> (Sigs, Sigs) {
|
||||||
|
let mut hdr = BTreeMap::new();
|
||||||
|
for decl in strip_comments(HEADER).split(';') {
|
||||||
|
let Some(at) = decl.find("(*") else { continue };
|
||||||
|
if !decl.contains("typedef") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ret = squash(
|
||||||
|
decl.split("typedef")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap()
|
||||||
|
.split('(')
|
||||||
|
.next()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let rest = &decl[at + 2..];
|
||||||
|
let (name, after) = rest.split_once(')').unwrap();
|
||||||
|
let params = after
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('(')
|
||||||
|
.rsplit_once(')')
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let params = split_params(params).iter().map(|p| param_type(p)).collect();
|
||||||
|
hdr.insert(name.trim().to_string(), (ret, params));
|
||||||
|
}
|
||||||
|
let engine = include_str!("../src/engine.rs");
|
||||||
|
let mut rust = BTreeMap::new();
|
||||||
|
for line in engine.lines().filter(|l| l.contains("= extern \"C\" fn(")) {
|
||||||
|
let name = line
|
||||||
|
.trim_start_matches("pub type ")
|
||||||
|
.split(' ')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
let sig = line.split("fn(").nth(1).unwrap();
|
||||||
|
let (params, tail) = sig.rsplit_once(')').unwrap();
|
||||||
|
let ret = match tail.trim().trim_end_matches(';').trim().strip_prefix("->") {
|
||||||
|
Some(r) => c_type(r),
|
||||||
|
None => "void".to_string(),
|
||||||
|
};
|
||||||
|
let params = split_params(params).iter().map(|p| c_type(p)).collect();
|
||||||
|
rust.insert(format!("Zs{name}"), (ret, params));
|
||||||
|
}
|
||||||
|
(hdr, rust)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_header_callback_typedefs_match_the_rust_callback_types() {
|
||||||
|
let (hdr, rust) = callback_types();
|
||||||
|
assert_eq!(
|
||||||
|
rust.len(),
|
||||||
|
4,
|
||||||
|
"the scanner saw {} callback types",
|
||||||
|
rust.len()
|
||||||
|
);
|
||||||
|
assert_eq!(rust, hdr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
;;; A CFFI smoke test: load libzset_ffi, add weights, query, hit the overflow code, free.
|
||||||
|
;;; Run: sbcl --non-interactive --load ~/quicklisp/setup.lisp \
|
||||||
|
;;; --eval '(defvar cl-user::*zset-lib* "target/release/libzset_ffi.dylib")' \
|
||||||
|
;;; --load crates/zset-ffi/tests/smoke.lisp
|
||||||
|
;;; Not a CI gate: sbcl and cffi are not in the dev shell. See context/code/zset-ffi.md.
|
||||||
|
(ql:quickload :cffi :silent t)
|
||||||
|
(defpackage :zset-smoke (:use :cl :cffi))
|
||||||
|
(in-package :zset-smoke)
|
||||||
|
|
||||||
|
(load-foreign-library cl-user::*zset-lib*)
|
||||||
|
|
||||||
|
(defcfun "zs_abi_version" :uint32)
|
||||||
|
(defcfun "zs_last_error" :string)
|
||||||
|
(defcfun "zs_zset_new" :int32 (out :pointer))
|
||||||
|
(defcfun "zs_zset_free" :void (z :pointer))
|
||||||
|
(defcfun "zs_zset_insert" :int32 (z :pointer) (key :pointer) (len :size) (weight :int64))
|
||||||
|
(defcfun "zs_zset_weight" :int32 (z :pointer) (key :pointer) (len :size) (out :pointer))
|
||||||
|
(defcfun "zs_zset_len" :int32 (z :pointer) (out :pointer))
|
||||||
|
|
||||||
|
(defconstant +overflow+ 2)
|
||||||
|
|
||||||
|
(defun check (what got want)
|
||||||
|
(unless (equal got want) (error "~a: got ~s, wanted ~s" what got want))
|
||||||
|
(format t "ok ~a~%" what))
|
||||||
|
|
||||||
|
(defun key-call (fn z key &rest more)
|
||||||
|
"Calls FN with Z, a foreign copy of the string KEY's UTF-8 bytes, its length, MORE."
|
||||||
|
(with-foreign-string ((ptr len) key :null-terminated-p nil)
|
||||||
|
(apply fn z ptr len more)))
|
||||||
|
|
||||||
|
(with-foreign-object (out :pointer)
|
||||||
|
(check "new" (zs-zset-new out) 0)
|
||||||
|
(let ((z (mem-ref out :pointer)))
|
||||||
|
(check "insert" (key-call #'zs-zset-insert z "k" 40) 0)
|
||||||
|
(check "insert again" (key-call #'zs-zset-insert z "k" 2) 0)
|
||||||
|
(with-foreign-object (w :int64)
|
||||||
|
(check "weight" (progn (key-call #'zs-zset-weight z "k" w) (mem-ref w :int64)) 42)
|
||||||
|
(check "overflow code" (key-call #'zs-zset-insert z "k" (1- (expt 2 63))) +overflow+)
|
||||||
|
(check "error text" (and (search "overflow" (zs-last-error)) t) t)
|
||||||
|
(check "unchanged" (progn (key-call #'zs-zset-weight z "k" w) (mem-ref w :int64)) 42))
|
||||||
|
(zs-zset-free z)))
|
||||||
|
|
||||||
|
;;; A callback: the body is wrapped in handler-case and returns the failure value, because a
|
||||||
|
;;; non-local exit through the library would leave the circuit dead (ZS_ERR_POISONED).
|
||||||
|
(defcfun "zs_circuit_new" :int32 (out :pointer))
|
||||||
|
(defcfun "zs_circuit_free" :void (c :pointer))
|
||||||
|
(defcfun "zs_circuit_input" :int32 (c :pointer) (out :pointer))
|
||||||
|
(defcfun "zs_circuit_filter" :int32 (c :pointer) (stream :uint32) (keep :pointer) (ctx :pointer) (out :pointer))
|
||||||
|
(defcfun "zs_circuit_push" :int32 (c :pointer) (input :uint32) (z :pointer))
|
||||||
|
(defcfun "zs_circuit_step" :int32 (c :pointer))
|
||||||
|
|
||||||
|
(defcallback keep-or-fail :int32 ((ctx :pointer) (key :pointer) (len :size))
|
||||||
|
(declare (ignore ctx key len))
|
||||||
|
(handler-case (error "a Lisp error inside a callback")
|
||||||
|
(error () -1)))
|
||||||
|
|
||||||
|
(with-foreign-objects ((cout :pointer) (id :uint32) (zout :pointer))
|
||||||
|
(check "circuit" (zs-circuit-new cout) 0)
|
||||||
|
(let ((c (mem-ref cout :pointer)))
|
||||||
|
(check "input" (zs-circuit-input c id) 0)
|
||||||
|
(check "filter" (zs-circuit-filter c (mem-ref id :uint32) (callback keep-or-fail) (null-pointer) id) 0)
|
||||||
|
(check "zset" (zs-zset-new zout) 0)
|
||||||
|
(key-call #'zs-zset-insert (mem-ref zout :pointer) "k" 1)
|
||||||
|
(check "push" (zs-circuit-push c 0 (mem-ref zout :pointer)) 0)
|
||||||
|
(check "step reports the callback's failure" (zs-circuit-step c) 8)
|
||||||
|
(check "the circuit is dead afterwards" (zs-circuit-step c) 7)
|
||||||
|
(zs-zset-free (mem-ref zout :pointer))
|
||||||
|
(zs-circuit-free c)))
|
||||||
|
(format t "zset smoke: all ok~%")
|
||||||
@@ -9,6 +9,10 @@ crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as
|
|||||||
crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.').
|
crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.').
|
||||||
crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.').
|
crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.').
|
||||||
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.').
|
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.').
|
||||||
|
crate(numtower,'crates/numtower','exact decimal arithmetic tower (ADR 005): Num, canonical text; no workspace deps. Used by improvise-protocol for the wire form.').
|
||||||
|
crate(zset,'crates/zset','std-only Z-sets (checked i64 weights) plus Circuit, operators, Trace and Rebuild (ADR 009 clause 1); no dependencies.').
|
||||||
|
crate(zset_ffi,'crates/zset-ffi','C ABI over zset (cdylib, staticlib, rlib; include/zset_ffi.h) for CFFI. native_only: not in nix/ci.nix wasmCrates.').
|
||||||
|
crate(improvise_incremental,'crates/improvise-incremental','the Derived trait, Reading, Eager, differential_in/drawn_probes and cell_delta/DeltaValue; plugs zset in behind a trait (ADR 009).').
|
||||||
layer(model,improvise_core).
|
layer(model,improvise_core).
|
||||||
layer(view,improvise_core).
|
layer(view,improvise_core).
|
||||||
layer(formula,improvise_formula).
|
layer(formula,improvise_formula).
|
||||||
@@ -581,6 +585,13 @@ decision('improvise-cgqt',import_collisions_are_asked_not_defaulted,'PR #304, me
|
|||||||
decision('improvise-cgqt',occurrence_category_goes_on_axis_none,'Under DuplicatePolicy::Separate the build adds the occurrence category with items 1..=deepest collision and sets it to Axis::None in the active view, so the default grid still shows each tuple total and nothing new is pivoted. A record that collides with nothing is occurrence 1 (improvise-cgqt, improvise-9bit).').
|
decision('improvise-cgqt',occurrence_category_goes_on_axis_none,'Under DuplicatePolicy::Separate the build adds the occurrence category with items 1..=deepest collision and sets it to Axis::None in the active view, so the default grid still shows each tuple total and nothing new is pivoted. A record that collides with nothing is occurrence 1 (improvise-cgqt, improvise-9bit).').
|
||||||
decision('improvise-felo.1',category_cap_has_one_home,'The regular-category cap is improvise_core::model::MAX_CATEGORIES and nothing else. Until felo.1 the import wizard kept a private const of its own, so raising the model number alone would have left the wizard refusing a 13th category the model accepts -- a derivation stated twice that agreed only by coincidence. The only place the literal 24 is spelled is the test add_category_max_limit; every other test that fills to the cap derives it. No recorded reason for 12 was found; the user raised it on 2026-09-25 (12 categories is feeling small, we should increase to 24).').
|
decision('improvise-felo.1',category_cap_has_one_home,'The regular-category cap is improvise_core::model::MAX_CATEGORIES and nothing else. Until felo.1 the import wizard kept a private const of its own, so raising the model number alone would have left the wizard refusing a 13th category the model accepts -- a derivation stated twice that agreed only by coincidence. The only place the literal 24 is spelled is the test add_category_max_limit; every other test that fills to the cap derives it. No recorded reason for 12 was found; the user raised it on 2026-09-25 (12 categories is feeling small, we should increase to 24).').
|
||||||
decision('improvise-felo.13',skip_rebuild_keyed_on_declared_model_effects_plus_observed_view_and_drill,'Renamed from skip_rebuild_keyed_on_observed_inputs_not_effect_kind, whose name overstated. App::refresh_layout skips the rebuild unless layout_stale (DECLARED: set from EffectKind::Model(_) in apply_effects, and by install_workbook), or the drawn view name or the drill snapshot pointer (OBSERVED, compared as LayoutBasis) moved. It is not a hand-kept list of effects, the shape cursor_epoch and document_generation avoid, but it inherits gotcha 3 classification hole: a ViewOnly effect that changes what the layout reads leaves app.layout stale, where before felo.13 every batch rebuilt. A writer outside effects must call rebuild_layout itself (gotcha 23).').
|
decision('improvise-felo.13',skip_rebuild_keyed_on_declared_model_effects_plus_observed_view_and_drill,'Renamed from skip_rebuild_keyed_on_observed_inputs_not_effect_kind, whose name overstated. App::refresh_layout skips the rebuild unless layout_stale (DECLARED: set from EffectKind::Model(_) in apply_effects, and by install_workbook), or the drawn view name or the drill snapshot pointer (OBSERVED, compared as LayoutBasis) moved. It is not a hand-kept list of effects, the shape cursor_epoch and document_generation avoid, but it inherits gotcha 3 classification hole: a ViewOnly effect that changes what the layout reads leaves app.layout stale, where before felo.13 every batch rebuilt. A writer outside effects must call rebuild_layout itself (gotcha 23).').
|
||||||
|
decision('improvise-felo.4',zset_weight_overflow,'zset weights are checked i64 and panic on overflow, in debug and release (user ruling 2026-10-10). The group is partial past i64.').
|
||||||
|
decision('improvise-felo.4',derived_lives_in_incremental,'Derived lives in improvise-incremental, not improvise-core. core never depends on zset: the store logs CellChange and improvise-incremental builds the ZSet (cell_delta).').
|
||||||
|
decision('improvise-felo.4',ffi_circuit_poisoned,'A failed circuit step (a panic in step or in a rebuild step) poisons the zset-ffi circuit: every later call returns ZS_ERR_POISONED and the caller builds another.').
|
||||||
|
decision('improvise-felo.4',resumable_deadline_predicate,'The resumable step takes a predicate, not an Instant, because Instant::now() panics on wasm32 and the host closes over its own clock (context/code/zset.md, point 8).').
|
||||||
|
decision('improvise-felo.4',eager_occupancy,'Eager::occupancy counts the visible stored members of a collapsed group, and formula-target keys are not asked.').
|
||||||
|
decision('improvise-z2vr',delivery_axis_refetch,'A delivery axis change makes the correction a window refetch until ADR 008 supplies the render model (context/code/delivery.md section 3).').
|
||||||
|
decision('improvise-felo.4',dirty_flag_is_delta_plus,'The document dirty flag is each CellData effect own cell delta (drained after every such effect, so ordering with Save holds) plus reorders, formulas and config; writes cancelling across effects still leave it set. The record tier alone uses the consolidated batch delta (moved_records). record_fingerprints and document_content are gone.').
|
||||||
wire_carries(command,both_directions,'user intent up, projection commands down').
|
wire_carries(command,both_directions,'user intent up, projection commands down').
|
||||||
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
|
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
|
||||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||||
@@ -640,6 +651,15 @@ crate_depends_on(improvise_protocol,improvise_core).
|
|||||||
crate_depends_on(improvise_command,improvise_protocol).
|
crate_depends_on(improvise_command,improvise_protocol).
|
||||||
crate_depends_on(improvise_gp,gp_tree).
|
crate_depends_on(improvise_gp,gp_tree).
|
||||||
crate_depends_on(improvise_gp,improvise_formula).
|
crate_depends_on(improvise_gp,improvise_formula).
|
||||||
|
crate_depends_on(improvise_incremental,improvise_core).
|
||||||
|
crate_depends_on(improvise_incremental,zset).
|
||||||
|
crate_depends_on(zset_ffi,zset).
|
||||||
|
crate_depends_on(improvise_protocol,zset).
|
||||||
|
crate_depends_on(improvise_protocol,numtower).
|
||||||
|
crate_depends_on(improvise_command,zset).
|
||||||
|
crate_depends_on(improvise_command,improvise_incremental).
|
||||||
|
crate_depends_on(improvise_gp,improvise_core).
|
||||||
|
crate_depends_on(improvise_command,improvise_gp).
|
||||||
depends_transitively(A,B):-depends_via(A,B,[A]).
|
depends_transitively(A,B):-depends_via(A,B,[A]).
|
||||||
depends_via(A,B,C):-crate_depends_on(A,B).
|
depends_via(A,B,C):-crate_depends_on(A,B).
|
||||||
depends_via(A,B,C):-crate_depends_on(A,D), \+memberchk(D,C),depends_via(D,B,[D|C]).
|
depends_via(A,B,C):-crate_depends_on(A,D), \+memberchk(D,C),depends_via(D,B,[D|C]).
|
||||||
@@ -690,7 +710,11 @@ wasm_bundle_crate(improvise_command).
|
|||||||
wasm_bundle_crate(improvise_protocol).
|
wasm_bundle_crate(improvise_protocol).
|
||||||
wasm_bundle_crate(improvise_wasm_client).
|
wasm_bundle_crate(improvise_wasm_client).
|
||||||
wasm_bundle_crate(improvise_worker_server).
|
wasm_bundle_crate(improvise_worker_server).
|
||||||
|
wasm_bundle_crate(numtower).
|
||||||
|
wasm_bundle_crate(zset).
|
||||||
|
wasm_bundle_crate(improvise_incremental).
|
||||||
not_in_wasm_bundle(improvise,'terminal binary; depends on crossterm').
|
not_in_wasm_bundle(improvise,'terminal binary; depends on crossterm').
|
||||||
|
not_in_wasm_bundle(zset_ffi,'C ABI cdylib/staticlib for CFFI; native only, absent from nix/ci.nix wasmCrates').
|
||||||
wasm_checks_clean(all_bundle_crates,no_feature_flags_needed).
|
wasm_checks_clean(all_bundle_crates,no_feature_flags_needed).
|
||||||
wasm_runtime_hazard(instant_now,'panics on wasm32-unknown-unknown',fixed_with(web_time)).
|
wasm_runtime_hazard(instant_now,'panics on wasm32-unknown-unknown',fixed_with(web_time)).
|
||||||
wasm_runtime_hazard(std_fs,'compiles on wasm but fails in a browser',tracked_as('improvise-fuj')).
|
wasm_runtime_hazard(std_fs,'compiles on wasm but fails in a browser',tracked_as('improvise-fuj')).
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const SECTIONS = [
|
|||||||
["context/adr/005-exact-decimal-arithmetic.md", "adr/005-exact-decimal-arithmetic", "ADR 005 — Exact decimal arithmetic (proposed)"],
|
["context/adr/005-exact-decimal-arithmetic.md", "adr/005-exact-decimal-arithmetic", "ADR 005 — Exact decimal arithmetic (proposed)"],
|
||||||
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
|
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
|
||||||
["context/adr/007-stable-identities-amendment-1.md", "adr/007-stable-identities-amendment-1", "ADR 007, amendment 1"],
|
["context/adr/007-stable-identities-amendment-1.md", "adr/007-stable-identities-amendment-1", "ADR 007, amendment 1"],
|
||||||
["context/adr/009-incremental-evaluation.md", "adr/009-incremental-evaluation", "ADR 009 — Incremental evaluation (proposed)"],
|
["context/adr/009-incremental-evaluation.md", "adr/009-incremental-evaluation", "ADR 009 — Incremental evaluation"],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,6 +69,7 @@ const SECTIONS = [
|
|||||||
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
|
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
|
||||||
["context/code/numtower.md", "code/numtower", "numtower, the numeric tower"],
|
["context/code/numtower.md", "code/numtower", "numtower, the numeric tower"],
|
||||||
["context/code/zset.md", "code/zset", "zset, the Z-set group"],
|
["context/code/zset.md", "code/zset", "zset, the Z-set group"],
|
||||||
|
["context/code/zset-ffi.md", "code/zset-ffi", "zset-ffi, the C ABI"],
|
||||||
["context/code/delivery.md", "code/delivery", "delivery, notices and corrections"],
|
["context/code/delivery.md", "code/delivery", "delivery, notices and corrections"],
|
||||||
["context/code/improvise-incremental.md", "code/improvise-incremental", "improvise-incremental, the plug"],
|
["context/code/improvise-incremental.md", "code/improvise-incremental", "improvise-incremental, the plug"],
|
||||||
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
|
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
|
||||||
@@ -91,6 +92,7 @@ const SECTIONS = [
|
|||||||
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
|
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
|
||||||
["context/test-evidence/numtower.md", "test-evidence/numtower", "numtower"],
|
["context/test-evidence/numtower.md", "test-evidence/numtower", "numtower"],
|
||||||
["context/test-evidence/zset.md", "test-evidence/zset", "zset"],
|
["context/test-evidence/zset.md", "test-evidence/zset", "zset"],
|
||||||
|
["context/test-evidence/zset-ffi.md", "test-evidence/zset-ffi", "zset-ffi"],
|
||||||
["context/test-evidence/delivery.md", "test-evidence/delivery", "delivery"],
|
["context/test-evidence/delivery.md", "test-evidence/delivery", "delivery"],
|
||||||
["context/test-evidence/improvise-incremental.md", "test-evidence/improvise-incremental", "improvise-incremental"],
|
["context/test-evidence/improvise-incremental.md", "test-evidence/improvise-incremental", "improvise-incremental"],
|
||||||
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
|
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
|
||||||
|
|||||||
Reference in New Issue
Block a user