Skip to content

dogsdogsdogs: one lookup primitive under the delta-join operators - #818

Open
frankmcsherry wants to merge 3 commits into
master-nextfrom
dogs3-lookup-cut
Open

dogsdogsdogs: one lookup primitive under the delta-join operators#818
frankmcsherry wants to merge 3 commits into
master-nextfrom
dogs3-lookup-cut

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

dogsdogsdogs: one lookup primitive under the delta-join operators

half_join, lookup_map, count, propose and validate were five hand-rolled
binary_frontier operators, each carrying its own copy of the stash-by-capability, frontier,
cursor-seek and compaction logic. They are one operator with five output functions:

for each (request, matching value): here are the consolidated (time, diff) arrangement
updates the cut admits. Do what you like with them.

operators::lookup is that operator. Two behaviours sit on it — lookup_map sums the admitted
updates into one diff and emits once (count), and lookup_join visits them individually,
joining each time into a time the request carries (propose, validate). half_join2 is
promoted to half_join, whose 366-line body becomes a 155-line derivation.

Two commits: the operator unification, then retiring AltNeu now that the cut is explicit.

Cuts are derived, not chosen

Cut::for_positions(seed, atom) returns the strict or lax comparison from the two atoms'
positions in the query. Rule seed claims a combination exactly when it is the largest index
achieving the Ord-maximum of the times, so exactly one rule claims each. Ordering by Ord
rather than PartialOrder is what makes that maximum exist — under the partial order two
incomparable times have no maximum, no rule claims the pair, and the match is silently lost.
Every other value a caller could pass either double counts or drops matches, so passing one is
now impossible.

The order time stays the timestamp; the join time rides in the payload

Progress tracking has to see the time the cuts compare against. Since order ⪯ join, a frontier
over join times would not bound the order times still in flight, and a downstream operator could
not set its compaction bound. So propose and validate emit per admitted update rather than
accumulating — which matters as soon as a cut admits an update incomparable to the request, i.e.
inside a nested scope.

AltNeu retired

AltNeu<T> encoded the same strict/lax choice in the timestamp: an atom read "before" the seed
was entered at neu so a plain less_equal excluded ties. With Cut carrying it directly the
encoding is redundant — callers were passing a cut and relying on alt/neu to do the real work.

Retiring it removes a scope. Callers ran inside scope.scoped::<AltNeu<_>> and maintained an
"old" and a "new" copy of every arrangement, since alt and neu are separate delayed
collections. On plain usize times the triangle query in delta_query_wcoj goes from four
CollectionIndexes to two
, and lookup_map_regression likewise. calculus
(Differentiate/Integrate) is defined over AltNeu by construction, so it goes too.

Behaviour changes worth a look

  • validate multiplies by the matched diff instead of passing the prefix diff through. An
    atom contributes to the product whether it proposed an extension or merely validated it. For
    set-valued relations the matched diff is one, so nothing observable moves.
  • lookup_map prunes only when the cut admits nothing, never when the admitted diffs sum to
    zero. Those updates can carry different join times, so their sum is not the count at any one
    of them; dropping on it removes the request from the collection outright, so no atom proposes
    it and the extension is lost. An inaccurate non-zero count merely picks a worse proposer.
  • CollectionIndex holds local Arranged values rather than trace handles. Re-importing an
    arrangement the same dataflow produced forfeits the scope's progress tracking; inside a
    recursive scope the timestamp then counts upward without end. Documented on
    TraceAgent::import — the only change outside dogsdogsdogs/, and it stands alone if you'd
    rather it were separate.
  • extend_using gains two parameters (the cut and its compaction bound).

Tests

11, every case at one and four workers.

  • delta_join_property, wcoj_triangle_property — two- and three-atom joins against
    brute-force oracles over random Product<usize, usize> updates. The three-atom one caught a
    real bug during development: lookup sorted its ready prefix by payload, but seek_key only
    advances, so a key selector extracting anything other than a payload prefix could send the
    cursor backwards and silently see no match.
  • half_join_total_order — exactly-once pinned from all three sides, on incomparable and on
    equal times.
  • ktruss_iterative — a worst-case-optimal join inside a recursive scope, maintained
    incrementally across insertions and deletions. This caught the zero-count prune above.
  • lookup_map_regression — ported off AltNeu; it asserts on its output, so it is the evidence
    that derived cuts and plain timestamps find the same triangles the alt/neu encoding did.

Deliberately left

count still reports distinct-extension cardinality. That tracks changes and no longer reports
a false zero, but it measures the wrong thing — the work is the post-consolidation updates a
walk encounters, and reporting that needs a per-key update-count hook on the cursor that does
not exist. Left alone rather than swapped for a proxy that saturates.

