Merge dbsp/step4-aggregates into dbsp/step5-formulas

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (dbsp-backend)
2026-10-10 15:21:28 -07:00
committed by Edward Langley
24 changed files with 4265 additions and 11 deletions
Generated
+5
View File
@@ -910,11 +910,13 @@ dependencies = [
"improvise-incremental",
"improvise-io",
"improvise-protocol",
"proptest",
"serde",
"serde_json",
"tempfile",
"unicode-width",
"web-time",
"zset",
]
[[package]]
@@ -992,9 +994,12 @@ name = "improvise-protocol"
version = "0.1.0-rc2"
dependencies = [
"improvise-core",
"numtower",
"proptest",
"serde",
"serde_json",
"unicode-width",
"zset",
]
[[package]]
+20
View File
@@ -3024,6 +3024,10 @@ rec {
name = "web-time";
packageId = "web-time";
}
{
name = "zset";
packageId = "zset";
}
];
devDependencies = [
{
@@ -3036,6 +3040,10 @@ rec {
packageId = "improvise-protocol";
features = [ "test-support" ];
}
{
name = "proptest";
packageId = "proptest";
}
{
name = "tempfile";
packageId = "tempfile";
@@ -3290,6 +3298,10 @@ rec {
name = "improvise-core";
packageId = "improvise-core";
}
{
name = "numtower";
packageId = "numtower";
}
{
name = "serde";
packageId = "serde";
@@ -3305,8 +3317,16 @@ rec {
name = "unicode-width";
packageId = "unicode-width";
}
{
name = "zset";
packageId = "zset";
}
];
devDependencies = [
{
name = "proptest";
packageId = "proptest";
}
{
name = "serde_json";
packageId = "serde_json";
+227
View File
@@ -0,0 +1,227 @@
# Delivery: the reasoning
ADR 009 step 8 (improvise-z2vr): notices per committed command, watermarks, corrections, the shared
bounded log, the lossless wire and the edge adapters. The decisions are the ADR's clause 9
(`context/adr/009-incremental-evaluation.md`). This file records how the code reads them, the places
the ADR left a choice open, and the places it could not be satisfied yet. The evidence for each test is
in `context/test-evidence/delivery.md`.
**What is built, and against what.** Neither the incremental backend nor ADR 008's render model
exists. So the source of every target is the existing Eager path: the cells a `GridLayout` draws for a
view. Everything below is correct against that source and does not depend on what replaces it. The
circuit will supply "moved output keys" where `Delivery::commit` diffs two `Output`s today, and
nothing else changes.
| piece | where |
|---|---|
| wire values, keys, Z-set elements | `improvise-protocol/src/delivery/wire.rs` |
| stamp, range, notice, merge | `.../notice.rs` |
| request, correction, `Edge` | `.../exchange.rs` |
| the client end: watermark, held cells, when to ask | `.../client.rs` |
| the publisher, the log, live shapes, answering | `improvise-command/src/ui/delivery.rs` |
| `Host` integration, in-process and JSON edges | `ui/host.rs`, `ui/delivery.rs` |
## 1. The wire form
A number crosses as ADR 005's canonical text through `numtower::Num`: `#i0.1`, `#i-0.0`, `inf`,
`-inf`, `nan`. `WireNum` accepts only text the tower itself writes (`parse` re-prints and compares),
because a second spelling of one value would make textual equality wrong. Equality of wire values is
textual, so `-0.0` and `+0.0` are different wire values. That is the opposite of the cell delta in
clause 3, which makes them equal; the two do not meet, because the delta is a store-side signal and a
correction is a value the client displays.
An integer is a JSON number below 2^53 and a decimal string at or above it (`wide`, `wide_weight`).
That covers stamps, positions, window sizes and weights. Text is a JSON string, byte-exact.
`CellValue` is still an `f64`, so `WireValue::of` writes it through `Num::inexact` and
`to_cell_value` narrows an exact number to `f64`. The round-trip properties therefore run over
28-digit decimals and `i128`-wide fractions at the `WireNum` level, where they are lossless, and over
`f64` at the `CellValue` level, where only the NaN payload is dropped.
`CellKey` is not `Serialize`, so a cell is named by `WireKey`, its `(category, item)` tuple. Under
ADR 007 those become ids; nothing here reads the strings.
## 2. Stamps and notices
`Stamp` is `(host instance, document_generation, publication)`. A stamp from another host or
generation is behind every current one (`Stamp::is_behind`). The publication number increments once
per committed command, and it keeps counting across a document swap rather than restarting, so a
number is never reused within one host instance.
**What "a committed command" is.** The host publishes one notice for each `Command::Model` that
lands. A view command (cursor, mode, a view switch) publishes nothing: it is view-store state and
the model store has no business announcing it. A write that changes nothing still publishes, with
empty `moved`, because the contract is one notice per commit and a client counts them.
**A notice names moved keys per live shape**, plus axis changes, relabels, flags, evicted shapes. Axis
changes are computed from the categories' item lists before and after (`axis_changes`): insert and
remove by item, and a reorder when the surviving items changed order. `Relabel` exists and is never
emitted: with names as identity a rename is a remove and an insert, and display writes arrive with
ADR 007. Flags come from comparing the formula texts, `measure_agg`, the views and the default view.
`Notice::merge` joins two notices only when the second starts where the first ends (same epoch).
Anything else returns both unchanged, so a merge cannot hide a loss. `Moved::Correct` absorbs key
lists in a merge.
## 3. Requests and corrections
A request is a shape key, a window and one of three asks. `Ask::Correction` is "bring me up from my
watermark"; `Ask::Fetch` is "the current values of these keys, covering the publications up to
`through`"; `Ask::Notices` is "the notices after this stamp".
**A correction's elements are `target`, not `target - held`.** The host does not know what the client
holds, so it sends the target restricted as the body says, and the client computes `target - held`
with ordinary Z-set subtraction. A `Moved` body restricts both sides to the named keys, so a key the
target no longer holds is removed. A `Window` body replaces everything held.
**`through` is not `at`.** `at` is the state the values were read at, always the latest committed one.
`through` is how far the correction advances the client's watermark. They differ for a `Fetch`:
the client named the keys of the notices it has seen, and a later commit may have moved other keys
whose notice it has not seen yet. Advancing to `at` would make that notice look already applied, so
the watermark advances only to `through` and the later notice refetches its keys. The values are
at least as new as the watermark claims, never older.
**An axis change makes the correction a window refetch.** A moved key list says which values changed,
not which keys sit in the client's window, and an insert or remove shifts cells into and out of it
without moving them. The ADR has the client re-derive positions from the axis changes and fetch what
it lacks. That needs ADR 008's render model, which this layer does not have. So the client records a
notice that moved an axis or set `flags.views` (`restructured`), asks `Fetch { window: true }`, and the host answers with a
`Window` body whose `carried` notice holds the axis changes, in order. This is a fetch, not a
correction, so the required test "a frame `[AddItem, SetCell]` ... and no client asks for a correction"
holds: it asserts no `Ask::Correction` was sent. When the render model exists the client can narrow
the fetch to the cells that entered its window (improvise-bw4p.9).
A view command (`SetAxis`, `TransposeAxes`, a page selection, a hide) moves positions and no key, and
reaches the client only as `flags.views`. So that flag counts as an axis change on both sides: the client
fetches its window, and the host answers a correction over a range holding the flag with a window body.
## 4. The client
`EdgeClient` holds, per shape: the window, the held cells (a `ZSet`), the **watermark** (applied
through), `seen` (the last notice received), the notices received and not yet fetched, `restructured`
and `stale`.
`on_notice` ignores a duplicate (`to <= seen`), treats a gap, another host or generation, a shape in the
evicted list, or `Moved::Correct` as "ask for a correction", and otherwise queues the moved keys. The
watermark advances over a notice only when nothing is owed, so it never claims a key it has not fetched.
`apply` takes a correction. It is a duplicate if the watermark already covers `through`; a `Moved`
body is a gap unless it starts at or below the watermark. Otherwise `held += target - held`, the
watermark becomes `through`, and the queued notices it covers are dropped. A late reply older than
the watermark therefore cannot regress the state, which is what makes a retry safe.
**A correction from a generation or host the client has left is a duplicate, whatever the
watermark.** A shape records the epochs its watermark has moved out of (`left`, the newest 64), and a
correction from one of them, or from an older generation of the host it is on, changes nothing. Epochs of
different hosts are not ordered, so a client that has never applied anything from the newer host cannot
tell a late reply from a dead one from a legitimate first answer, and applies it; once it has moved on, the
dead host's replies are refused. A duplicate answer also prunes every queued entry from another epoch
and moves `seen` onto the live epoch, so a late notice from a dead host costs one extra round and cannot
livelock `sync`.
`poll` hands a pulled notice only to the shape it asked about. A notice carries only that shape's keys,
and applying it to another tracked shape would advance that shape past keys it never saw.
`connection_lost`, `request_failed` and `unreadable` set `stale`, which `wants` turns into
`Ask::Correction` from the watermark the client held. A duplicate whose `through` equals the watermark
also clears `stale`: the host has said there is nothing newer.
**A lost final notice is invisible to a listener.** The next notice shows the gap, but if none comes the
client never learns. `poll` is the answer: it asks `Ask::Notices { after: seen }`, which the host
answers from the log. The property test ends every history with a `poll` for this reason.
`Event`s (`Axis`, `Relabel`, `Flags`, `Resync`) are what the shape's consumer applies to its
structure. They are deduplicated by `events_through`, because an axis change is not idempotent.
`Resync` means positions are unknown: re-derive them from what is held.
## 5. The Eager stand-in for a shape key
The real key is the circuit's: axes, None categories, filters, pages, `measure_agg`, in canonical id
form. Here a shape key is `eager:view/<name>` (`shape_of_view`) and the output is that view's layout.
That is finer than the real key, which is always safe: two views that would share state do not, and
nothing else is lost. A view that no longer exists is announced as evicted.
`Output` is the shape's whole output, keyed by `WireKey` with the position attached. `target` restricts
it to a window. `moved_since` compares two outputs by value and presence; a changed position is an axis
change, not a key.
**This costs O(grid) per live shape per commit.** It stands in for the circuit's delta, and it is the
reason the module exists beside, not instead of, the incremental work: nothing else in the host reads
the whole grid per command.
## 6. The host
`Delivery` is one value per document, held by `Host`. `Host::handle` calls `Delivery::commit` after
each `Command::Model` that lands, and `Host::deliver` answers a `Request`.
**Reads see only the last committed state.** `answer` reads `Live::output`, which `commit` refreshed,
and never the workbook. A value a failed command wrote is not in it.
**Rollback.** `Host::mutating_frame` clones the `Delivery` beside the app snapshot and the session
mirrors, and `undo_frame` puts it back. The clone is cheap: `Arc` for outputs, notices and structure,
and both the log and the outbox hold at most `capacity` notices. So a refused frame consumes no publication number and publishes nothing.
**The ADR and ADR 001 disagree here, and ADR 001 wins until ADR 006 exists.** Clause 9 says a frame
whose later command fails keeps its earlier commands and their notices, "each command commits on its own
(ADR 006)". Today's host undoes the whole frame (ADR 001 clause 4, `Host::mutating_frame`), and ADR 006 is
not in the tree. The required test "a refused command consumes no publication number, and the commands
before it in its frame stay published" is split in two. The first half is tested. The second is
tested as its opposite, `commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001`,
which is the test to flip when per-command commit lands (improvise-bw4p.8): the publisher already supports it, since it
publishes whatever the host tells it committed.
**Nothing is published while nobody can hear it.** With no live shape and no attached transport
(`Host::attach_notices`), a commit only advances the publication number: no structure is read, nothing is
diffed, the log is cleared and the queued structure is forgotten. A shape that goes live, or a transport
that attaches, re-reads the structure, so its first diff is against a known state. Without this every
host pays for delivery on every command while nothing consumes it. The outbox exists only while a
transport is attached, and keeps the newest `capacity` notices; a transport that falls behind sees a gap
and asks for a correction. The axis diff is linear in the items (`diff_items`, set lookups), and its test
counts comparisons on a counting item type rather than reading a clock.
**The host instance** is a random id, never a time (`Host::new` takes one from `RandomState`).
`RandomState` has no entropy on `wasm32-unknown-unknown`, so a worker must pass a real one through
`Host::with_instance`. A restarted host with the same id would accept a stale watermark as current.
`Worker` still calls `Host::new` (improvise-bw4p.7).
**Eviction** (`evict_idle`) takes a local count and an idle length in the same unit, never a wall clock
compared across machines. An evicted key is announced in the next notice and forgotten; naming it again
revives it with `since` set to the current publication, so a watermark older than that is answered with
a window.
## 7. Answering
`covered(shape, watermark)` is true when the watermark is in the current epoch, is not ahead of the
host, and either equals the current publication or is no older than both the log's start and the time
the shape went live. Only then are the moved keys of `(watermark, now]` complete.
- covered, only moved keys: a `Moved` body with those keys' current values in the window;
- covered, with axis changes or relabels in range: a `Window` body with `carried`;
- a `Moved::Correct` in range, or not covered: a `Window` body from `None`, which is `target - empty`.
A notice lists no keys for a shape whose list passed `MAX_KEYS`; it says `Moved::Correct` instead, and
costs every reader a correction. `LOG_CAPACITY` bounds the single shared log. Both are set per host by
`limit_delivery` so a test can reach them.
The token in a request is carried and echoed by the client and never read by the host.
## 8. Edge adapters
`Edge` is one end of a stream: `call(Request) -> Result<Reply, EdgeError>`. `InProcess` is the
function call; `WireEdge` passes request and reply through JSON text, the adapter at a wire boundary,
so the lossless properties are exercised by every test that uses it. Neither adds a `ClientFrame` or
`ServerFrame` variant: the page does not read notices yet, and adding the frames means changing the
browser and the worker together (ADR 008 replaces `Projection`; improvise-bw4p.10). A transport
pushes `Host::take_notices()`.
## Not done, and why
- **The notices are not sent by the socket or worker hosts.** They need frame variants and the page
(above).
- **Positions and column totals are not on the wire.** A correction carries `(key, value)` and no row or
column, and `Output` holds body cells only. The ADR attaches the position when output is sent and lists
totals among the moved keys. Both need the render model and the totals' own keys (ADR 008, clause 10).
- **A twin at the serving end** (a server-side watermark or filter) is not built; the ADR allows it and
does not require it.
- **A pushed filter** is the client's `window`, applied on every request.
- **Hidden-item overlays, pruning queries and column widths** are ADR 008 and clause 10.
+6 -4
View File
@@ -1,10 +1,11 @@
# `improvise-incremental`: the reasoning
`crates/improvise-incremental` is ADR 009 step 2 (improvise-t8ow, epic improvise-bw4p): the
`crates/improvise-incremental` began as ADR 009 step 2 (improvise-t8ow, epic improvise-bw4p): the
plug. It holds the `Derived` trait, `Eager` (today's evaluator behind it) and the
differential harness every later backend is checked with. There is no incremental backend
here, the evaluator is untouched and no existing crate changed. The decisions are the ADR's
(`context/adr/009-incremental-evaluation.md`, clause 2). The evidence for each test is in
differential harness every backend is checked with, and, from step 4 (improvise-u2le), the
first backend that keeps state, `Incremental` (section "Step 4" below). The step 2 sections
describe the trait and the oracle; they predate the backend. The decisions are the ADR's
(`context/adr/009-incremental-evaluation.md`). The evidence for each test is in
`context/test-evidence/improvise-incremental.md`.
## The shape
@@ -18,6 +19,7 @@ here, the evaluator is untouched and no existing crate changed. The decisions ar
| `probes`, `Probe` | every question worth asking of a model, and a way to put it to any backend |
| `differential` | runs one edit sequence on a candidate and on `Eager`, comparing every probe at every step |
| `Divergence`, `Report` | what a failure names (step, probe, both answers) and what a pass compared |
| `Incremental` | the backend of step 4: maintained groups and occupancy; formulas and `Sum`/`Avg` still go to the model (later steps change that) |
## Interpretations
+174
View File
@@ -0,0 +1,174 @@
# Test evidence: delivery
What each delivery test guards and which injected fault it was seen to fail on. Keyed by test name.
The reasoning is in `context/code/delivery.md`. Each fault was put into the source one at a time,
the suite run, the fault reverted with `git checkout -- <file>`, and the suite run again.
The host-side tests are in `crates/improvise-command/src/ui/delivery_tests.rs`, the client's in
`crates/improvise-protocol/src/delivery/client_tests.rs`, the wire's in `delivery/wire.rs`.
## The injected faults, by what failed
- **The host reports no moved keys** (`let keys = BTreeSet::new()` in `Delivery::commit`): seven
host-side tests fail: `a_dropped_connection_or_a_failed_request_triggers_a_correction_and_the_session_resumes`,
`a_pull_only_client_follows_the_log_without_a_correction`,
`a_notice_names_the_moved_cells_including_a_formula_cell_it_did_not_write`,
`values_keep_every_bit_from_the_cell_to_the_client`,
`an_oversized_notice_says_correct_and_costs_every_reader_a_correction`,
`a_dropped_notice_is_detected_and_a_correction_repairs_it` and the random property.
`a_frame_of_two_commands_publishes_one_notice_each_and_no_client_asks_for_a_correction` still
passes, on purpose: its `AddItem` makes the client refetch the window, which also fetches the write.
- **The client treats every notice as contiguous** (`false => true` in `on_notice`):
`a_dropped_notice_is_detected`, `a_dropped_notice_is_detected_and_a_correction_repairs_it` and the
random property.
- **A failed frame does not put the `Delivery` back** (`undo_frame` drops `published`):
`a_refused_command_consumes_no_publication_number`,
`commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001` and
`a_refused_frame_is_never_read_back`.
- **A moved correction replaces instead of subtracting held** (`target` for `target - held_there`):
`a_moved_correction_applies_only_at_the_watermark_it_starts_from` and
`a_moved_key_the_target_no_longer_holds_is_removed`. The same for a window correction (`target` for
`target - held`): `a_correction_equals_the_target_and_a_second_one_is_the_zero_zset`,
`a_correction_replaces_what_is_held_not_adds_to_it` and
`a_correction_already_covered_is_idempotent_and_never_regresses`.
- **A wire number loses the sign of zero** (`Num::inexact(x + 0.0)` in `WireNum::of_f64`):
`a_float_crosses_in_canonical_text_with_the_sign_of_zero` and
`a_float_keeps_every_bit_but_the_nan_payload`.
- **`covered` ignores the log's start and the shape's liveness**:
`a_watermark_the_log_covers_gets_the_moved_keys_and_one_it_does_not_gets_the_window` and
`an_evicted_shape_is_announced_and_its_reader_corrects`.
- **The client does not remember an axis change** (`restructured` never set):
`an_axis_change_that_shifts_an_unmoved_cell_into_the_window_refetches_the_window`. The host's own
`restructured` test alone fails only the random property, since the client's `window` flag also forces
a window.
- **`Stamp::same_epoch` ignores the host instance**:
`a_notice_from_another_host_or_generation_asks_for_a_correction`,
`a_stamp_from_another_epoch_is_behind_every_current_one`,
`a_reconnect_after_a_host_restart_asks_for_a_correction_from_the_old_watermark` and
`a_notice_from_a_restarted_host_asks_for_a_correction_too`.
## Entries
### a_refused_frame_is_never_read_back
The first fixture filled the document to the category limit and then refused the frame at its basis
check, so the write never ran and the test passed with the `Delivery` not restored. A fixture that fails
in a wider shape than the fault: the frame never reached `mutating_frame`. It now writes North, then
loads a missing document, and asserts `Rejection::MutationRefused` so the write is known to have run. The
rival above fails it with `77.0` held.
### a_corrected_state_is_the_target_and_correcting_it_again_is_the_zero_zset
The random property: histories of up to twelve single-command frames (writes of `0.0`, `-0.0`, both
infinities, NaN and `0.1 + 0.2`, item adds and removes, a formula), a random window, notices delivered
or dropped, connections lost, requests dropped, replies dropped and garbled. It ends with a `poll`, then
asserts the client holds exactly what the grid draws in its window, and that `target - held` is the zero
Z-set. The oracle (`drawn`) reads `GridLayout` itself and shares nothing with `Output`.
Without the closing `poll` a lost last notice is not detectable by a listener, and the property
reported it as a stale client (minimal case: one `Remove`, notice dropped, no later notice).
The first version of the property also failed on a window of one cell after a remove, and that was a
real gap in the correction, not in the test: the remove moved South into the window without moving its
value. It is the reason for the `restructured` rule (`delivery.md` §3).
### a_frame_of_two_commands_publishes_one_notice_each_and_no_client_asks_for_a_correction
Step 8's first required test. The frame's first command carries one basis stating both records
(`Host`'s frame rule: the first mutation's basis stands for the frame), which the test helper builds.
### commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001
Step 8 requires that the commands before a refused one stay published. Today's host undoes the whole
frame (ADR 001 clause 4), so this test asserts the opposite and names the reason. Flip it when ADR 006
makes each command commit on its own.
### a_dropped_connection_or_a_failed_request_triggers_a_correction_and_the_session_resumes
The three transport faults (request dropped, reply dropped, reply unreadable) each leave the client
asking for `Ask::Correction` from the watermark it held, and the next request carries the same session
token and that watermark.
### an_axis_change_that_shifts_an_unmoved_cell_into_the_window_refetches_the_window
A one-cell window, a remove that moves South into it. The notice lists only North, so a client that
fetches moved keys alone ends with an empty window.
## Review of #348, round one
Each test below was written against the behaviour the review reported and failed before its fix, or, for
the cost tests, was run against a quadratic rival.
### diffing_a_category_compares_each_item_a_bounded_number_of_times
Items are a type whose `eq` is counted. Rival: `contains` on the slices in `diff_items`, which makes the
count quadratic and fails every size. A wall-clock test would have passed on a fast machine.
### a_commit_with_nothing_live_and_no_transport_reads_nothing
Counts `Structure::of` calls. Rival: the idle skip removed.
### the_outbox_and_the_clone_a_frame_takes_are_bounded
Rival: the outbox truncation removed, so twenty commits leave twenty queued.
### a_late_window_reply_from_an_older_generation_does_not_regress
Rival: the older-generation guard disabled (fails this test in the protocol crate).
### a_late_notice_from_a_dead_host_settles_against_the_live_one
Rival: the duplicate arm no longer prunes other epochs' entries; the random property also fails.
### a_poll_for_one_shape_leaves_the_other_shapes_alone
Rival: pulled notices applied to every shape.
### a_transpose_empties_a_partial_window_of_the_cell_that_left_it
A transpose moves no key. Rival: `flags.views` not counted as a structural change in the client and the
host. `a_dropped_views_notice_is_answered_with_the_window_not_an_empty_key_list` fails with the host
alone changed.
### wide_integers_are_canonical_on_input
Both spellings are refused outside their range: a JSON number at or above 2^53, a string below it, a
sign, leading zeros and trailing space.
### an_item_insert_position_crosses_as_a_wide_integer
`ItemInserted.at` is a `u128` through `wide`.
### a_fetch_covers_only_the_notices_it_named_when_a_later_commit_is_already_in_the_state
Rival: a fetch answered with `through: at`. The first version of the random property did not catch it (it
delivered each notice before the next commit); the property now splits, delays, duplicates and replays
notices and old replies, and fails the rival in most runs (17 of 20 at 256 cases, measured on the
author's machine); this test fails it on every run, by name.
### equal_values_compare_equal_whatever_the_zero_counts_say
`ExactSum`'s equality compared the sign-of-zero counters even when the total was nonzero.
## Review of #348, round two
### a_late_window_reply_from_a_dead_host_does_not_regress
A client that has applied a correction from host 2 receives a late `Window` reply from host 1. Epochs of
different hosts are unordered, so the first fix (older generation of the same host) did not cover it.
Rival: the `left` check removed; this test and the two below fail.
### a_reply_from_a_generation_the_client_left_does_not_return
Generations 1, 2, 3 of one host; a late generation-2 reply. Rival: both guards removed.
### a_reply_held_across_a_host_restart_cannot_pull_the_client_back
The host-side form: the reply is computed and lost, the host restarts, the client settles against the new
host, and the old reply then arrives. Rival: the `left` check removed.
**The random property, round two.** The property now checks after every step that the watermark's epoch never goes back to one it had left
(so a mid-history regression cannot be repaired by the closing `poll`), and replays an earlier reply after
every restart and swap. Rival: the `left` check removed fails it. The older-generation guard on its own is
not separable from `left` in the histories the property reaches, so it is held by
`a_late_window_reply_from_an_older_generation_does_not_regress` alone.
+27
View File
@@ -466,3 +466,30 @@ factors (3, 77, 2^20, 3^40), so the lcm and the product differ.
The rival combines over the product of the denominators. Its value is right, so only the
lcm assertion can see it. That is why the property asserts the denominator before the
value.
### a_sum_of_negative_zeros_is_negative_zero
`ExactSum` kept only limbs, so every zero sum read `+0.0` and a lone `-0.0` addend lost its sign
(ADR 009 clause 8 asks for IEEE's). Injected fault: `negative_zero()` always false. This test, the
property below and the retraction property all fail, each by name.
### a_zero_sum_has_the_sign_ieee_gives_the_same_addends
The oracle is an IEEE fold from `-0.0` over the same addends. The generator builds zero sums on
purpose (zeros of either sign plus cancelling pairs, shuffled), because filtering random addends
for a zero sum rejected nearly every case. Rival: the sign rule without the `others` count
(`neg_zeros > 0` alone) fails `one_positive_zero_makes_the_zero_positive_and_so_does_a_cancellation`
and both properties, since one `+0.0` or one cancelling pair must make the zero positive.
### a_retracted_addend_leaves_the_sign_of_the_rest
Adds `keep` and `gone`, then retracts `gone`; the read and the sum itself must equal a fresh sum
of `keep`. It is why both counters are net weights and why `PartialEq` compares the sign.
### a_sum_that_is_not_zero_never_reads_as_negative_zero
The first version of the sign rule returned `-0.0` whenever the `-0.0` count was positive and the
other count was nil, without checking that the finite total was zero. The weights here (+5.0 and -7.0
net to one "other" addend, and the `-0.0` is held) make the counts say "negative zero" over a total of
-2. Found by the existing `the_accumulator_reads_the_exact_sum_or_its_stated_roundings`, which read
`#i-0.0` for a nonzero sum; the rival is that version.
+2
View File
@@ -12,6 +12,7 @@ improvise-protocol = { path = "../improvise-protocol" }
improvise-formula = { path = "../improvise-formula" }
improvise-io = { path = "../improvise-io" }
improvise-gp = { path = "../improvise-gp" }
zset = { path = "../zset" }
# The cell delta behind the record tier and the dirty flag (ADR 009 step 3).
improvise-incremental = { path = "../improvise-incremental" }
anyhow = "1"
@@ -28,3 +29,4 @@ web-time = "1"
improvise-core = { path = "../improvise-core", features = ["test-support"] }
improvise-protocol = { path = "../improvise-protocol", features = ["test-support"] }
tempfile = "3"
proptest = "1"
+573
View File
@@ -0,0 +1,573 @@
//! The host end of delivery: one notice per committed command, the shared bounded
//! log, and corrections (ADR 009 clause 9). Why: `context/code/delivery.md` §5-§7.
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use std::sync::Arc;
use improvise_core::view::layout::GridLayout;
use improvise_core::workbook::Workbook;
use improvise_protocol::delivery::wire::{self, Cell, WireKey, WireValue};
use improvise_protocol::delivery::{
Ask, AxisChange, Correction, CorrectionBody, Edge, EdgeError, Flags, Moved, Notice, PubRange,
Reply, Request, ShapeKey, Stamp, Window,
};
use zset::ZSet;
use crate::ui::host::Host;
/// The most notices the log keeps, and the most keys one shape lists in one notice.
pub const LOG_CAPACITY: usize = 1024;
pub const MAX_KEYS: usize = 4096;
const EAGER: &str = "eager:view/";
/// The Eager stand-in for a shape key: the view it draws (`context/code/delivery.md` §5).
pub fn shape_of_view(view: &str) -> ShapeKey {
ShapeKey(format!("{EAGER}{view}"))
}
fn view_of(shape: &ShapeKey) -> Option<&str> {
shape.0.strip_prefix(EAGER)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Placed {
row: u128,
col: u128,
value: WireValue,
}
/// A shape's whole output, keyed by tuple identity with the position attached.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Output {
cells: BTreeMap<WireKey, Placed>,
}
impl Output {
pub fn of(wb: &Workbook, view: &str) -> Option<Output> {
let model = &wb.model;
let layout = GridLayout::with_frozen_records(model, wb.views.get(view)?, None);
let eval = layout.evaluator(model);
let mut cells = BTreeMap::new();
for row in 0..layout.row_count() {
for col in 0..layout.col_count() {
let Some(key) = layout.cell_key(row, col) else {
continue;
};
let value = match layout.is_records_mode() {
true => layout.records_value(row, col),
false => eval.cell(&key),
};
if let Some(value) = value {
cells.insert(
WireKey::of(&key),
Placed {
row: row as u128,
col: col as u128,
value: WireValue::of(&value),
},
);
}
}
}
Some(Output { cells })
}
/// The cells in `window` as a Z-set of weight one: the target a correction is read from.
pub fn target(&self, window: &Window, only: Option<&BTreeSet<WireKey>>) -> ZSet<Cell> {
self.cells
.iter()
.filter(|(k, p)| window.contains(p.row, p.col) && only.is_none_or(|s| s.contains(k)))
.map(|(k, p)| ((k.clone(), p.value.clone()), 1))
.collect()
}
/// The keys whose value, or presence, differs. A moved position is an axis change, not a key.
fn moved_since(&self, before: &Output) -> BTreeSet<WireKey> {
let changed = self
.cells
.iter()
.filter(|(k, p)| before.cells.get(*k).is_none_or(|b| b.value != p.value));
let gone = before.cells.keys().filter(|k| !self.cells.contains_key(*k));
changed.map(|(k, _)| k).chain(gone).cloned().collect()
}
}
/// What a notice reports besides cells: the items on each axis and the document-wide settings.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct Structure {
categories: Vec<(String, Vec<String>)>,
formulas: Vec<String>,
measure_agg: String,
views: String,
default_view: String,
}
#[cfg(test)]
thread_local! {
pub(crate) static STRUCTURE_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
impl Structure {
fn of(wb: &Workbook) -> Self {
#[cfg(test)]
STRUCTURE_READS.with(|n| n.set(n.get() + 1));
let model = &wb.model;
let aggs: BTreeMap<_, _> = model.measure_agg.iter().collect();
Self {
categories: model
.categories
.iter()
.map(|(name, cat)| (name.clone(), cat.items.keys().cloned().collect()))
.collect(),
formulas: model.formulas().iter().map(|f| f.raw.clone()).collect(),
measure_agg: format!("{aggs:?}"),
views: format!("{:?}", wb.views),
default_view: wb.active_view.clone(),
}
}
fn since(&self, before: &Structure) -> (Vec<AxisChange>, Flags) {
let flags = Flags {
formulas: self.formulas != before.formulas,
measure_agg: self.measure_agg != before.measure_agg,
views: self.views != before.views,
default_view: self.default_view != before.default_view,
};
(axis_changes(&before.categories, &self.categories), flags)
}
}
fn axis_changes(
before: &[(String, Vec<String>)],
after: &[(String, Vec<String>)],
) -> Vec<AxisChange> {
let held: BTreeMap<_, _> = before.iter().map(|(c, i)| (c.as_str(), i)).collect();
let now: BTreeSet<_> = after.iter().map(|(c, _)| c.as_str()).collect();
let gone = before
.iter()
.filter(|(c, _)| !now.contains(c.as_str()))
.map(|(category, _)| AxisChange::CategoryRemoved {
category: category.clone(),
});
let rest = after.iter().flat_map(|(category, items)| {
let Some(was) = held.get(category.as_str()) else {
let added = AxisChange::CategoryAdded {
category: category.clone(),
};
let inserted = items
.iter()
.enumerate()
.map(|(at, item)| AxisChange::ItemInserted {
category: category.clone(),
item: item.clone(),
at: at as u128,
});
return std::iter::once(added).chain(inserted).collect::<Vec<_>>();
};
item_changes(category, was, items)
});
gone.chain(rest).collect()
}
/// What changed between two item lists, in one pass over each: set lookups, never `contains`.
pub(crate) struct ItemDiff<'a, T> {
pub removed: Vec<&'a T>,
pub inserted: Vec<(usize, &'a T)>,
pub reordered: bool,
}
pub(crate) fn diff_items<'a, T: Eq + std::hash::Hash>(
was: &'a [T],
now: &'a [T],
) -> ItemDiff<'a, T> {
let had: HashSet<&T> = was.iter().collect();
let has: HashSet<&T> = now.iter().collect();
let removed = was.iter().filter(|i| !has.contains(i)).collect();
let inserted = now
.iter()
.enumerate()
.filter(|(_, i)| !had.contains(i))
.collect();
let kept_before = was.iter().filter(|i| has.contains(i));
let kept_after = now.iter().filter(|i| had.contains(i));
ItemDiff {
removed,
inserted,
reordered: !kept_before.eq(kept_after),
}
}
fn item_changes(category: &str, was: &[String], now: &[String]) -> Vec<AxisChange> {
if was == now {
return Vec::new();
}
let diff = diff_items(was, now);
let removed = diff
.removed
.into_iter()
.map(|item| AxisChange::ItemRemoved {
category: category.into(),
item: item.clone(),
});
let inserted = diff
.inserted
.into_iter()
.map(|(at, item)| AxisChange::ItemInserted {
category: category.into(),
item: item.clone(),
at: at as u128,
});
let reordered = diff.reordered.then(|| AxisChange::ItemsReordered {
category: category.into(),
items: now.to_vec(),
});
removed.chain(inserted).chain(reordered).collect()
}
#[derive(Debug, Clone)]
struct Live {
view: String,
since: u64,
last_touch: u64,
output: Arc<Output>,
}
/// The publisher, the shared log and the live shapes of one document. Cheap to clone,
/// so a frame that fails can put it back (`context/code/delivery.md` §6).
#[derive(Debug, Clone)]
pub struct Delivery {
host: u64,
generation: u64,
publication: u64,
log: VecDeque<Arc<Notice>>,
capacity: usize,
max_keys: usize,
live: BTreeMap<ShapeKey, Live>,
structure: Option<Arc<Structure>>,
attached: bool,
evicted: BTreeSet<ShapeKey>,
outbox: Vec<Arc<Notice>>,
}
impl Delivery {
/// `host` is a random instance id, never a time (clause 9).
pub fn new(host: u64, generation: u64) -> Self {
Self {
host,
generation,
publication: 0,
log: VecDeque::new(),
capacity: LOG_CAPACITY,
max_keys: MAX_KEYS,
live: BTreeMap::new(),
structure: None,
attached: false,
evicted: BTreeSet::new(),
outbox: Vec::new(),
}
}
/// A transport is (or is not) draining notices. Without one, and with no live shape, a
/// commit only counts: nothing is read, diffed or kept.
pub fn set_attached(&mut self, on: bool, wb: &Workbook) {
self.attached = on;
if on && self.structure.is_none() {
self.structure = Some(Arc::new(Structure::of(wb)));
}
}
pub fn set_limits(&mut self, capacity: usize, max_keys: usize) {
self.capacity = capacity;
self.max_keys = max_keys;
}
pub fn stamp(&self) -> Stamp {
Stamp {
host: self.host,
generation: self.generation,
publication: self.publication,
}
}
pub fn log_len(&self) -> usize {
self.log.len()
}
pub fn is_live(&self, shape: &ShapeKey) -> bool {
self.live.contains_key(shape)
}
/// Notices published since the last call, for the transport to push.
pub fn take_outbox(&mut self) -> Vec<Notice> {
std::mem::take(&mut self.outbox)
.into_iter()
.map(|n| (*n).clone())
.collect()
}
/// One command committed: publish its notice. The document is read once, here, and every
/// read after it sees this state until the next commit.
pub fn commit(&mut self, wb: &Workbook, generation: u64) -> Stamp {
let new_epoch = generation != self.generation;
if new_epoch {
self.generation = generation;
self.log.clear();
}
self.publication += 1;
if !self.attached && self.live.is_empty() {
self.log.clear();
self.structure = None;
self.evicted.clear();
return self.stamp();
}
let range = PubRange::one(self.stamp());
let structure = Arc::new(Structure::of(wb));
let (axis, flags) = match (new_epoch, self.structure.as_deref()) {
(false, Some(before)) => structure.since(before),
_ => (Vec::new(), all_flags()),
};
self.structure = Some(structure);
let mut notice = Notice::empty(range);
notice.axis = axis;
notice.flags = flags;
notice.evicted = std::mem::take(&mut self.evicted);
let max_keys = self.max_keys;
self.live.retain(|shape, live| {
let Some(output) = Output::of(wb, &live.view) else {
notice.evicted.insert(shape.clone());
return false;
};
let keys = output.moved_since(&live.output);
let moved = match (new_epoch, keys.len() > max_keys) {
(false, false) => Moved::Keys(keys),
_ => Moved::Correct,
};
if moved != Moved::Keys(BTreeSet::new()) {
notice.moved.insert(shape.clone(), moved);
}
live.output = Arc::new(output);
true
});
let notice = Arc::new(notice);
self.log.push_back(Arc::clone(&notice));
while self.log.len() > self.capacity {
self.log.pop_front();
}
if self.attached {
self.outbox.push(notice);
let excess = self.outbox.len().saturating_sub(self.capacity);
self.outbox.drain(..excess);
}
self.stamp()
}
/// A request names `shape`: it goes live if it was not, and is not idle.
pub fn touch(&mut self, shape: &ShapeKey, wb: &Workbook, now: u64) {
match self.live.get_mut(shape) {
Some(live) => live.last_touch = now,
None => {
let Some(view) = view_of(shape) else { return };
if self.structure.is_none() {
self.structure = Some(Arc::new(Structure::of(wb)));
}
let Some(output) = Output::of(wb, view) else {
return;
};
self.live.insert(
shape.clone(),
Live {
view: view.to_string(),
since: self.publication,
last_touch: now,
output: Arc::new(output),
},
);
}
}
}
/// Drop shapes no request has named for `idle`, a local duration in the caller's unit.
pub fn evict_idle(&mut self, now: u64, idle: u64) -> Vec<ShapeKey> {
let gone: Vec<_> = self
.live
.iter()
.filter(|(_, l)| now.saturating_sub(l.last_touch) >= idle)
.map(|(s, _)| s.clone())
.collect();
for shape in &gone {
self.live.remove(shape);
self.evicted.insert(shape.clone());
}
gone
}
/// Answer a request from the last committed state.
pub fn answer(&mut self, wb: &Workbook, request: &Request, now: u64) -> Reply {
self.touch(&request.shape, wb, now);
match &request.ask {
Ask::Notices { after } => match self.covered(&request.shape, Some(after)) {
Some(after) => Reply::Notices(self.notices_after(&request.shape, after, None)),
None => Reply::Correction(Box::new(self.window(request, None, self.stamp(), None))),
},
Ask::Correction { watermark } => match self.covered(&request.shape, watermark.as_ref())
{
Some(from) => self.moved(request, from, self.stamp(), None, false),
None => Reply::Correction(Box::new(self.window(request, None, self.stamp(), None))),
},
Ask::Fetch {
watermark,
keys,
through,
window,
} => {
let sane =
through.same_epoch(&self.stamp()) && through.publication <= self.publication;
match (self.covered(&request.shape, watermark.as_ref()), sane) {
(Some(from), true) if from.publication <= through.publication => {
self.moved(request, from, *through, Some(keys), *window)
}
_ => {
Reply::Correction(Box::new(self.window(request, None, self.stamp(), None)))
}
}
}
}
}
/// The watermark, if the log and the shape's liveness reach back to it.
fn covered(&self, shape: &ShapeKey, watermark: Option<&Stamp>) -> Option<Stamp> {
let w = *watermark?;
let live = self.live.get(shape)?;
let log_start = self.log.front().map_or(self.publication, |n| n.range.from);
let reaches = w.publication <= self.publication
&& (w.publication == self.publication
|| (w.publication >= log_start && w.publication >= live.since));
(w.same_epoch(&self.stamp()) && reaches).then_some(w)
}
fn notices_after(&self, shape: &ShapeKey, after: Stamp, through: Option<Stamp>) -> Vec<Notice> {
self.log
.iter()
.filter(|n| n.range.to > after.publication)
.filter(|n| through.is_none_or(|t| n.range.to <= t.publication))
.map(|n| n.for_shape(shape))
.collect()
}
fn window(
&self,
request: &Request,
from: Option<Stamp>,
through: Stamp,
carried: Option<Notice>,
) -> Correction {
let target = self
.live
.get(&request.shape)
.map(|l| l.output.target(&request.window, None))
.unwrap_or_default();
Correction {
shape: request.shape.clone(),
at: self.stamp(),
from,
through,
body: CorrectionBody::Window,
target: wire::encode(&target),
carried,
}
}
fn moved(
&self,
request: &Request,
from: Stamp,
through: Stamp,
only: Option<&BTreeSet<WireKey>>,
window: bool,
) -> Reply {
let covering = self.notices_after(&request.shape, from, Some(through));
let oversized = covering
.iter()
.any(|n| n.moved.get(&request.shape) == Some(&Moved::Correct));
let carried = covering
.iter()
.cloned()
.reduce(|a, b| a.merge(b).unwrap_or_else(|e| e.1));
let restructured = covering
.iter()
.any(|n| !n.axis.is_empty() || !n.relabels.is_empty() || n.flags.views);
if oversized {
return Reply::Correction(Box::new(self.window(request, None, self.stamp(), None)));
}
if window || restructured {
return Reply::Correction(Box::new(self.window(request, Some(from), through, carried)));
}
let keys: BTreeSet<WireKey> = match only {
Some(keys) => keys.clone(),
None => covering
.iter()
.filter_map(|n| n.moved.get(&request.shape))
.flat_map(|m| match m {
Moved::Keys(k) => k.iter().cloned().collect::<Vec<_>>(),
Moved::Correct => Vec::new(),
})
.collect(),
};
let target = self
.live
.get(&request.shape)
.map(|l| l.output.target(&request.window, Some(&keys)))
.unwrap_or_default();
Reply::Correction(Box::new(Correction {
shape: request.shape.clone(),
at: self.stamp(),
from: Some(from),
through,
body: CorrectionBody::Moved { keys },
target: wire::encode(&target),
carried,
}))
}
}
fn all_flags() -> Flags {
Flags {
formulas: true,
measure_agg: true,
views: true,
default_view: true,
}
}
/// An edge that is a function call: the host in this process.
pub struct InProcess<'a> {
pub host: &'a mut Host,
pub now: u64,
}
impl Edge for InProcess<'_> {
fn call(&mut self, request: Request) -> Result<Reply, EdgeError> {
Ok(self.host.deliver(&request, self.now))
}
}
/// An edge whose request and reply cross as JSON text: the adapter at a wire boundary.
pub struct WireEdge<E>(pub E);
impl<E: Edge> Edge for WireEdge<E> {
fn call(&mut self, request: Request) -> Result<Reply, EdgeError> {
let sent = serde_json::to_string(&request).map_err(|_| EdgeError::Unreadable)?;
let arrived = serde_json::from_str(&sent).map_err(|_| EdgeError::Unreadable)?;
let reply = self.0.call(arrived)?;
let sent = serde_json::to_string(&reply).map_err(|_| EdgeError::Unreadable)?;
serde_json::from_str(&sent).map_err(|_| EdgeError::Unreadable)
}
}
/// A notice as it crosses a wire.
pub fn notice_over_the_wire(notice: &Notice) -> Result<Notice, EdgeError> {
let sent = serde_json::to_string(notice).map_err(|_| EdgeError::Unreadable)?;
serde_json::from_str(&sent).map_err(|_| EdgeError::Unreadable)
}
File diff suppressed because it is too large Load Diff
+61 -5
View File
@@ -2,9 +2,11 @@
//! *document*, many clients per `App` — see `context/code/command.md`.
use crate::ui::app::App;
use crate::ui::delivery::Delivery;
use crate::ui::effect;
use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions};
use crate::ui::reduce::{self, Checked, CheckedBatch};
use improvise_protocol::delivery::{Notice, Reply, Request, ShapeKey};
use improvise_protocol::{AppMode, Command, Rejection};
use improvise_protocol::{ClientFrame, ServerFrame};
@@ -22,16 +24,60 @@ pub struct Addressed {
pub struct Host {
app: App,
sessions: Sessions,
delivery: Delivery,
}
impl Host {
pub fn new(app: App) -> Self {
use std::hash::{BuildHasher, Hasher};
let instance = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
Self::with_instance(app, instance)
}
/// A host with a chosen instance id, which must differ from every earlier run's
/// (`context/code/delivery.md` §6).
pub fn with_instance(app: App, instance: u64) -> Self {
let delivery = Delivery::new(instance, app.document_generation());
Self {
app,
sessions: Sessions::default(),
delivery,
}
}
pub fn delivery(&self) -> &Delivery {
&self.delivery
}
/// Answer a delivery request from the last committed state. `now` is a local monotonic count.
pub fn deliver(&mut self, request: &Request, now: u64) -> Reply {
self.delivery
.answer(&self.app.model_state.workbook, request, now)
}
/// Forget shapes no request has named for `idle` (a local duration in `now`'s unit).
pub fn evict_idle(&mut self, now: u64, idle: u64) -> Vec<ShapeKey> {
self.delivery.evict_idle(now, idle)
}
/// How many notices the shared log keeps, and how many keys one shape lists in a notice.
pub fn limit_delivery(&mut self, capacity: usize, max_keys: usize) {
self.delivery.set_limits(capacity, max_keys);
}
/// A transport will drain `take_notices`; until then nothing is queued for it.
pub fn attach_notices(&mut self) {
self.delivery
.set_attached(true, &self.app.model_state.workbook);
}
/// Notices published since the last call, for the transport to push.
pub fn take_notices(&mut self) -> Vec<Notice> {
self.delivery.take_outbox()
}
pub fn app(&self) -> &App {
&self.app
}
@@ -224,11 +270,12 @@ impl Host {
fn mutating_frame(&mut self, id: SessionId, batch: CheckedBatch<'_>) -> Vec<Addressed> {
let undo = self.app.snapshot();
let mirrors = self.sessions.clone();
let published = self.delivery.clone();
let mut out = Vec::new();
let mut asker_reset = false;
for cmd in batch {
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
return self.undo_frame(id, undo, mirrors, reason);
return self.undo_frame(id, (undo, mirrors, published), reason);
}
// Only a mutation runs `apply_effects`, which resets both flags — so
// after anything else they are STALE. A refusal need not abort (cz8d).
@@ -252,7 +299,7 @@ impl Host {
out.extend(answered);
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
if mutation && failed {
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
return self.undo_frame(id, (undo, mirrors, published), Rejection::MutationRefused);
}
}
// Last, after everything the frame owed: a client folds the answer to
@@ -269,12 +316,12 @@ impl Host {
fn undo_frame(
&mut self,
id: SessionId,
undo: crate::ui::app::AppSnapshot,
mirrors: Sessions,
(undo, mirrors, published): (crate::ui::app::AppSnapshot, Sessions, Delivery),
reason: Rejection,
) -> Vec<Addressed> {
self.app.restore(undo);
self.sessions = mirrors;
self.delivery = published;
vec![Addressed {
to: id,
msg: ServerFrame::Rejected(reason),
@@ -284,7 +331,16 @@ impl Host {
/// The one door (gotcha 36); the match that follows is the mechanism (gotcha 39):
/// asking for the `Dirty` means naming the case where a `Snapshot` is owed.
fn command(&mut self, id: SessionId, cmd: Checked<'_>) -> Vec<Addressed> {
match self.sessions.apply_checked(&mut self.app, id, cmd) {
let mutation = matches!(cmd.command(), Command::Model { .. });
let applied = self.sessions.apply_checked(&mut self.app, id, cmd);
let landed = !matches!(applied, Applied::Rejected { .. })
&& !(self.app.last_batch_refused() || self.app.last_batch_aborted());
if mutation && landed {
let generation = self.app.document_generation();
self.delivery
.commit(&self.app.model_state.workbook, generation);
}
match applied {
// The document was replaced: nothing to diff and nothing to broadcast,
// each snapshot carrying a whole view slice and its seed.
Applied::Rebased { snapshots } => snapshots
+4
View File
@@ -1,7 +1,11 @@
pub mod app;
pub mod cat_tree;
pub mod delivery;
pub mod effect;
pub mod host;
pub mod project;
pub mod reduce;
pub mod viewport;
#[cfg(test)]
mod delivery_tests;
+4
View File
@@ -8,6 +8,9 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
improvise-core = { path = "../improvise-core" }
# ADR 009 clause 9: the wire form carries a correction as Z-set arithmetic, a value as ADR 005 canonical text.
zset = { path = "../zset" }
numtower = { path = "../numtower" }
serde = { version = "1", features = ["derive"] }
# For the one width rule both front ends measure a header label with
# (`AxisFrame::header_widths`). A terminal column is a display cell, not a
@@ -24,3 +27,4 @@ test-support = ["dep:serde_json"]
[dev-dependencies]
serde_json = "1"
proptest = "1"
@@ -0,0 +1,404 @@
//! The client end of an edge: watermarks, the held window, and when to ask (ADR 009 clause 9).
//! Why each choice: `context/code/delivery.md` §4.
use std::collections::{BTreeMap, BTreeSet};
use zset::ZSet;
use super::exchange::{Ask, Correction, CorrectionBody, Edge, EdgeError, Reply, Request};
use super::exchange::{SessionToken, Window};
use super::notice::{AxisChange, Flags, Moved, Notice, Relabel, ShapeKey, Stamp};
use super::wire::{Cell, WireKey, decode};
/// What the shape's consumer must do with the structure around the cells.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Axis(AxisChange),
Relabel(Relabel),
Flags(Flags),
/// Positions and labels are unknown: re-derive them from what is held.
Resync,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Want {
Correction {
watermark: Option<Stamp>,
},
Fetch {
watermark: Option<Stamp>,
keys: BTreeSet<WireKey>,
through: Stamp,
window: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
/// Applied; `delta` is what changed in the held cells.
Applied { delta: ZSet<Cell> },
/// Already covered by the watermark: nothing was done.
Duplicate,
/// Starts after the watermark, or in another epoch: ask again from the watermark.
Gap,
/// Names a shape this client does not read.
Untracked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Synced {
Settled,
Failed,
/// The rounds ran out with work left: a defect in the edge, not a state to wait out.
Exhausted,
}
/// How many abandoned epochs a shape remembers.
const LEFT_EPOCHS: usize = 64;
#[derive(Debug, Clone)]
struct Tracked {
window: Window,
held: ZSet<Cell>,
watermark: Option<Stamp>,
seen: Option<Stamp>,
/// Notices received and not yet fetched: `(publication, what moved)`.
unapplied: Vec<(Stamp, Moved)>,
/// The last notice that moved an axis: until a window is read past it, positions are unknown.
restructured: Option<Stamp>,
/// Epochs the watermark has moved out of: nothing from them applies again.
left: Vec<(u64, u64)>,
stale: bool,
}
#[derive(Debug, Clone, Default)]
pub struct EdgeClient {
session: Option<SessionToken>,
shapes: BTreeMap<ShapeKey, Tracked>,
events_through: Option<Stamp>,
}
impl EdgeClient {
pub fn new(session: Option<SessionToken>) -> Self {
Self {
session,
..Self::default()
}
}
/// Start reading `shape`; the first thing it wants is a full refetch.
pub fn track(&mut self, shape: ShapeKey, window: Window) {
self.shapes.insert(
shape,
Tracked {
window,
held: ZSet::new(),
watermark: None,
seen: None,
unapplied: Vec::new(),
restructured: None,
left: Vec::new(),
stale: true,
},
);
}
pub fn held(&self, shape: &ShapeKey) -> Option<&ZSet<Cell>> {
self.shapes.get(shape).map(|t| &t.held)
}
pub fn watermark(&self, shape: &ShapeKey) -> Option<Stamp> {
self.shapes.get(shape).and_then(|t| t.watermark)
}
pub fn wants(&self, shape: &ShapeKey) -> Option<Want> {
let t = self.shapes.get(shape)?;
let needs_all = t.stale || t.unapplied.iter().any(|(_, m)| *m == Moved::Correct);
let keys: BTreeSet<WireKey> = t
.unapplied
.iter()
.flat_map(|(_, m)| match m {
Moved::Keys(keys) => keys.iter().cloned().collect::<Vec<_>>(),
Moved::Correct => Vec::new(),
})
.collect();
let owed = !t.unapplied.is_empty() || t.restructured.is_some();
match (needs_all, t.seen) {
(true, _) => Some(Want::Correction {
watermark: t.watermark,
}),
(false, Some(through)) if owed => Some(Want::Fetch {
watermark: t.watermark,
keys,
through,
window: t.restructured.is_some(),
}),
_ => None,
}
}
pub fn request(&self, shape: &ShapeKey, want: Want) -> Option<Request> {
let t = self.shapes.get(shape)?;
Some(Request {
session: self.session.clone(),
shape: shape.clone(),
window: t.window,
ask: match want {
Want::Correction { watermark } => Ask::Correction { watermark },
Want::Fetch {
watermark,
keys,
through,
window,
} => Ask::Fetch {
watermark,
keys,
through,
window,
},
},
})
}
/// A pushed notice. Duplicates are ignored, a gap or a new epoch makes every shape ask.
pub fn on_notice(&mut self, notice: &Notice) -> Vec<Event> {
self.accept(notice, None)
}
/// `only` limits the notice to one shape: a pulled notice carries no other shape's keys.
fn accept(&mut self, notice: &Notice, only: Option<&ShapeKey>) -> Vec<Event> {
let end = notice.stamp();
for (shape, t) in &mut self.shapes {
if only.is_some_and(|o| o != shape) {
continue;
}
let contiguous = match t.seen {
Some(seen) if seen.same_epoch(&end) => match notice.range.to <= seen.publication {
true => continue,
false => notice.range.from <= seen.publication,
},
_ => false,
};
t.seen = Some(end);
if !contiguous || notice.evicted.contains(shape) {
t.unapplied.push((end, Moved::Correct));
continue;
}
if !notice.axis.is_empty() || !notice.relabels.is_empty() || notice.flags.views {
t.restructured = Some(end);
}
match notice.moved.get(shape) {
Some(Moved::Keys(keys)) if keys.is_empty() => {}
Some(moved) => t.unapplied.push((end, moved.clone())),
None => {}
}
let current = t.watermark.is_some_and(|w| w.same_epoch(&end));
if t.unapplied.is_empty() && t.restructured.is_none() && !t.stale && current {
t.watermark = Some(end);
}
}
self.events_of(notice)
}
/// The connection dropped: every shape asks for a correction from its watermark.
pub fn connection_lost(&mut self) {
self.shapes.values_mut().for_each(|t| t.stale = true);
}
pub fn request_failed(&mut self, shape: &ShapeKey) {
if let Some(t) = self.shapes.get_mut(shape) {
t.stale = true;
}
}
/// A notice that could not be read: what it said is lost, so everything asks.
pub fn unreadable(&mut self) {
self.connection_lost();
self.events_through = None;
}
fn events_of(&mut self, n: &Notice) -> Vec<Event> {
let end = n.stamp();
let resync = vec![Event::Resync];
let usable = match self.events_through {
Some(t) if t.same_epoch(&end) => {
if n.range.to <= t.publication {
return Vec::new();
}
n.range.from == t.publication
}
_ => false,
};
self.events_through = Some(end);
match usable {
false => resync,
true => n
.axis
.iter()
.cloned()
.map(Event::Axis)
.chain(n.relabels.iter().cloned().map(Event::Relabel))
.chain(n.flags.any().then_some(Event::Flags(n.flags)))
.collect(),
}
}
/// A correction. It applies only at the watermark it starts from: `held + (target - held)`.
pub fn apply(&mut self, c: &Correction) -> (Outcome, Vec<Event>) {
let Some(t) = self.shapes.get_mut(&c.shape) else {
return (Outcome::Untracked, Vec::new());
};
let target = decode(&c.target);
let older = |s: Option<Stamp>| {
s.is_some_and(|s| s.host == c.through.host && c.through.generation < s.generation)
};
if older(t.watermark)
|| older(t.seen)
|| t.left.contains(&(c.through.host, c.through.generation))
{
return (Outcome::Duplicate, Vec::new());
}
let covered = t
.watermark
.is_some_and(|w| w.same_epoch(&c.through) && w.publication >= c.through.publication);
let starts_here = match (&c.from, t.watermark) {
(Some(from), Some(w)) => w.same_epoch(from) && w.publication >= from.publication,
_ => false,
};
let delta = match (&c.body, covered) {
(_, true) => {
t.stale &= t.watermark != Some(c.through);
let live = c.through;
t.unapplied
.retain(|(at, _)| at.same_epoch(&live) && at.publication > live.publication);
if t.restructured.is_some_and(|r| !r.same_epoch(&live)) {
t.restructured = None;
}
if t.seen.is_some_and(|s| !s.same_epoch(&live)) {
t.seen = Some(live);
}
return (Outcome::Duplicate, Vec::new());
}
(CorrectionBody::Window, false) => &target - &t.held,
(CorrectionBody::Moved { .. }, false) if !starts_here => {
return (Outcome::Gap, Vec::new());
}
(CorrectionBody::Moved { keys }, false) => {
let held_there: ZSet<Cell> = t
.held
.iter()
.filter(|((k, _), _)| keys.contains(k))
.map(|(cell, w)| (cell.clone(), w))
.collect();
&target - &held_there
}
};
t.held += &delta;
if let Some(w) = t.watermark.filter(|w| !w.same_epoch(&c.through)) {
t.left.push((w.host, w.generation));
let excess = t.left.len().saturating_sub(LEFT_EPOCHS);
t.left.drain(..excess);
}
t.watermark = Some(c.through);
let window = matches!(c.body, CorrectionBody::Window);
t.stale &= !window;
if window
&& t.restructured.is_some_and(|r| {
!r.same_epoch(&c.through) || r.publication <= c.through.publication
})
{
t.restructured = None;
}
let seen_epoch = t.seen.map(|s| (s.host, s.generation));
t.unapplied.retain(|(at, _)| {
Some((at.host, at.generation)) == seen_epoch
&& !(at.same_epoch(&c.through) && at.publication <= c.through.publication)
});
let seen_ahead = t
.seen
.is_some_and(|s| s.same_epoch(&c.through) && s.publication >= c.through.publication);
if !seen_ahead {
t.seen = Some(c.through);
}
let events = match (&c.carried, window) {
(Some(carried), _) => self.events_of(carried),
(None, true) => {
self.events_through = Some(c.through);
vec![Event::Resync]
}
(None, false) => Vec::new(),
};
(Outcome::Applied { delta }, events)
}
/// Ask until `shape` wants nothing, at most `rounds` times.
pub fn sync(
&mut self,
edge: &mut impl Edge,
shape: &ShapeKey,
rounds: usize,
) -> (Synced, Vec<Event>) {
let mut events = Vec::new();
for _ in 0..rounds {
let Some(want) = self.wants(shape) else {
return (Synced::Settled, events);
};
let Some(request) = self.request(shape, want) else {
return (Synced::Settled, events);
};
match edge.call(request) {
Ok(Reply::Correction(c)) => events.extend(self.apply(&c).1),
Ok(Reply::Notices(list)) => {
list.iter()
.for_each(|n| events.extend(self.accept(n, Some(shape))));
}
Err(EdgeError::Failed) => {
self.request_failed(shape);
return (Synced::Failed, events);
}
Err(EdgeError::Unreadable) => {
self.unreadable();
return (Synced::Failed, events);
}
}
}
match self.wants(shape) {
None => (Synced::Settled, events),
Some(_) => (Synced::Exhausted, events),
}
}
/// Pull instead of listening: ask for the notices after the horizon seen.
pub fn poll(&mut self, edge: &mut impl Edge, shape: &ShapeKey) -> (Synced, Vec<Event>) {
let (Some(t), Some(after)) = (
self.shapes.get(shape),
self.shapes.get(shape).and_then(|t| t.seen),
) else {
return self.sync(edge, shape, 4);
};
let request = Request {
session: self.session.clone(),
shape: shape.clone(),
window: t.window,
ask: Ask::Notices { after },
};
let mut events = Vec::new();
match edge.call(request) {
Ok(Reply::Notices(list)) => list
.iter()
.for_each(|n| events.extend(self.accept(n, Some(shape)))),
Ok(Reply::Correction(c)) => events.extend(self.apply(&c).1),
Err(EdgeError::Failed) => {
self.request_failed(shape);
return (Synced::Failed, events);
}
Err(EdgeError::Unreadable) => {
self.unreadable();
return (Synced::Failed, events);
}
}
let (synced, more) = self.sync(edge, shape, 4);
events.extend(more);
(synced, events)
}
}
@@ -0,0 +1,292 @@
//! The client end of an edge against hand-built notices and corrections.
use super::client::*;
use super::exchange::*;
use super::notice::*;
use super::wire::{Cell, WireElem, WireKey, WireValue, decode, encode};
fn at(host: u64, publication: u64) -> Stamp {
Stamp {
host,
generation: 1,
publication,
}
}
fn shape() -> ShapeKey {
ShapeKey("s".into())
}
fn window() -> Window {
Window {
top: 0,
left: 0,
rows: 10,
cols: 10,
}
}
fn key(n: u8) -> WireKey {
WireKey(vec![("C".into(), n.to_string())])
}
fn cell(k: u8, v: &str) -> Cell {
(key(k), WireValue::Text(v.into()))
}
fn target(cells: &[Cell]) -> Vec<WireElem> {
encode(&cells.iter().cloned().map(|c| (c, 1)).collect())
}
fn window_correction(through: Stamp, cells: &[Cell]) -> Correction {
Correction {
shape: shape(),
at: through,
from: None,
through,
body: CorrectionBody::Window,
target: target(cells),
carried: None,
}
}
fn notice(host: u64, publication: u64, moved: &[u8]) -> Notice {
let mut n = Notice::empty(PubRange::one(at(host, publication)));
if !moved.is_empty() {
n.moved.insert(
shape(),
Moved::Keys(moved.iter().map(|k| key(*k)).collect()),
);
}
n
}
fn synced_at(publication: u64, cells: &[Cell]) -> EdgeClient {
let mut c = EdgeClient::default();
c.track(shape(), window());
c.apply(&window_correction(at(1, publication), cells));
assert_eq!(c.wants(&shape()), None, "the fixture must start settled");
c
}
fn moved_correction(from: u64, through: u64, keys: &[u8], cells: &[Cell]) -> Correction {
Correction {
shape: shape(),
at: at(1, through),
from: Some(at(1, from)),
through: at(1, through),
body: CorrectionBody::Moved {
keys: keys.iter().map(|k| key(*k)).collect(),
},
target: target(cells),
carried: None,
}
}
#[test]
fn a_tracked_shape_first_wants_a_full_refetch() {
let mut c = EdgeClient::default();
c.track(shape(), window());
assert_eq!(
c.wants(&shape()),
Some(Want::Correction { watermark: None })
);
}
#[test]
fn consecutive_notices_ask_for_no_correction_and_advance_the_watermark() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&notice(1, 5, &[]));
c.on_notice(&notice(1, 6, &[]));
assert_eq!(c.wants(&shape()), None);
assert_eq!(c.watermark(&shape()), Some(at(1, 6)));
}
#[test]
fn a_moved_key_wants_a_fetch_and_not_a_correction() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&notice(1, 5, &[1, 2]));
assert_eq!(
c.wants(&shape()),
Some(Want::Fetch {
watermark: Some(at(1, 4)),
keys: [key(1), key(2)].into(),
through: at(1, 5),
window: false
})
);
}
#[test]
fn a_dropped_notice_is_detected() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&notice(1, 6, &[]));
assert_eq!(
c.wants(&shape()),
Some(Want::Correction {
watermark: Some(at(1, 4))
})
);
}
#[test]
fn a_notice_from_another_host_or_generation_asks_for_a_correction() {
for foreign in [
notice(2, 5, &[]),
Notice::empty(PubRange::one(Stamp {
generation: 2,
..at(1, 5)
})),
] {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&foreign);
assert!(
matches!(c.wants(&shape()), Some(Want::Correction { .. })),
"{foreign:?}"
);
}
}
#[test]
fn an_eviction_and_an_oversized_notice_each_ask_for_a_correction() {
let mut evicted = notice(1, 5, &[]);
evicted.evicted.insert(shape());
let mut oversized = notice(1, 5, &[]);
oversized.moved.insert(shape(), Moved::Correct);
for n in [evicted, oversized] {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&n);
assert!(matches!(c.wants(&shape()), Some(Want::Correction { .. })));
}
}
#[test]
fn a_duplicate_notice_changes_nothing() {
let mut c = synced_at(4, &[]);
c.on_notice(&notice(1, 5, &[3]));
let before = c.wants(&shape());
assert_eq!(c.on_notice(&notice(1, 5, &[3])), Vec::new());
assert_eq!(c.wants(&shape()), before);
}
#[test]
fn a_correction_equals_the_target_and_a_second_one_is_the_zero_zset() {
let mut c = EdgeClient::default();
c.track(shape(), window());
let fix = window_correction(at(1, 4), &[cell(1, "a"), cell(2, "b")]);
let (first, _) = c.apply(&fix);
let Outcome::Applied { delta } = first else {
panic!("{first:?}")
};
assert_eq!(delta.len(), 2);
assert_eq!(c.held(&shape()), Some(&decode(&fix.target)));
let later = window_correction(at(1, 5), &[cell(1, "a"), cell(2, "b")]);
let (second, _) = c.apply(&later);
let Outcome::Applied { delta } = second else {
panic!("{second:?}")
};
assert!(delta.is_zero(), "correct state corrected: {delta:?}");
}
#[test]
fn a_correction_replaces_what_is_held_not_adds_to_it() {
let mut c = synced_at(4, &[cell(1, "a"), cell(2, "b")]);
c.apply(&window_correction(at(1, 5), &[cell(1, "z")]));
assert_eq!(c.held(&shape()), Some(&decode(&target(&[cell(1, "z")]))));
}
#[test]
fn a_correction_already_covered_is_idempotent_and_never_regresses() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.apply(&window_correction(at(1, 7), &[cell(1, "new")]));
let late = window_correction(at(1, 5), &[cell(1, "old")]);
assert_eq!(c.apply(&late).0, Outcome::Duplicate);
assert_eq!(c.held(&shape()), Some(&decode(&target(&[cell(1, "new")]))));
assert_eq!(c.watermark(&shape()), Some(at(1, 7)));
}
#[test]
fn a_moved_correction_applies_only_at_the_watermark_it_starts_from() {
let mut c = synced_at(4, &[cell(1, "a"), cell(2, "b")]);
let ahead = moved_correction(6, 8, &[1], &[cell(1, "x")]);
assert_eq!(c.apply(&ahead).0, Outcome::Gap);
assert_eq!(c.watermark(&shape()), Some(at(1, 4)));
let fits = moved_correction(4, 6, &[1], &[cell(1, "x")]);
assert!(matches!(c.apply(&fits).0, Outcome::Applied { .. }));
assert_eq!(c.apply(&fits).0, Outcome::Duplicate);
let expect = decode(&target(&[cell(1, "x"), cell(2, "b")]));
assert_eq!(c.held(&shape()), Some(&expect));
assert_eq!(c.watermark(&shape()), Some(at(1, 6)));
}
#[test]
fn a_moved_key_the_target_no_longer_holds_is_removed() {
let mut c = synced_at(4, &[cell(1, "a"), cell(2, "b")]);
c.apply(&moved_correction(4, 5, &[2], &[]));
assert_eq!(c.held(&shape()), Some(&decode(&target(&[cell(1, "a")]))));
}
#[test]
fn a_dropped_connection_or_failed_request_asks_from_the_watermark() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.connection_lost();
assert_eq!(
c.wants(&shape()),
Some(Want::Correction {
watermark: Some(at(1, 4))
})
);
c.apply(&moved_correction(4, 4, &[], &[]));
assert_eq!(c.wants(&shape()), None, "an empty answer settles it");
c.request_failed(&shape());
assert!(matches!(c.wants(&shape()), Some(Want::Correction { .. })));
}
#[test]
fn a_fetch_does_not_advance_past_the_notices_it_did_not_name() {
let mut c = synced_at(4, &[cell(1, "a")]);
c.on_notice(&notice(1, 5, &[1]));
c.on_notice(&notice(1, 6, &[2]));
c.apply(&moved_correction(4, 5, &[1], &[cell(1, "x")]));
assert_eq!(c.watermark(&shape()), Some(at(1, 5)));
assert_eq!(
c.wants(&shape()),
Some(Want::Fetch {
watermark: Some(at(1, 5)),
keys: [key(2)].into(),
through: at(1, 6),
window: false
})
);
}
#[test]
fn carried_axis_changes_are_applied_once_and_in_order() {
let mut c = synced_at(4, &[]);
let mut n = notice(1, 5, &[]);
n.axis = vec![
AxisChange::CategoryAdded {
category: "X".into(),
},
AxisChange::ItemInserted {
category: "X".into(),
item: "i".into(),
at: 0,
},
];
let events = c.on_notice(&n);
assert_eq!(events.len(), 2, "{events:?}");
let mut carried = moved_correction(4, 5, &[], &[]);
carried.carried = Some(n);
let (_, again) = c.apply(&carried);
assert_eq!(again, Vec::new(), "the same changes must not apply twice");
}
#[test]
fn a_gap_in_structure_asks_the_consumer_to_resync() {
let mut c = synced_at(4, &[]);
assert_eq!(c.on_notice(&notice(1, 7, &[])), vec![Event::Resync]);
}
@@ -0,0 +1,96 @@
//! Requests, corrections and the edge between a client and the host (ADR 009 clause 9).
//! Why each choice: `context/code/delivery.md` §3.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::notice::{Notice, ShapeKey, Stamp};
use super::wire::{WireElem, WireKey, wide};
/// The cross-product rectangle a request costs O(visible) for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Window {
#[serde(with = "wide")]
pub top: u128,
#[serde(with = "wide")]
pub left: u128,
#[serde(with = "wide")]
pub rows: u128,
#[serde(with = "wide")]
pub cols: u128,
}
impl Window {
pub fn contains(&self, row: u128, col: u128) -> bool {
(self.top..self.top.saturating_add(self.rows)).contains(&row)
&& (self.left..self.left.saturating_add(self.cols)).contains(&col)
}
}
/// Opaque transport, never read by the host (clause 9, "an edge is a session").
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionToken(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Ask {
/// Everything since `watermark`; `None` is a full refetch, `target - empty`.
Correction { watermark: Option<Stamp> },
/// The current values of `keys`, covering the publications up to `through`. `window`
/// asks for the whole window too: an axis change moved positions, not values.
Fetch {
watermark: Option<Stamp>,
keys: BTreeSet<WireKey>,
through: Stamp,
window: bool,
},
/// The notices after `after`, if the host's log still has them.
Notices { after: Stamp },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub session: Option<SessionToken>,
pub shape: ShapeKey,
pub window: Window,
pub ask: Ask,
}
/// What a correction replaces on the client: the keys it names, or the whole window.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CorrectionBody {
Moved { keys: BTreeSet<WireKey> },
Window,
}
/// `target`, restricted as `body` says, read at `at` and covering `(from, through]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Correction {
pub shape: ShapeKey,
pub at: Stamp,
pub from: Option<Stamp>,
pub through: Stamp,
pub body: CorrectionBody,
pub target: Vec<WireElem>,
/// The axis changes, relabels and flags of `(from, through]`, in order, as a merged notice.
pub carried: Option<Notice>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Reply {
Correction(Box<Correction>),
Notices(Vec<Notice>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeError {
/// The request did not complete: dropped, timed out or refused by the transport.
Failed,
/// What arrived could not be read.
Unreadable,
}
/// One end of an edge: a request goes out and a reply, or a failure, comes back.
pub trait Edge {
fn call(&mut self, request: Request) -> Result<Reply, EdgeError>;
}
@@ -0,0 +1,20 @@
//! Delivery (ADR 009 clause 9): the wire form, notices, corrections and the client end.
//! The host end is in `improvise-command`; why: `context/code/delivery.md`.
pub mod client;
pub mod exchange;
pub mod notice;
pub mod wire;
pub use client::{EdgeClient, Event, Outcome, Synced, Want};
pub use exchange::{
Ask, Correction, CorrectionBody, Edge, EdgeError, Reply, Request, SessionToken, Window,
};
pub use notice::{AxisChange, Flags, Moved, Notice, PubRange, Relabel, ShapeKey, Stamp};
pub use wire::{Cell, WireElem, WireKey, WireNum, WireValue};
#[cfg(test)]
mod client_tests;
#[cfg(test)]
mod review_tests;
@@ -0,0 +1,315 @@
//! The stamp, the change notice and the merge of notices (ADR 009 clause 9).
//! Why each choice: `context/code/delivery.md` §2.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::wire::{WireKey, wide};
/// A logical position in a host's stream: never a wall clock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Stamp {
#[serde(with = "wide")]
pub host: u64,
#[serde(with = "wide")]
pub generation: u64,
#[serde(with = "wide")]
pub publication: u64,
}
impl Stamp {
pub fn same_epoch(&self, other: &Stamp) -> bool {
self.host == other.host && self.generation == other.generation
}
/// A stamp from another host instance or generation is behind every current one.
pub fn is_behind(&self, current: &Stamp) -> bool {
!self.same_epoch(current) || self.publication < current.publication
}
}
/// The publications `(from, to]` one notice covers, in one epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PubRange {
#[serde(with = "wide")]
pub host: u64,
#[serde(with = "wide")]
pub generation: u64,
#[serde(with = "wide")]
pub from: u64,
#[serde(with = "wide")]
pub to: u64,
}
impl PubRange {
pub fn one(at: Stamp) -> Self {
Self {
host: at.host,
generation: at.generation,
from: at.publication - 1,
to: at.publication,
}
}
pub fn end(&self) -> Stamp {
Stamp {
host: self.host,
generation: self.generation,
publication: self.to,
}
}
pub fn start(&self) -> Stamp {
Stamp {
host: self.host,
generation: self.generation,
publication: self.from,
}
}
}
/// A named query: the key operator state is selected by (clause 7).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ShapeKey(pub String);
/// A change to the items an axis is made of. The client re-derives positions from these.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AxisChange {
CategoryAdded {
category: String,
},
CategoryRemoved {
category: String,
},
ItemInserted {
category: String,
item: String,
#[serde(with = "wide")]
at: u128,
},
ItemRemoved {
category: String,
item: String,
},
ItemsReordered {
category: String,
items: Vec<String>,
},
}
/// A display label that moved: the id and what to show.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Relabel {
pub id: String,
pub display: String,
}
/// What moved that no cell key names.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Flags {
pub formulas: bool,
pub measure_agg: bool,
pub views: bool,
pub default_view: bool,
}
impl Flags {
pub fn any(&self) -> bool {
self.formulas || self.measure_agg || self.views || self.default_view
}
pub fn union(self, other: Flags) -> Flags {
Flags {
formulas: self.formulas || other.formulas,
measure_agg: self.measure_agg || other.measure_agg,
views: self.views || other.views,
default_view: self.default_view || other.default_view,
}
}
}
/// The output keys of one shape that moved, or "correct" when the list was too long to send.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Moved {
Keys(BTreeSet<WireKey>),
Correct,
}
impl Moved {
fn union(self, other: Moved) -> Moved {
match (self, other) {
(Moved::Keys(mut a), Moved::Keys(b)) => {
a.extend(b);
Moved::Keys(a)
}
_ => Moved::Correct,
}
}
}
/// One committed command, or a run of them merged. Addressed to no client.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notice {
pub range: PubRange,
pub axis: Vec<AxisChange>,
pub relabels: Vec<Relabel>,
pub flags: Flags,
pub evicted: BTreeSet<ShapeKey>,
pub moved: BTreeMap<ShapeKey, Moved>,
}
impl Notice {
pub fn empty(range: PubRange) -> Self {
Self {
range,
axis: Vec::new(),
relabels: Vec::new(),
flags: Flags::default(),
evicted: BTreeSet::new(),
moved: BTreeMap::new(),
}
}
pub fn stamp(&self) -> Stamp {
self.range.end()
}
/// `self` followed by `next`, if `next` starts where `self` ends. Anything else
/// has a gap or another epoch in it, and merging would hide a loss.
pub fn merge(mut self, next: Notice) -> Result<Notice, Box<(Notice, Notice)>> {
let adjacent = self.range.end() == next.range.start();
match adjacent {
false => Err(Box::new((self, next))),
true => {
self.range.to = next.range.to;
self.axis.extend(next.axis);
self.relabels.extend(next.relabels);
self.flags = self.flags.union(next.flags);
self.evicted.extend(next.evicted);
for (shape, moved) in next.moved {
let merged = match self.moved.remove(&shape) {
Some(before) => before.union(moved),
None => moved,
};
self.moved.insert(shape, merged);
}
Ok(self)
}
}
}
/// This notice as one shape reads it: other shapes' key lists are not its business.
pub fn for_shape(&self, shape: &ShapeKey) -> Notice {
Notice {
moved: self
.moved
.get(shape)
.map(|m| BTreeMap::from([(shape.clone(), m.clone())]))
.unwrap_or_default(),
..self.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn stamp(publication: u64) -> Stamp {
Stamp {
host: 7,
generation: 1,
publication,
}
}
pub(crate) fn key(n: u8) -> WireKey {
WireKey(vec![("C".into(), n.to_string())])
}
pub(crate) fn notice(publication: u64, moved: &[(&str, &[u8])]) -> Notice {
let mut n = Notice::empty(PubRange::one(stamp(publication)));
for (shape, keys) in moved {
n.moved.insert(
ShapeKey((*shape).into()),
Moved::Keys(keys.iter().map(|k| key(*k)).collect()),
);
}
n
}
#[test]
fn a_stamp_from_another_epoch_is_behind_every_current_one() {
let now = stamp(5);
assert!(stamp(4).is_behind(&now));
assert!(!stamp(5).is_behind(&now));
let other_host = Stamp {
host: 8,
..stamp(99)
};
let other_generation = Stamp {
generation: 2,
..stamp(99)
};
assert!(other_host.is_behind(&now), "a restart is behind");
assert!(other_generation.is_behind(&now), "a swap is behind");
}
#[test]
fn adjacent_notices_merge_into_the_range_they_cover() {
let merged = notice(3, &[("a", &[1])])
.merge(notice(4, &[("a", &[2]), ("b", &[9])]))
.unwrap();
assert_eq!((merged.range.from, merged.range.to), (2, 4));
assert_eq!(
merged.moved[&ShapeKey("a".into())],
Moved::Keys([key(1), key(2)].into())
);
assert!(merged.moved.contains_key(&ShapeKey("b".into())));
}
#[test]
fn a_gap_does_not_merge() {
let (a, b) = *notice(3, &[]).merge(notice(5, &[])).unwrap_err();
assert_eq!((a.range.to, b.range.to), (3, 5));
let foreign = Notice::empty(PubRange::one(Stamp {
host: 9,
..stamp(4)
}));
assert!(notice(3, &[]).merge(foreign).is_err());
}
#[test]
fn merging_keeps_axis_changes_in_order_and_correct_absorbs_keys() {
let mut a = notice(1, &[("s", &[1])]);
a.axis.push(AxisChange::CategoryAdded {
category: "X".into(),
});
let mut b = notice(2, &[]);
b.moved.insert(ShapeKey("s".into()), Moved::Correct);
b.axis.push(AxisChange::ItemRemoved {
category: "X".into(),
item: "i".into(),
});
b.flags.formulas = true;
let m = a.merge(b).unwrap();
assert_eq!(m.axis.len(), 2);
assert!(matches!(m.axis[0], AxisChange::CategoryAdded { .. }));
assert!(matches!(m.axis[1], AxisChange::ItemRemoved { .. }));
assert_eq!(m.moved[&ShapeKey("s".into())], Moved::Correct);
assert!(m.flags.formulas && !m.flags.views);
}
#[test]
fn a_notice_round_trips_and_a_wide_stamp_crosses_as_a_string() {
let wide_host = Stamp {
host: u64::MAX,
..stamp(1 << 53)
};
let n = Notice::empty(PubRange::one(wide_host));
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains(&format!("\"{}\"", u64::MAX)), "{json}");
assert_eq!(serde_json::from_str::<Notice>(&json).unwrap(), n);
}
}
@@ -0,0 +1,203 @@
//! Findings from the review of #348: each is a hand-built history that failed before its fix.
use serde::{Deserialize, Serialize};
use super::client::*;
use super::exchange::*;
use super::notice::*;
use super::wire::{self, Cell, WireElem, WireKey, WireValue, encode};
fn at(host: u64, generation: u64, publication: u64) -> Stamp {
Stamp {
host,
generation,
publication,
}
}
fn shape(name: &str) -> ShapeKey {
ShapeKey(name.into())
}
fn window() -> Window {
Window {
top: 0,
left: 0,
rows: 10,
cols: 10,
}
}
fn key(n: u8) -> WireKey {
WireKey(vec![("C".into(), n.to_string())])
}
fn cell(k: u8, v: &str) -> Cell {
(key(k), WireValue::Text(v.into()))
}
fn target(cells: &[Cell]) -> Vec<WireElem> {
encode(&cells.iter().cloned().map(|c| (c, 1)).collect())
}
fn window_correction(name: &str, through: Stamp, cells: &[Cell]) -> Correction {
Correction {
shape: shape(name),
at: through,
from: None,
through,
body: CorrectionBody::Window,
target: target(cells),
carried: None,
}
}
fn synced(name: &str, through: Stamp, cells: &[Cell]) -> EdgeClient {
let mut c = EdgeClient::default();
c.track(shape(name), window());
c.apply(&window_correction(name, through, cells));
c
}
#[test]
fn a_late_window_reply_from_an_older_generation_does_not_regress() {
let mut c = synced("s", at(1, 2, 5), &[cell(1, "new")]);
let late = window_correction("s", at(1, 1, 9), &[cell(1, "old")]);
assert_eq!(c.apply(&late).0, Outcome::Duplicate);
assert_eq!(c.watermark(&shape("s")), Some(at(1, 2, 5)));
let kept = wire::decode(&target(&[cell(1, "new")]));
assert_eq!(c.held(&shape("s")), Some(&kept));
}
#[test]
fn a_late_notice_from_a_dead_host_settles_against_the_live_one() {
let mut c = synced("s", at(2, 1, 4), &[cell(1, "a")]);
c.on_notice(&Notice::empty(PubRange::one(at(1, 1, 9))));
assert!(matches!(
c.wants(&shape("s")),
Some(Want::Correction { .. })
));
let live = window_correction("s", at(2, 1, 4), &[cell(1, "a")]);
assert_eq!(c.apply(&live).0, Outcome::Duplicate);
assert_eq!(
c.wants(&shape("s")),
None,
"the dead host's notice is dropped"
);
c.on_notice(&Notice::empty(PubRange::one(at(2, 1, 5))));
assert_eq!(
c.wants(&shape("s")),
None,
"and the live host's next one is contiguous"
);
}
struct Replies(Vec<Reply>);
impl Edge for Replies {
fn call(&mut self, _: Request) -> Result<Reply, EdgeError> {
match self.0.is_empty() {
true => Err(EdgeError::Failed),
false => Ok(self.0.remove(0)),
}
}
}
#[test]
fn a_poll_for_one_shape_leaves_the_other_shapes_alone() {
let (a, b) = (shape("a"), shape("b"));
let mut c = EdgeClient::default();
for s in [&a, &b] {
c.track(s.clone(), window());
c.apply(&window_correction(&s.0, at(1, 1, 4), &[]));
}
let mut pulled = Notice::empty(PubRange::one(at(1, 1, 5)));
pulled.moved.insert(a.clone(), Moved::Keys([key(1)].into()));
let mut edge = Replies(vec![Reply::Notices(vec![pulled])]);
c.poll(&mut edge, &a);
assert_eq!(c.watermark(&b), Some(at(1, 1, 4)), "b was not asked about");
let mut next = Notice::empty(PubRange::one(at(1, 1, 6)));
next.moved.insert(b.clone(), Moved::Keys([key(2)].into()));
c.on_notice(&next);
assert!(
matches!(c.wants(&b), Some(Want::Correction { .. })),
"b missed publication 5, and must see the gap: {:?}",
c.wants(&b)
);
}
#[test]
fn a_views_flag_makes_the_client_fetch_its_window() {
let mut c = synced("s", at(1, 1, 4), &[cell(1, "a")]);
let mut n = Notice::empty(PubRange::one(at(1, 1, 5)));
n.flags.views = true;
c.on_notice(&n);
assert!(matches!(
c.wants(&shape("s")),
Some(Want::Fetch { window: true, .. })
));
}
#[test]
fn wide_integers_are_canonical_on_input() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct P(
#[serde(with = "wire::wide")] u64,
#[serde(with = "wire::wide_weight")] i64,
);
let bad = [
r#"[9007199254740992,1]"#,
r#"["5",1]"#,
r#"["+9007199254740992",1]"#,
r#"["09007199254740992",1]"#,
r#"[1,9007199254740992]"#,
r#"[1,"5"]"#,
r#"[1,"-5"]"#,
r#"[1,"+9007199254740992"]"#,
r#"[1,"-9007199254740992 "]"#,
];
for text in bad {
assert!(serde_json::from_str::<P>(text).is_err(), "{text}");
}
let good = r#"["9007199254740992","-9007199254740992"]"#;
assert_eq!(
serde_json::from_str::<P>(good).unwrap(),
P(1 << 53, -(1 << 53))
);
assert_eq!(
serde_json::from_str::<P>("[9007199254740991,-9007199254740991]").unwrap(),
P((1 << 53) - 1, -((1 << 53) - 1))
);
}
#[test]
fn an_item_insert_position_crosses_as_a_wide_integer() {
let change = AxisChange::ItemInserted {
category: "C".into(),
item: "i".into(),
at: 1 << 60,
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains(r#""at":"1152921504606846976""#), "{json}");
assert_eq!(serde_json::from_str::<AxisChange>(&json).unwrap(), change);
}
#[test]
fn a_late_window_reply_from_a_dead_host_does_not_regress() {
let mut c = synced("s", at(1, 1, 9), &[cell(1, "old")]);
c.apply(&window_correction("s", at(2, 1, 3), &[cell(1, "new")]));
let late = window_correction("s", at(1, 1, 12), &[cell(1, "dead")]);
assert_eq!(c.apply(&late).0, Outcome::Duplicate);
assert_eq!(c.watermark(&shape("s")), Some(at(2, 1, 3)));
let kept = wire::decode(&target(&[cell(1, "new")]));
assert_eq!(c.held(&shape("s")), Some(&kept));
}
#[test]
fn a_reply_from_a_generation_the_client_left_does_not_return() {
let mut c = synced("s", at(1, 1, 9), &[cell(1, "old")]);
c.apply(&window_correction("s", at(1, 2, 1), &[cell(1, "new")]));
c.apply(&window_correction("s", at(1, 3, 1), &[cell(1, "newer")]));
let late = window_correction("s", at(1, 2, 7), &[cell(1, "gen2")]);
assert_eq!(c.apply(&late).0, Outcome::Duplicate);
}
@@ -0,0 +1,342 @@
//! The lossless wire form of what flows on a delivery edge (ADR 009 clause 9).
//! Why each choice: `context/code/delivery.md` §1.
use improvise_core::model::cell::{CellKey, CellValue};
use numtower::Num;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zset::ZSet;
const EXACT_JSON: u128 = 1 << 53;
/// An unsigned integer: a JSON number below 2^53, a decimal string above it.
pub mod wide {
use super::*;
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Repr {
Small(u64),
Wide(String),
}
pub fn serialize<T: Copy + Into<u128>, S: Serializer>(n: &T, s: S) -> Result<S::Ok, S::Error> {
let n: u128 = (*n).into();
match n < EXACT_JSON {
true => Repr::Small(n as u64).serialize(s),
false => Repr::Wide(n.to_string()).serialize(s),
}
}
pub fn deserialize<'de, T: TryFrom<u128>, D: Deserializer<'de>>(d: D) -> Result<T, D::Error> {
let n: u128 = match Repr::deserialize(d)? {
Repr::Small(n) if u128::from(n) < EXACT_JSON => n.into(),
Repr::Small(_) => {
return Err(serde::de::Error::custom("a wide integer must be a string"));
}
Repr::Wide(text) => {
let n: u128 = text.parse().map_err(serde::de::Error::custom)?;
match n >= EXACT_JSON && n.to_string() == text {
true => n,
false => return Err(serde::de::Error::custom("not a canonical wide integer")),
}
}
};
T::try_from(n).map_err(|_| serde::de::Error::custom("integer out of range"))
}
}
/// A signed weight, with the same rule as [`wide`].
pub mod wide_weight {
use super::*;
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Repr {
Small(i64),
Wide(String),
}
pub fn serialize<S: Serializer>(n: &i64, s: S) -> Result<S::Ok, S::Error> {
match u128::from(n.unsigned_abs()) < EXACT_JSON {
true => Repr::Small(*n).serialize(s),
false => Repr::Wide(n.to_string()).serialize(s),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
match Repr::deserialize(d)? {
Repr::Small(n) if u128::from(n.unsigned_abs()) < EXACT_JSON => Ok(n),
Repr::Small(_) => Err(serde::de::Error::custom("a wide weight must be a string")),
Repr::Wide(text) => {
let n: i64 = text.parse().map_err(serde::de::Error::custom)?;
let wide = u128::from(n.unsigned_abs()) >= EXACT_JSON;
match wide && n.to_string() == text {
true => Ok(n),
false => Err(serde::de::Error::custom("not a canonical wide weight")),
}
}
}
}
}
/// A number in ADR 005's canonical text. Equality is textual, so the sign of a zero survives.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WireNum(String);
impl WireNum {
pub fn of(n: &Num) -> Self {
Self(n.to_string())
}
pub fn of_f64(x: f64) -> Self {
Self::of(&Num::inexact(x))
}
/// `None` for text the tower does not write, so one value has one spelling.
pub fn parse(text: &str) -> Option<Self> {
let n = Num::from_canonical(text)?;
(n.to_string() == text).then(|| Self(text.to_string()))
}
pub fn text(&self) -> &str {
&self.0
}
pub fn to_num(&self) -> Num {
Num::from_canonical(&self.0).expect("a WireNum holds canonical text by construction")
}
}
impl Serialize for WireNum {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for WireNum {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let text = String::deserialize(d)?;
Self::parse(&text)
.ok_or_else(|| serde::de::Error::custom(format!("{text:?} is not a canonical number")))
}
}
/// A cell's value on the wire. Text is byte-exact; a number never crosses as a double.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum WireValue {
Num(WireNum),
Text(String),
Error(String),
}
impl WireValue {
pub fn of(value: &CellValue) -> Self {
match value {
CellValue::Number(x) => Self::Num(WireNum::of_f64(*x)),
CellValue::Text(t) => Self::Text(t.clone()),
CellValue::Error(e) => Self::Error(e.clone()),
}
}
/// Exact numbers narrow to `f64` here, because `CellValue` is still an `f64` (ADR 005).
pub fn to_cell_value(&self) -> CellValue {
match self {
Self::Num(n) => CellValue::Number(n.to_num().to_f64()),
Self::Text(t) => CellValue::Text(t.clone()),
Self::Error(e) => CellValue::Error(e.clone()),
}
}
}
/// A cell named by its coordinate tuple (ids once ADR 007 lands).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WireKey(pub Vec<(String, String)>);
impl WireKey {
pub fn of(key: &CellKey) -> Self {
Self(key.0.clone())
}
}
pub type Cell = (WireKey, WireValue);
/// One element of a Z-set on the wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireElem {
pub key: WireKey,
pub value: WireValue,
#[serde(with = "wide_weight")]
pub weight: i64,
}
pub fn encode(z: &ZSet<Cell>) -> Vec<WireElem> {
z.iter()
.map(|((key, value), weight)| WireElem {
key: key.clone(),
value: value.clone(),
weight,
})
.collect()
}
pub fn decode(elems: &[WireElem]) -> ZSet<Cell> {
elems
.iter()
.map(|e| ((e.key.clone(), e.value.clone()), e.weight))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn json_round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).expect("serializes")).expect("reads back")
}
#[test]
fn a_float_crosses_in_canonical_text_with_the_sign_of_zero() {
for (x, text) in [
(0.1, "#i0.1"),
(-0.0, "#i-0.0"),
(0.0, "#i0.0"),
(f64::INFINITY, "inf"),
(f64::NEG_INFINITY, "-inf"),
(f64::NAN, "nan"),
] {
let sent = WireValue::of(&CellValue::Number(x));
let json = serde_json::to_string(&sent).unwrap();
assert_eq!(json, format!(r#"{{"Num":"{text}"}}"#), "{x:?}");
let CellValue::Number(y) = json_round(&sent).to_cell_value() else {
panic!("a number came back as something else")
};
match x.is_nan() {
true => assert!(y.is_nan()),
false => assert_eq!(x.to_bits(), y.to_bits(), "{x:?} came back {y:?}"),
}
}
}
#[test]
fn every_nan_is_the_one_token() {
let payload = f64::from_bits(0x7ff8_dead_beef_0001);
assert!(payload.is_nan());
assert_eq!(WireNum::of_f64(payload), WireNum::of_f64(f64::NAN));
}
#[test]
fn exact_numbers_round_trip_beyond_a_double() {
let wide_decimal = Num::from_canonical("1234567890123456789.0123456789").unwrap();
let fraction = Num::ratio(i128::MAX / 3, 7).unwrap();
for n in [wide_decimal, fraction] {
let back = json_round(&WireNum::of(&n)).to_num();
assert_eq!(back.to_string(), n.to_string());
assert_eq!(back.is_exact(), n.is_exact());
assert!(back.is_exact(), "{n} lost its exactness: {back}");
}
}
#[test]
fn text_that_the_tower_does_not_write_is_refused() {
for bad in ["1e3", " 1", "+1", "#i1", "1.50x", "0x10", ""] {
assert_eq!(WireNum::parse(bad), None, "{bad:?}");
let json = serde_json::to_string(bad).unwrap();
assert!(serde_json::from_str::<WireNum>(&json).is_err(), "{bad:?}");
}
}
#[test]
fn wide_integers_cross_as_strings_and_narrow_ones_as_numbers() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct P(
#[serde(with = "wide")] u128,
#[serde(with = "wide_weight")] i64,
);
let below = (1u128 << 53) - 1;
assert_eq!(
serde_json::to_string(&P(below, -3)).unwrap(),
format!("[{below},-3]")
);
let p = P(u128::MAX, i64::MIN);
let json = serde_json::to_string(&p).unwrap();
assert_eq!(json, format!(r#"["{}","{}"]"#, u128::MAX, i64::MIN));
assert_eq!(json_round(&p), p);
assert_eq!(json_round(&P(1 << 53, 1 << 53)), P(1 << 53, 1 << 53));
let past = r#"["340282366920938463463374607431768211456",1]"#;
assert!(serde_json::from_str::<P>(past).is_err());
}
#[test]
fn text_is_byte_exact() {
for t in ["", " é ", "e\u{301}", "\u{0}\n\r\"\\", "\u{1F600}"] {
let v = WireValue::Text(t.to_string());
assert_eq!(json_round(&v), v, "{t:?}");
}
}
fn any_f64() -> impl Strategy<Value = f64> {
prop_oneof![
any::<f64>(),
Just(-0.0),
Just(f64::INFINITY),
Just(f64::NEG_INFINITY),
Just(f64::NAN),
any::<u64>().prop_map(f64::from_bits),
]
}
fn a_decimal(mantissa: u64, scale: usize) -> Option<Num> {
let digits = format!("{mantissa:0>w$}", w = scale + 1);
let (whole, frac) = digits.split_at(digits.len() - scale);
let text = match scale {
0 => whole.to_string(),
_ => format!("{whole}.{frac}"),
};
Num::from_canonical(&text)
}
fn any_value() -> impl Strategy<Value = WireValue> {
prop_oneof![
any_f64().prop_map(|x| WireValue::of(&CellValue::Number(x))),
(any::<i128>(), 1..i128::MAX)
.prop_filter_map("fits", |(n, d)| Num::ratio(n, d))
.prop_map(|n| WireValue::Num(WireNum::of(&n))),
(any::<u64>(), 0usize..=28)
.prop_filter_map("decimal", |(m, s)| a_decimal(m, s))
.prop_map(|n| WireValue::Num(WireNum::of(&n))),
any::<String>().prop_map(WireValue::Text),
any::<String>().prop_map(WireValue::Error),
]
}
proptest! {
#[test]
fn a_value_is_the_identity_through_the_wire(v in any_value()) {
prop_assert_eq!(json_round(&v), v);
}
#[test]
fn a_float_keeps_every_bit_but_the_nan_payload(x in any_f64()) {
let sent = WireValue::of(&CellValue::Number(x));
let CellValue::Number(y) = json_round(&sent).to_cell_value() else {
panic!("a number came back as something else")
};
match x.is_nan() {
true => prop_assert!(y.is_nan()),
false => prop_assert_eq!(x.to_bits(), y.to_bits()),
}
}
#[test]
fn a_zset_is_the_identity_through_the_wire(
elems in proptest::collection::vec((0u8..4, any_value(), -3i64..=3), 0..12)
) {
let z: ZSet<Cell> = elems
.into_iter()
.map(|(k, v, w)| ((WireKey(vec![("c".into(), k.to_string())]), v), w))
.collect();
prop_assert_eq!(decode(&json_round(&encode(&z))), z);
}
}
}
+1
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
pub mod basis;
pub mod client;
pub mod delivery;
pub mod face;
pub mod frame;
pub mod help;
+23 -2
View File
@@ -27,6 +27,10 @@ pub struct ExactSum {
nan: i128,
pos_inf: i128,
neg_inf: i128,
/// Net weight of the `-0.0` addends, and of every other finite one: a zero sum is
/// `-0.0` only when the first is positive and the second is nil (IEEE).
neg_zeros: i128,
others: i128,
}
/// Equal sums, not equal representations: carries propagated, `pending` ignored.
@@ -36,7 +40,9 @@ impl PartialEq for ExactSum {
propagate(&mut mine);
propagate(&mut theirs);
let counts = |s: &ExactSum| (s.nan, s.pos_inf, s.neg_inf);
mine == theirs && counts(self) == counts(o)
let zero = mine.iter().all(|l| *l == 0);
let same_zero = !zero || self.negative_zero() == o.negative_zero();
mine == theirs && counts(self) == counts(o) && same_zero
}
}
@@ -50,6 +56,8 @@ impl Default for ExactSum {
nan: 0,
pos_inf: 0,
neg_inf: 0,
neg_zeros: 0,
others: 0,
}
}
}
@@ -66,6 +74,10 @@ impl ExactSum {
(false, true, true) => self.pos_inf += weight as i128,
(false, true, false) => self.neg_inf += weight as i128,
(false, false, _) => {
match x == 0.0 && x.is_sign_negative() {
true => self.neg_zeros += weight as i128,
false => self.others += weight as i128,
}
let magnitude = weight.unsigned_abs();
for j in 0..3 {
let digit = (magnitude >> (30 * j)) & (CHUNK - 1);
@@ -122,6 +134,12 @@ impl ExactSum {
self.nan += o.nan;
self.pos_inf += o.pos_inf;
self.neg_inf += o.neg_inf;
self.neg_zeros += o.neg_zeros;
self.others += o.others;
}
fn negative_zero(&self) -> bool {
self.neg_zeros > 0 && self.others == 0
}
/// The exact sum rounded to the nearest f64, ties to even.
@@ -130,7 +148,10 @@ impl ExactSum {
(true, _, _) | (false, true, true) => f64::NAN,
(false, true, false) => f64::INFINITY,
(false, false, true) => f64::NEG_INFINITY,
(false, false, false) => ratio_to_f64(&self.total(), &(BigInt::one() << 1074)),
(false, false, false) => match (self.negative_zero(), self.total()) {
(true, total) if total.is_zero() => -0.0,
(_, total) => ratio_to_f64(&total, &(BigInt::one() << 1074)),
},
}
}
+102
View File
@@ -0,0 +1,102 @@
//! ADR 009 step 8's last test: the sign of a zero sum is IEEE's. Evidence: `context/test-evidence/numtower.md`.
use numtower::ExactSum;
use proptest::prelude::*;
fn addend() -> impl Strategy<Value = f64> {
prop_oneof![
Just(-0.0),
Just(0.0),
Just(0.5),
Just(-0.5),
Just(3.0),
Just(-3.0),
]
}
/// IEEE addition from `-0.0`, the identity that keeps a `-0.0` addend's sign.
fn ieee(addends: &[f64]) -> f64 {
addends.iter().fold(-0.0, |sum, x| sum + x)
}
fn exact(addends: &[f64]) -> f64 {
let mut sum = ExactSum::new();
addends.iter().for_each(|x| sum.add(*x, 1));
sum.value()
}
#[test]
fn a_sum_of_negative_zeros_is_negative_zero() {
assert_eq!(exact(&[-0.0, -0.0]).to_bits(), (-0.0f64).to_bits());
assert_eq!(exact(&[-0.0]).to_bits(), (-0.0f64).to_bits());
}
#[test]
fn one_positive_zero_makes_the_zero_positive_and_so_does_a_cancellation() {
assert_eq!(exact(&[-0.0, 0.0]).to_bits(), 0.0f64.to_bits());
assert_eq!(exact(&[0.5, -0.5]).to_bits(), 0.0f64.to_bits());
assert_eq!(exact(&[-0.0, 0.5, -0.5]).to_bits(), 0.0f64.to_bits());
}
#[test]
fn an_empty_sum_is_positive_zero() {
assert_eq!(ExactSum::new().value().to_bits(), 0.0f64.to_bits());
}
/// Addends whose exact sum is zero: some zeros of either sign, and cancelling pairs.
fn zero_sum() -> impl Strategy<Value = Vec<f64>> {
let zeros = proptest::collection::vec(prop_oneof![Just(-0.0), Just(0.0)], 0..5);
let pairs = proptest::collection::vec(prop_oneof![Just(0.5), Just(-3.0)], 0..3);
(zeros, pairs)
.prop_map(|(mut all, pairs)| {
all.extend(pairs.iter().flat_map(|p| [*p, -*p]));
all
})
.prop_filter("non-empty", |all| !all.is_empty())
.prop_shuffle()
}
proptest! {
#[test]
fn a_zero_sum_has_the_sign_ieee_gives_the_same_addends(addends in zero_sum()) {
let want = ieee(&addends);
prop_assert_eq!(want, 0.0, "the generator must build zero sums");
prop_assert_eq!(exact(&addends).to_bits(), want.to_bits());
}
#[test]
fn a_retracted_addend_leaves_the_sign_of_the_rest(
keep in zero_sum(),
gone in proptest::collection::vec(addend(), 0..6),
) {
let mut sum = ExactSum::new();
keep.iter().chain(&gone).for_each(|x| sum.add(*x, 1));
gone.iter().for_each(|x| sum.add(*x, -1));
prop_assert_eq!(sum.value().to_bits(), ieee(&keep).to_bits());
let mut fresh = ExactSum::new();
keep.iter().for_each(|x| fresh.add(*x, 1));
prop_assert_eq!(sum, fresh);
}
}
#[test]
fn a_sum_that_is_not_zero_never_reads_as_negative_zero() {
let mut sum = ExactSum::new();
sum.add(-0.0, 1);
sum.add(5.0, 1);
sum.add(7.0, -1);
assert_eq!(sum.value(), -2.0);
}
#[test]
fn equal_values_compare_equal_whatever_the_zero_counts_say() {
let mut with = ExactSum::new();
with.add(-0.0, 1);
with.add(5.0, 1);
with.add(7.0, -1);
let mut without = ExactSum::new();
without.add(5.0, 1);
without.add(7.0, -1);
assert_eq!(with.value(), without.value());
assert_eq!(with, without);
}
+5
View File
@@ -30,6 +30,9 @@
# The `test` job ran the dev/test profiles, not crane's default release:
# debug assertions and overflow checks are part of what it tested.
CARGO_PROFILE = "";
# No DWARF in the dev/test artifacts: it was about half of the test build's
# disk and the runners hit ENOSPC linking it. Asserts are unaffected.
CARGO_PROFILE_DEV_DEBUG = "0";
};
wasmTarget = "wasm32-unknown-unknown";
@@ -103,6 +106,8 @@ in rec {
// {
pname = "improvise-test";
cargoArtifacts = nativeDeps;
# Nothing reuses the test target dir; archiving it is more disk.
doInstallCargoArtifacts = false;
cargoTestExtraArgs = "--workspace";
nativeBuildInputs = [pkgs.python3 pkgs.git];
# tests/shipped_documents_load.rs walks the git index (`git ls-files`)
+2
View File
@@ -69,6 +69,7 @@ const SECTIONS = [
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
["context/code/numtower.md", "code/numtower", "numtower, the numeric tower"],
["context/code/zset.md", "code/zset", "zset, the Z-set group"],
["context/code/delivery.md", "code/delivery", "delivery, notices and corrections"],
["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/tooling.md", "code/tooling", "The repository's own checkers"],
@@ -90,6 +91,7 @@ const SECTIONS = [
["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/zset.md", "test-evidence/zset", "zset"],
["context/test-evidence/delivery.md", "test-evidence/delivery", "delivery"],
["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/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],