There is no tactic trait here yet; lookup takes a closure-based probe. The natural boundary
(cursor at a key, request's time in, admitted updates out) would own the admits test, the
release gate and the compaction bound together, and is where a columnar or int-proxy backend
would plug in.

Diffstat

22 files changed, 1876 insertions(+), 1223 deletions(-)

dogsdogsdogs/src          903 insertions, 1033 deletions   (net -130)
dogsdogsdogs/tests        838 insertions,    39 deletions
dogsdogsdogs/examples     117 insertions,   151 deletions
differential-dataflow      18 insertions                   (import doc)

Source shrinks modestly; the additions are tests and doc comments — a little over half of the
new lookup.rs is documentation.

`half_join`, `lookup_map`, `count`, `propose` and `validate` were five
hand-rolled `binary_frontier` operators, each carrying its own copy of the
stash-by-capability, frontier, cursor-seek and compaction logic. They are one
operator with five output functions:

    for each (request, matching value): here are the consolidated (time, diff)
    arrangement updates the cut admits. Do what you like with them.

`operators::lookup` is that operator. Two behaviours sit on it: `lookup_map`
sums the admitted updates into one diff and emits once (`count`), and
`lookup_join` visits them individually, joining each time into a time the
request carries (`propose`, `validate`). `half_join2` is promoted to
`half_join`, whose 366-line body becomes a 155-line derivation; the previous
implementation is removed.

Source is roughly flat at +903/-864 -- this consolidates duplicated operator
logic rather than shrinking the tree, and a little over half of the new
`lookup.rs` is doc comments. The +804 under `tests/` is the bulk of the change.

Cuts are derived, not chosen. `Cut::for_positions(seed, atom)` returns the
strict or lax comparison from the two atoms' positions: rule `seed` claims a
combination exactly when it is the largest index achieving the `Ord`-maximum of
the times, so exactly one rule claims each. Ordering by `Ord` rather than
`PartialOrder` is what makes that maximum exist -- under the partial order two
incomparable times have no maximum, no rule claims the pair, and the match is
lost. Every other value a caller could pass either double counts or drops
matches.

The order time stays the dataflow timestamp and the join time rides in the
payload. Progress tracking must see the time the cuts compare against, and
since order <= join, a frontier over join times would not bound the order times
still in flight. `propose` and `validate` therefore emit per admitted update
rather than accumulating, which matters as soon as a cut admits an update
incomparable to the request -- that is, inside a nested scope.

`CollectionIndex` holds local `Arranged` values rather than trace handles.
Re-importing an arrangement the same dataflow produced forfeits the scope's
progress tracking, and inside a recursive scope the timestamp then counts
upward without end. Documented on `TraceAgent::import`.

Two behaviour changes worth review:

  - `validate` multiplies by the matched diff instead of passing the prefix
    diff through. An atom contributes to the product whether it proposed an
    extension or merely validated it. For set-valued relations the matched diff
    is one, so nothing observable moves.

  - `lookup_map` prunes a request only when the cut admits nothing, never when
    the admitted diffs sum to zero. Those updates can carry different join
    times, so their sum is not the count at any one of them; dropping on it
    removes the request from the collection outright, so no atom proposes it
    and the extension is lost. An inaccurate non-zero count merely picks a
    worse proposer.

`count` still reports distinct-extension cardinality, which tracks changes but
measures the wrong thing: the work is the post-consolidation updates a walk
encounters. Reporting that needs a cursor hook that does not exist yet, so it
is left alone.

Tests, 11 in total, every case at one and four workers:

  - `delta_join_property`, `wcoj_triangle_property`: two- and three-atom joins
    against brute-force oracles over random `Product<usize, usize>` updates.
  - `half_join_total_order`: exactly-once pinned from all three sides, on
    incomparable and on equal times.
  - `ktruss_iterative`: a worst-case-optimal join inside a recursive scope,
    maintained incrementally across insertions and deletions.

Created using Claude Code
`AltNeu<T>` encoded a delta rule's strict-vs-lax choice in the timestamp: an
atom read "before" the seed was entered at `neu` so that a plain `less_equal`
excluded ties, and one read "at or before" was entered at `alt`. `Cut` now
carries that distinction directly, derived from the atoms' positions, so the
encoding is redundant -- the callers were passing a cut *and* relying on
alt/neu timestamps to do the real work.

Retiring it removes a scope. Each caller ran inside `scope.scoped::<AltNeu<_>>`
and maintained an "old" and a "new" copy of every arrangement, because alt and
new are separate `delay`ed collections. On plain `usize` times with derived
cuts, one index per orientation suffices: the triangle query in
`delta_query_wcoj` goes from four `CollectionIndex`es to two, and
`lookup_map_regression` likewise.

`calculus` (`Differentiate` / `Integrate`) is defined over `AltNeu` by
construction -- `differentiate` splits each update into an alt and a neu half --
so it goes too. Its only user was the second half of `delta_query`, which
cross-checked the propose/validate path against a calculus-based one with
`assert_eq`. That cross-check is lost; the propose/validate path is now covered
by property tests against brute-force oracles, which is a stronger check than
agreement with a second implementation.

`delta_query` keeps its first half, ported to derived cuts, and remains the one
example of the raw `propose`/`validate` operators with the delta region
bracketed by hand rather than by `extend`.

Verified by `lookup_map_regression`, which asserts on its output: the same
triangle is found with plain `usize` times and derived cuts as with the alt/neu
encoding.

Created using Claude Code
`lookup`'s module doc described the flat structure that existed before
`lookup_join` was split out, so it named `lookup_map` as the only behaviour and
claimed `propose` and `validate` went through it. They do not: they take
`lookup_join`, and `validate` multiplies rather than filters.

Replaces that with the actual three layers, and states why the choice between
the two behaviours is forced rather than stylistic -- `lookup_map` collapses
many admitted times into one output, which is correct only when the request's
time dominates them all, so it suits `count` (a routing decision, contributing
no record and no time) and not `propose`/`validate` (each contributing a record
to a tuple that exists at the join of all contributing times).

Also notes that `half_join` reaches `lookup` directly rather than through
`lookup_join`, and why: its output function takes the whole admitted list and a
container builder, because advancing times by a carried time can collapse
distinct times and it must re-consolidate. Folding the two together is
plausible and untried.

Disambiguates the intra-doc links, each of which named a module and a function
of the same name; `cargo doc` is now warning-free.

Created using Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant