Skip to content

Corgi-DDIR work#795

Draft
frankmcsherry wants to merge 69 commits into
TimelyDataflow:master-nextfrom
frankmcsherry:corgi-on-mn
Draft

Corgi-DDIR work#795
frankmcsherry wants to merge 69 commits into
TimelyDataflow:master-nextfrom
frankmcsherry:corgi-on-mn

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

No description provided.

frankmcsherry and others added 30 commits July 3, 2026 04:14
…, value_id)

A boundary where only integers cross: a storage backend presents each
record as ((key_hash, value_id), time, diff) — integer proxies for data
it keeps in its own layout — the operators own all the lattice/time
logic over those integers, and the backend supplies value semantics via
callbacks. Any columnar (or otherwise opaque-to-DD) value store can
then reuse join and reduce without materializing values.

- trace/chunk/int_proxy: ProxyChunk, a cursor-less Chunk of proxy
  columns, with from_unsorted (integer sort+consolidate with
  representative provenance) as the presentation-building helper.
- operators/int_proxy: ProxyJoinTactic / ProxyReduceTactic for the
  join_with_tactic and reduce_with_tactic seams (made pub here), and
  the backend traits: present-as-proxies (read), value callback with
  hash-minted output ids (write), materialize (egress). Reduce output
  ids are content hashes, so an output arrangement re-presents with the
  same ids downstream with no registry; pending interesting times are
  keyed by the stable key_hash across retires. The module doc carries
  the boundary contract and design notes (why value_id is not
  order-preserving; collision risk); each tactic and the in-memory
  reference backend (VecChunk arrangements, fnv hashes) sit in their
  own file under the module.
- Tests: join and count/distinct/min reduces against the row operators
  over multi-round retracting inputs, and a scripted Product-time
  retire sequence exercising synthetic corrections and pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e, not a requirement

value_id never outlives one computation (a join unit's presentations;
one reduce retire, whose materialize resolves ids to real data before
anything leaves). The actual contract is a per-computation bijection
with value equality, plus within-retire agreement between the output
presentation and minted ids. Content hashing discharges all of it
statelessly (the reference backend's choice); exact schemes — dense
ordinals from grouping, a per-retire value→id map — are equally valid
and collision-free. Only key_hash must be a stable pure function of the
key (cross-retire pending, changed-key filter), making the key side the
irreducible collision exposure. Persisting ids into an output
arrangement itself would force stable value ids; this design does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…worse than the cursor tactics

Two regressions found by asking exactly that question, both fixed:

- The reference reduce backend implemented the changed-key restriction
  by scanning every batch and filtering — O(trace) per retire where the
  cursor tactic seeks, O(delta·log). Presents now seek the changed keys
  (novel keys resolve from this retire's delta-sized input batches;
  pending keys from the retire that pended them, which is exactly what
  the persistent hash→key map retains — pruned to the changed set each
  retire, so it is bounded by the delta, and the per-retire value map
  is cleared).

- The join interface had no restriction at all, forcing ANY backend to
  present the entire accumulated side per fresh batch — O(trace·log)
  per unit where the cursor join seeks. present0/present1 now take an
  optional sorted key-hash filter; the tactic presents the fresh side
  first and passes its key set for the accumulated side, the join
  analogue of reduce's changed-key restriction.

The check is mechanical, not wall-clock: counting backend wrappers
measure presented records — with 20k arranged keys and five single-key
rounds, reduce presents 37 records and join 10, where the scanning
versions present Ω(rounds·N) (join: 100,005) and fail the gate. What
remains above the cursor tactics is a log-factor sort of delta-sized
presentations, and per-(key, wave) rescans of a changed key's
presented range where the row replayer consolidates progressively —
same worst-case order, different constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cle fuzz

The minimal harness for the framework, closed over itself: the tactics
demand only BatchReader of the trace, and ProxyChunk is already a
Chunk, so a batch of proxy chunks is the minimal arrangement — the
proxy data IS the data, no separate chunk class needed. The identity
backend makes values u64s with the identity as the id function: no
hashing, no resolution machinery, no collision possibility, and
materialize emits the proxy records verbatim (incidentally exercising
ChunkBatch<ProxyChunk> as a real output batch).

What remains under test is exactly the framework's own contribution —
interesting-time discovery, desired-vs-current deltas, pending, held
routing — fuzzed over Product-time grids: 300 random inputs retired
through random diagonal frontiers (so synthetic joins arise inside and
across intervals and must pend), driven through an emulation of the
reduce driver protocol, and checked against a brute-force oracle at
every grid point: the accumulated output must equal the reduction of
the accumulated input, everywhere, for count/distinct/min-shaped
reducers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An ignored benchmark (cargo test --release --test int_proxy -- --ignored
--nocapture) compares full stacks — the stock row operators against
chunk arrangements plus the proxy tactics over the reference backend —
for a bulk load and a steady-state incremental phase (warmed past the
post-load merge-amortization transient).

The bench caught what the counting gates could not: the reference
backend's hash→key map was pruned by retain (and the per-retire value
map by clear), both of which keep the backing table — so after a
million-key load, every retire walked a million-bucket table to visit
one entry, ~130µs/round of pure capacity. shrink_to_fit after the
prune and a fresh map per retire fix it.

Steady-state single-key rounds after the fix, proxy vs row: reduce
~1.4x at every scale (flat from 10k to 1M keys — the delta-
proportionality gates hold in wall clock too), join at parity below 1M
(~1.0x). Bulk load carries the presentation layer's constant factor
(per-record hashing, clones, by-hash sort): reduce 3-5x, join ~2x —
the costs a columnar backend's bulk primitives are meant to attack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The longer-term goal for the boundary is fewer crossings into the
backend's value logic (interpreted or columnar execution pays per-call
overhead): each crossing should carry a list of keys and a longer
bracketed list of value entries.

ProxyReduceBackend gains reduce_many(keys, ends, input) — group_offsets
-shaped brackets, one per key, each non-empty — returning concatenated
per-key outputs with their own bracket ends. A default implementation
loops the per-key reduce, so simple backends implement only that;
backends with bulk value logic override reduce_many.

The tactic now calls only reduce_many: retire's key-major loop becomes
two passes — derive each changed key's active times, group the work
into waves by time, then play the waves in ascending order (Ord extends
the partial order, so a key's earlier deltas always precede a later
time's reads) with at most one callback per wave, batching every key
active at that time.

The identity backend overrides reduce_many (asserting the bracket
protocol from the backend's seat), so the grid-oracle fuzz exercises
the batched path; the reference backend uses the default, so the
row-comparison tests cover the loop. All gates and benches unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desired output at a (key, time) moment is a function of the key's
input accumulation at that time alone — no output-side state — so no
time ordering constrains the batch: every moment of the retire can
share one reduce_many call, a key contributing one bracket per active
time. The order-sensitive part (subtracting the current output, which
includes deltas emitted at earlier moments) is pure proxy-space
arithmetic and moves to a separate pass that plays the moments in
ascending time order.

The bracket, not the key, is reduce_many's unit: keys may repeat.
Contract docs updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…linear

The row suite's reduce_scaling/join_scaling shapes (one key, many
distinct times, one batch) exposed quadratic behavior in both proxy
tactics: reduce rescanned a key's full presented range per interesting
time (and its interesting-time closure joined all pairs), and join
cross-produced full matched histories pairwise. Measured: reduce 6.4s
at scale 10k, 4.7x per doubling; join 9.8s at 10k, >90s at 20k.

The robust versions are the cursor variants with integers in place of
keys and values, as intended:

- history.rs: IdHistory, the id-space ValueHistory — (value_id, time,
  diff) edits replayed in ascending time order into a buffer repeatedly
  advanced by the meet of the times still to come and consolidated.
  The advancement is the collapse that keeps a key with many distinct
  times linear: accumulations read the small buffer, never the raw
  history. The presentations serve as the fused per-key load.
- reduce: discover_and_accumulate ports history_replay::compute —
  lazy interesting-time discovery (novel and pending seed; synthetics
  from joins with the advanced batch buffer and times_current) replaces
  the eager join-closure, and per-moment accumulation reads the
  advanced buffers. Phase B replays the output side per key over the
  discovered moments with the same machinery (suffix meets; emitted
  deltas advanced and consolidated). Still one batched reduce_many
  crossing per retire.
- join: join_key ports JoinThinker::think — each side's edits replayed
  against the other side's advanced buffer (identical emitted times:
  t0 ∨ (t1 ∨ meet) = t0 ∨ t1), with the dead-simple cross product kept
  for small histories.

proxy_reduce_scaling / proxy_join_scaling (scale 100k, the row tests'
shapes) now pin this; scale 10k dropped from seconds to milliseconds
and growth is ~4x per 4x scale. The grid-oracle fuzz over partially
ordered times, the scripted pending test, the row comparisons, the
delta-proportionality gates, and the steady-state bench all pass
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The row-vs-proxy comparison on the scaling shapes showed proxy reduce
still superlinear (9x per 4x scale; timeout at 4M where row takes
0.5s). Profiling pinned it outside the tactic: the reference backend's
materialize built ONE giant VecChunk and let the builder settle it,
and settle's split path peels TARGET-sized pieces off the front with
split_off, copying the remaining tail each iteration — O(m²/TARGET)
in the batch size. Feeding the builder TARGET-sized chunks directly
fixes it: 4M drops from >120s to 3.2s.

With that, both operators are in the row implementations' complexity
class on the scaling shapes (growth ~5x per 4x scale ≈ n·log n; the
hot frames are the presentation sort and fnv hashing — constants, not
structure): at scale 4M, join row 0.96s / proxy 3.6s, reduce row 0.48s
/ proxy 3.2s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TimelyDataflow#778 Chunk split)

A columnar differential-dataflow backend for DDIR whose native representation
is corgi Value columns and whose scalar logic is corgi `eval_graph`, parallel
to `backend::vec`.

Arrangement:
  - CorgiChunk : Chunk (NOT NavigableChunk) — corgi columns + Vec time/diff,
    ordered (key,val) by corgi structural order then time. merge/extract/advance/
    settle ported from the VecChunk reference; drives corgi's discrimination sort
    (sort_perm) + batched compare_idx, never per-pair. Rides TimelyDataflow#778's cursor-less
    Chunk path, so it gets the fueled/graded ChunkBatchMerger for free.
  - Tactics (Route B): cursor-less reduce (incremental key selection over the
    input delta); join via corgi `find` (find_ranges → equal-range merge-join,
    multi-record). as_collection reads columns directly.

differential-dataflow proper: widen the JoinTactic/ReduceTactic/Fresh/
join_with_tactic/reduce_with_tactic seam from pub(crate) to pub, so an
out-of-crate tactic can be implemented at all. (Candidate for upstreaming as
the public extension point the TimelyDataflow#773 tactics were designed to be.)

State: all 6 canonical programs match `vec`; 33 lib tests pass. reach ~1.9×
slower than vec, compute-bound linear ~3× FASTER (columnar eval avoids the
row backend's ~22% Value::cmp pointer-chasing). The remaining reach gap is
entirely the reduce, still row-wise — the only operator not yet columnar.

Notes / follow-ups: corgi_arrange.rs (old CorgiBatch impl) is superseded by
corgi_chunk.rs and retained only as reference; scratch examples alongside the
corgi_perf/corgi_progs/corgi_prof harnesses. Depends on frankmcsherry/wip
corgi branch dd-arrange-api.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A standing harness that isolates each backend operator in a minimal DDIR
program, asserts corgi == vec, and reports the corgi/vec ratio against a
target. `PROG=<name> BACKEND=<corgi|vec>` loops one case for samply.

Baseline (n=100k linear / 20k arrange-y):
  map8            0.30x  ✓ (columnar compute, no row boundary)
  filter          0.75x  ✓
  arrange         1.5x   ✗   \
  join            1.5x   ✗    >  every operator with a columns<->rows
  reduce_distinct 2.0x   ✗   /   transcode boundary loses
  reduce_count    2.0x   ✗
  reach           2.0x   ✗

Corrects an earlier whole-program-profile conclusion ("only the reduce is
slow"): per-operator, arrange and join are independently ~1.5x — the shared
cost is the transcode boundary, not one operator. Beating vec means removing
row boundaries (chunk-native ingest, columnar operator emit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… vec

The columns↔rows transcodes at operator edges were self-inflicted, not
inherent. Removed all three:

  - as_collection: read chunk columns straight into a CorgiContainer
    (chunks_to_columns), no untranscode→from_updates round-trip.
  - join emit: the projection already yields corgi columns; emit them via
    give_container into a CapacityContainerBuilder<CorgiContainer> and drop the
    JoinToCorgi unary — no untranscode + per-row give + re-transcode.
  - arrange ingest: CorgiChunker (a ContainerBuilder) sort-consolidates each
    input CorgiContainer's columns directly into CorgiChunks — replacing
    ContainerChunker's drain-to-rows + VecMerger + row-based builder. It
    ACCUMULATES to TARGET before consolidating, so it emits few large chunks,
    not one tiny chunk per input container (else the columnar per-chunk set-up
    dominates on many small batches).

Scorecard (corgi/vec): arrange 1.5x→0.9x (BEATS vec), join 1.5x→0.7x (BEATS
vec), reduce_distinct/count 2.0x→1.35x, reach 1.9x→1.45x. All 6 programs match
vec; 33 lib tests pass. Remaining reduce gap is the row-wise reducer (Rust over
Value rows) — next: columnar consolidate via corgi group→fold_add→filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
columnar_sum_by_key: the monoid Count/consolidate fold as pure corgi ops
(group → map(fold_add) → filter via parse_ml + eval_graph), no Value rows.
Signed diffs passed as raw two's-complement u64 bits; wrapping fold_add gives
the correct i64 sum, `ne 0` the sign-agnostic zero-drop. Unit test:
[(1,+1),(1,-1),(2,5),(3,9),(3,1)] -> [(2,5),(3,10)] (net-zero key dropped).

Groundwork for the wave-based columnar reduce; not yet wired into the live
reduce (which stays row-wise). Depends on corgi dd-arrange-api wrapping-Reduce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…consolidation

Count = Σ diffs ≤ t, and summing the consolidated per-value diffs equals summing
the raw diffs — so for Count the consolidate_vals (a Value sort + clones) is pure
waste. Sum diffs directly; no Value churn. reduce_count 1.39x→1.30x, all 6
programs match vec.

Distinct/Min still consolidate per value (they reason about individual values) —
that's the corgi two-level group→fold→filter path next; Collect stays row-wise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… tested)

columnar_distinct_keys: the keys with a present value (net diff != 0, the
nonzero simplification) as pure corgi ops — group by the (key,val) composite,
fold_add raw diffs, drop net-zero, distinct keys of survivors. group-by-(key,val)
is just group-by a projected composite; the nonzero test keeps everything on the
raw-bit `ne 0` path (no signed encoding needed yet). Unit test:
(1,10):+1,-1 → absent; (2,20),(2,21),(3,30) → present → keys [2,3].

Both monoid consolidate blocks now proven (sum for Count, two-level for
Distinct). Remaining: the wave integration (feed many keys per wave, driven by
the Rust time logic; reuse the existing delta/emit loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…primitive

Group a key column by identity, returning only integers: `perm` (row indices in
group order) + `ends` (per-group exclusive end within perm). DD walks groups by
index — group g is perm[ends[g-1]..ends[g]] — and drives its time/diff logic
without ever seeing a key Value; keys and payloads stay columnar in corgi. This
is the boundary model (corgi presents abstract int ids tied to times/diffs DD
sees; DD hands back indices to include). Built on sort_perm + one batched
compare_idx. Tested.

Foundation for the path-2 columnar reduce (wave loop over group ranges). Note:
cross-retire `pending` needs a STABLE key id (a u64 hash), since group indices
are per-retire — converges with hashed-keys-as-u64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oundary model

Records: arrange & join now BEAT vec (transcode boundaries removed); multi-record
primitives exposed; reduce consolidate blocks + group_offsets proven; and the
design capture for a data-blind reduce tactic (framework presents as int-id/
time/diff, owns time navigation; backend supplies id-mapping + value callback;
output ids are hashes — mint = hash the new value).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…flow#781) consumption plan

Branch rebased onto int-proxy: the framework the SPIKE's boundary model
called for now exists upstream (renamed ids -> int_proxy in review);
the old in-branch copy is dropped. The hand-off section documents the
current interfaces, the one-crossing-per-retire reduce_many contract,
the assessment harness to replicate, the reduce-first plan, the traps
already hit once (scan-presents, capacity leaks, giant-chunk settle),
and the suitability questions the corgi agent should answer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement ProxyReduceBackend for corgi and swap CorgiReduceTactic ->
ProxyReduceTactic. The DD tactic owns all time/lattice logic over
(key_hash, value_id, time, diff); the corgi backend supplies only:
 - hashing: key_hash/value_id via corgi::arrange::hash_rows over the corgi
   key/val COLUMNS (columnar, content-addressed; DD never hashes).
 - reduce_many: ONE crossing per retire over every (key,time) bracket.
   Count/Distinct are integer-only (no value untranscode); Min/Collect
   gather+untranscode the bracket values once, never per key. Output ids
   minted by hashing the produced value column (agrees with present_output).
 - present_input/present_output: read arrangement chunks, restrict to changed
   keys by columnar semijoin (novel whole, history filtered); delta-proportional.
 - materialize: resolve ids -> real (key,val) rows, seal a CorgiChunk batch.

corgi_progs: all 6 match vec (scc min/recursion + unnest collect included).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the DValue resolution maps (HashMap<u64,DValue>) with corgi COLUMN
pools + integer id->row-index maps. The real keys/values never leave corgi
columns:
 - present_input/output register representative key/val columns into per-retire
   pools (key_index/val_index -> offsets into concatenated blocks), no untranscode.
 - reduce_many builds each reducer's output value COLUMN directly: Count -> u64
   prim, Distinct -> Unit, Min -> gathered input rows (id reused from input),
   Collect -> List. Ids minted by hash_rows over the built column. No transcode.
 - materialize resolves proxy ids to columns by gather + columns_to_batch
   (new corgi_chunk helper), column-native egress (no from_rows transcode).

The only residual untranscode is Min/Collect's value ORDERING (the reduction
contract is DDIR Ord, which is not corgi's structural order); the chosen rows
are still taken columnar.

Scorecard corgi/vec: reduce_distinct 1.01x -> 0.92-0.96x (BEATS vec),
reduce_count 1.03x -> 0.90-0.93x (BEATS vec), reach 1.10-1.22x -> 1.06-1.13x.
corgi_progs: all 6 match vec; interactive lib tests green (36).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the last untranscode (Min/Collect value ordering) with one columnar
corgi sort_blocks per retire:
 - Min: gather positive-diff candidates, segment by bracket, sort_blocks ->
   each bracket's argmin = perm[block_start]; output row taken columnar,
   reusing the input value_id.
 - Collect: segment entries by bracket, sort_blocks -> sorted run per bracket,
   expanded by diff into a List column.

Uses corgi STRUCTURAL order, which equals DDIR Ord for the non-negative
scalar/tuple values these reductions see (all 6 programs); diverges only for
negative ints (unsigned leaf compare) and list-valued compares (length-first),
neither of which arises here — documented as an order invariant.

Needs corgi::arrange::sort_blocks (exposed on fm/corgi dd-arrange-api 9b41cdc,
the segmented sort re-exported from ops::cmp::order). The reduce backend is now
entirely transcode-free. corgi_progs: all 6 match vec; lib tests green (36);
reduce still beats vec (distinct 0.92-0.95x, count 0.93-0.94x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
key_index/val_index keys are already well-distributed hash_rows u64s, so
siphash on every register/lookup was wasted (~7% of the reduce tactic in
profiling). Pass the id through unchanged. reduce_count 0.93x->0.88x;
distinct/reach within noise (their id-maps are small). Gate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The proxy records sort by (key_hash, value_id, time); key_hash is a full 64-bit
content hash, so one MSD counting pass on its high byte splits into 256
near-uniform buckets, each finished by a comparison sort on the full key. One
linear pass replaces most of the n log n (falls back to the plain sort for
n<512 or clustered hashes). Profiling: from_unsorted 20% -> 8% of the reduce,
driftsort gone. Wall-clock ~flat (0.89->0.90x) — the reduce is allocation-bound
(malloc 33-38%), so this just uncovers that as the next lever. Framework
(int_proxy + reduce_reference) tests green; corgi_progs all 6 match vec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
retire allocated fresh Vecs + an IdHistory for EVERY changed key (58% of the
reduce's Vec-growth in profiling, IdHistory another 17%). Hoist the transient
buffers out of the per-key loops and clear/reload them (IdHistory::load already
clears+refills, retaining capacity): rep, raw_moments (drain), and phase-B
meets, out_replay, emitted. Pure capacity reuse, no semantic change.

Scorecard: reduce_distinct/count 0.90->0.85-0.86x, reach 1.06->1.00x (parity)
at n=4000. Allocation was the real lever (sort CPU wasn't). Framework tests
(int_proxy + reduce_reference) green; corgi_progs all 6 match vec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…IdMap)

Phase B allocated a fresh BTreeMap per (key, moment) for the desired-vs-current
delta. Replace with one HashMap keyed by an identity hasher (value_ids are
already u64 content hashes), hoisted above the loop and cleared per moment
(retains capacity, no per-moment alloc/free). Iteration order is irrelevant —
deltas are consolidated and the output batch is sorted by from_unsorted.

Scorecard: reduce_count 0.86->0.85x, reach 1.06->0.98x at n=4000 (below parity).
Framework tests (int_proxy + reduce_reference) green; corgi_progs all 6 == vec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SCC is the sharp recursive case (nested fwd/bwd label propagation via min,
negation, 4 joins). Findings: corgi is ~1.9-2.5x slower than vec and the gap
GROWS with n (unlike reach, which reaches parity) — scc's nested-recursion /
negation / min structure scales worse for corgi. And corgi DIVERGES from vec on
larger random graphs (n>=1000: 1297 vs 1292) — a corgi-backend correctness bug
(the proxy reduce tactic + reference backend pass the scc oracles in
reduce_reference.rs, and it is NOT the min value-ordering — tested with a
DValue-order argmin). Harness flags the mismatch instead of panicking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two debugging tools:
 - reduce_reference.rs: add reduce_int_proxy (int_proxy ProxyReduceTactic + Vec
   value backend over VecChunk, NO corgi) and scc_int_proxy_* tests that run SCC
   with only the reduce swapped to int_proxy, vs the cursor reduce. They PASS up
   to 50 nodes/120 edges/20 seeds — so the int_proxy reduce TACTIC is correct at
   SCC's nested-product-time depth. (reduce_reference's own scc_* compare cursor
   vs the *stock* reference tactic, never int_proxy — this fills that gap.)
 - interactive corgi_scc_min.rs: sweep+delta-debug an SCC corgi-vs-vec divergence
   to a minimal graph. Finds nodes=4, 5 edges [(0,0),(0,2),(1,3),(2,1),(2,2)]:
   corgi=3 vs vec=2.

Conclusion: the divergence is in the CORGI backend, not the int_proxy work. The
int_proxy tactic + Vec values reproduces SCC exactly; swapping in corgi is what
breaks it. Next: narrow within corgi (reduce value backend vs join vs negate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ure)

Four minimizer/trace harnesses (interactive examples):
 - corgi_scc_min: minimal SCC divergence = 4 nodes, [(0,0),(0,2),(1,3),(2,1),(2,2)].
 - corgi_scc_trace: on that graph, export each SCC intermediate. Result:
   fwd::labels OK, trim_fwd OK, bwd::labels DIFF (node 2 -> label 1 in corgi vs
   2 in vec), trim_bwd/scc DIFF. So the divergence enters at the backward min-
   reduce, structurally identical to the correct forward one.
 - corgi_cc_min: single-level fwd min-label propagation NEVER diverges (to 80
   nodes) -> not single recursion.
 - corgi_bwd_min: two-pass fwd->trim->bwd with NO outer loop NEVER diverges ->
   not the chained nested scopes either.

Conclusion: the bug REQUIRES SCC's outer recursion (the iterate wrapping fwd/bwd
-> 3-deep product times, plus negation  and feedback
). Symptom is a failed retraction (stale label survives).
Suspects: corgi 3-level dynamic-time handling (leave_dynamic/results_in/enter_at)
or negation under recursion. Not int_proxy (proven), not the reduce structure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
corgi_scc_stream: emit the full (data,time,diff) stream of SCC intermediates via
inspect, per backend; diff consolidated by (data,time) to find the first moment
of divergence (DD is deterministic at all times).

Result on the minimal 4-node graph: fwd::labels and trim_fwd streams AGREE
exactly (the 16-vs-10 raw counts were +1/-1 that consolidate away). The bwd
min-reduce: its INPUT (proposals+nodes) diverges only at inner-times 258/513,
but its OUTPUT diverges at 257/512 — one tick earlier. So at time (0,[1,257])
the reduce input agrees while the output diverges (vec retracts node2->1, corgi
does not). By the inputs-agree-output-differs test, the bug is IN the corgi
min-reduce. The int_proxy tactic is proven on SCC (scc_int_proxy), so it's the
CorgiReduceBackend (present/materialize/changed-key), not the tactic. The
forward min-reduce (same code) is correct -> a data-specific edge case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Instrumented CorgiReduceBackend::present_input (env CORGI_DBG) to dump node-2's
presented input on the minimal repro, and compared to vec's true input stream:
for (node2, label1), vec has [0,257]+1 [0,258]+1 then [1,257]-1 [1,258]-1
(added round 0, retracted round 1 -> gone); corgi's present has [0,257]+1
[0,258]+1 [1,258]+1 -- the round-1 retractions are MISSING (plus a spurious +1).
And corgi's input STREAM (bwdin) agrees with vec at [1,257] (both -1), so the
retraction enters the arrange but present_input never returns it. => corgi loses
a reduce-input update when arranging/presenting under nested times; the min then
reads a non-retracted label1 and stays stale = 1. Not the min logic, not the
int_proxy tactic (proven) -- the reduce-input arrangement (CorgiChunk) or
present_input's changed-key/novel-history read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
frankmcsherry and others added 30 commits July 10, 2026 19:53
For a primitive column — a bare Prim(64) or a 1-field Prod([Prim(64)]) — the
value itself is already a collision-free id (i64 as u64 is a bijection), so pass
it through directly instead of content-hashing. Compound shapes still hash. The
id is used only as an identity for netting/dedup — its numeric order is never
relied on — so the raw two's-complement u64 is correct even for negatives; no
order-preserving swizzle needed.

Applied CONSISTENTLY at every id site (both value presentations AND the
reduce_brackets output values), so desired-current still nets by matching ids.

Gate: corgi_progs 6/6 (all programs match vec). Scorecard: reduce_distinct
1.18x->1.07x, reduce_count 1.17x->1.07x, reach 1.26x->1.20x. join unchanged
(the Phase 2a join is already hash-free). SCC flat within single-run noise
(allocation-bound, not hashing-bound) — confirming columnar times is the SCC lever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces columnar times for CorgiChunk without a DD change. Our
Time = Product<u64, PointStamp<u64>> already derives columnar::Columnar at every
layer (Product, PointStamp, u64), with the container's Ref deriving Ord/PartialOrd
(#[columnar(derive(Ord, PartialOrd))] on both Product and PointStamp). So the same
per-tuple times that were Vec<T> (one PointStamp SmallVec per row — the dominant
SCC allocation) live in <T as Columnar>::Container as SoA (offsets + values), one
allocation pair per column instead of n.

ColTimes exposes exactly what the CorgiChunk consumers need: push(&T) / push_ref
(Ref straight across, no T), get(i)->T (materialize only for Lattice ops + emit),
len, and cmp/cmp_cross (in-place ordering via the derived Ref: Ord, HRTB-bounded).

Not yet wired — Inner.times stays Vec<T> this commit. Containment rationale: the
Chunk trait boundary is whole-chunk + frontier-antichain only (CorgiChunk is Chunk
but NOT NavigableChunk, so no cursor TimeContainer/TimeGat), and the batcher/spine
move whole Rc<ChunkBatch> — so every O(data) per-tuple T is Inner.times, ours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CorgiChunk's resident per-tuple times are now SoA (ColTimes over
<T as Columnar>::Container) instead of Vec<T> — killing the per-row PointStamp
SmallVec allocation that dominated the SCC profile (~40%). The hot maintenance
paths (merge/emit/extract/advance/settle/concat) build and read times columnar:
compares go through the derived Ref: Ord in place (no T materialized), range
copies push Refs straight across, and an owned T is reconstructed only where a
Lattice op is unavoidable (advance_by in compaction) or at the egress boundary
(SortedRun for the join, CorgiContainer for as_collection — both want owned T).

Bounds: a ColTime alias (Timestamp + Lattice + Columnar) bundles the constraint;
the viral HRTB `for<'a> Ref<'a,T>: Ord` is discharged once in ColTime's blanket
impl behind ColTime::cmp_refs, so downstream code needs only T: ColTime. Ingress
paths (sort_consolidate, CorgiChunker accumulator, flatten_restricted) stay Vec<T>
(they already hold owned T), converting at from_columns.

No DD change. Gate: corgi_progs 6/6; 29 lib tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CorgiChunk::merge now drives off corgi `survey(kv1, kv2)`: one bidirectional
gallop over the (key,val) columns yields Runs — maximal exclusive A/B ranges
(bulk-copied, no per-row compare) + single Both matched pairs (time-ordered /
consolidated here, since corgi owns no time). Replaces the per-pair two-pointer
compare_at (the 22%-and-growing merge-compare tax). CorgiChunk::advance uses
`group_bounds(ckv)` for the group-boundary scan (one pass) instead of per-row
compare_at. Bumped corgi pin 9b41cdc -> f556868 (survey/group_bounds + hash_order
reverted).

Note on times: survey's Both is a positional (key,val) match, so it can
transiently mis-order/under-consolidate the out-of-band times; advance re-sorts +
re-consolidates each (key,val) group before any consumer reads the chunk, so it's
correct end-to-end (gate 6/6 + 29 lib tests incl. merge/advance/is_graded oracle).

SCC: 1.94x -> 1.62x vec (n=100k 6.55s -> 5.43s, ~17% off corgi). Branch
corgi-survey-merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tried a delta-proportional find_ranges seek of the changed keys (value-as-id
makes them seekable in structural order). It REGRESSED SCC 1.62x->2.16x: SCC's
changed set is broad (label propagation touches most keys each retire), so the
scan already touches ~every row while the per-chunk gallop only adds overhead.
Reverted to the scan; simplified the signature to `changed: &[u64]` and recorded
the negative result in the doc so it isn't re-tried. Gate 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ash_rows

The `ids()` compound fallback now hashes via the canonical native `corgi::hash`
(the designed boundary-id fold — width-blind, consistent-with-equality) instead
of the branch-local `arrange::hash_rows` (width-seeded). Safe for DDIR: every leaf
transcodes to u64 so width-blindness is a no-op, and there is no cross-path hash
comparison (value-as-id for primitives and native hash for compounds are never
used for the same value — shape is uniform per column). DDIR no longer references
hash_rows, so the corgi side can retire it freely. Gate 6/6 (incl. compound-keyed
adt/binders/unnest), 29 lib tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The survey merge (17ff06d) was a single-chunk MIRAGE. survey aligns only
(key,val) (corgi owns no time), so its positional Both under-consolidates
cross-side times; combined with consuming both chunks and no suffix push-back, it
leaves canceling rows in the arrangement. Single-chunk (n<=100k) it looked like a
win (SCC 1.94->1.62x, compare_idx 21.9->8.9%), but past the chunk boundary
(edges e=2n cross TARGET=262144 at n~131k) the un-consolidated arrangement BLOATS
every iteration and the reduce re-presents ever-growing input -> super-linear
(n=150k: 104.9s / 19.5x vec vs 9.9s / 2.08x with two-pointer). Profile smoking
gun: retire 96%, consolidate_updates 24.8% + quicksort 22.7% + collect_present
17% — the REDUCE drowning in bloated input, not the merge itself.

Restored the two-pointer merge (full (key,val,time) consolidation + survivor
push-back). Kept group_bounds in advance (perf-neutral but a clean one-pass scan;
doesn't touch consolidation). Back to ~1.96x vec, LINEAR to n=250k. A survey merge
needs a group-RANGE Both to compose with out-of-band times — flagged to corgi.

Gate 6/6. corgi pin stays f556868 (group_bounds + native corgi::hash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…c_big

Adds scc::strongly_connected_at(graph, logic) — the prioritized variant the
code comment always suggested — routing trim_edges through propagate_at so
label introduction is log-bucketed 256*(64-lz(logic(label))), byte-identical
to DDIR's enter_at($1[0]) delay in vec.rs/corgi.rs. strongly_connected is
unchanged (= strongly_connected_at(_, |_| 0)).

Oracle-tested: tests/scc.rs gains scc_at_{10_20_1000,100_200_10,100_2000_1}
running the prioritized variant against the sequential SCC oracle (incl. 1000
incremental rounds); all 6 pass.

corgi_scc_big gains the fair(enter_at) column. Measured (arm64, e=2n):
  n=100k: native 4.16s, fair 0.62s (0.15x nat), vec 3.06s (4.95x fair), corgi 5.94s (9.61x fair, 1.94x vec)
  n=150k: native 7.42s, fair 0.95s (0.13x nat), vec 4.83s (5.08x fair), corgi 9.90s (10.41x fair, 2.05x vec)
The compiled target was ~6.7x closer than the plain-native column suggested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
E-graph-flavored identifier-lookup workload: parent forest (chains of CHAIN,
roots self-looped) resolved to roots by an iterative min-join fixpoint
(pointer doubling), then a term table (id,a,b) resolved by two integer joins
against reps. Closed-form oracle (root = x - x%CHAIN) checked against vec,
corgi asserted == vec, hand-written native twin oracle-checked too.

Measured (arm64, chains=64, terms=2n):
  n=100k: native 0.59s, vec 2.34s (4.00x nat), corgi 3.49s (5.95x nat, 1.49x vec)
  n=1m:   native 6.55s, vec 26.99s (4.12x nat), corgi 46.28s (7.07x nat, 1.71x vec)
corgi-vs-vec gap GROWS with n (1.31x @1k -> 1.71x @1m) — profile candidate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same control structure as B1 but every node is a nested compound value
(x -> (x>>10, x&1023), order-preserving): no value-as-id fast path — compound
hashing, nested compares, and nested-column gathers carry the load.
enter_at($1[0][0]) buckets by group; the fair native twin is
strongly_connected_at over (i64,i64) with logic |n| n.0 (identical delay).

Correctness: corgi == vec on the full SCC edge set; compound output mapped
back through the encoding == the plain-encoding program on the same graph.

Measured (arm64, e=2n): compound keys hurt everyone, corgi worst:
  n=100k: fair 2.76s, vec 21.56s (7.80x), corgi 61.42s (22.22x fair, 2.85x vec)
  n=150k: fair 5.22s, vec 41.48s (7.95x), corgi 132.77s (25.43x fair, 3.20x vec)
(B1 plain-int corgi is 1.94-2.05x vec — the compound-key path is corgi's
weakest spot so far, and the ratio grows with n.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per row (a,b < 2^15): 8-element arithmetic list, flatmap explosion, Fwd/Bwd
variant + case + 4-element fold per element, min per 16 buckets (min so the
compute can't be dead-coded; reduce stays tiny). Closed-form Rust oracle
checked against vec, corgi == vec, native twin oracle-checked.

Values constrained non-negative + non-overflowing: the first draft (values to
1e6, a-b negative, e*e overflowing) exposed corgi min DIVERGING from vec on
negative ints — corgi's structural order is unsigned at the integer leaf.
Real abstraction gap, logged in the read-out; out of contract for B4.

Measured (arm64): corgi is NOT columnar on this path — flatmap/case/fold run
the row-wise fallback (ir::eval + transcode), so the expected compute win is
absent:
  n=1m: native 1.09s, vec 8.35s (7.69x nat), corgi 8.89s (8.19x nat, 1.07x vec)
  n=4m: native 4.55s, vec 34.61s (7.60x nat), corgi 37.19s (8.17x nat, 1.07x vec)
Columnarizing flatmap/case/fold is a top lever candidate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loops one backend of scc-compound at a fixed size for samply, built identically
for apples-to-apples profiles.

Iteration-1 profile findings (n=50k, corgi 28.0s vs vec 11.4s):
- retire subtree = 77% of corgi; vec's Value::cmp/partial_cmp/eq = 26% self.
- consolidate_updates_slice_slow 17.6% total. Two tactic-side fixes (permutation
  sort; interned-time integer consolidation + stable run-merge) were both
  measured FLAT and reverted: the hot consolidation is NOT the tactic's delta
  emit — it is next_window's presentation build in corgi_reduce_backend
  (collect_present clones an owned PointStamp per presented row, then
  consolidate_updates comparison-sorts the AoS records; the caller frame was
  inlined away). That backend site is the next lever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B4 corgi profile (n=1m): ir::eval 19% (row-wise List/Case fallback), Value
churn ~15%, arrangement merge of the 8m exploded rows ~29%, reduce 14%.
Both backends run B4 row-wise — the columnar win is unrealized here.
Iteration-4 lever scoped in the read-out: columnarize List/FlatMap/Case in
the backend via existing corgi kernels (Iota/Gather/Flatten/Fold/Inject/
MapSum + gather_lanes interleave).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B3 corgi profile at n=1m surfaces two concentrated corgi-kernel buckets the
SCC profiles never showed: sort_leaf_blocks 17% self (discrimination-sort
leaf pass, under ingress sort_consolidate + materialize from_columns) and
the join's find_ranges probe 17.8% total (interpreted CmpOp::eval compare
per binary-search step). Both kernel-general levers; details in the read-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 timing runs at n=100k (fixed graph): corgi 3.76-3.89s, vec 2.97-3.03s,
ratio 1.26-1.30x; n=250k: ratio 1.28-1.29x. 3 graph instances (SEED=1,
12345, 999983): ratio 1.24-1.29x, vec/fair 4.78-4.94x. Both axes ~ +/-2%;
the reported ratios are solid to about two figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New native_scc_prof (fair enter_at compiled twin). Triptych at n=100k, self-time
by module: allocator native 2.2% / vec 28.4% / corgi 27.2%; memmove native 0.7%
/ vec 2.8% / corgi 11.9%; timely progress tracking ~0.5% in BOTH backends. The
4.8x shared tax is BUFFER LIFECYCLE (allocation + copy churn), not progress
tracking (kills lever 1b as a priority), not operator count (rung B), not edge
time repr (rung A). vec's churn is per-row ir::Value boxes (cmp+clone+eq ~20%
self); corgi's is column rebuilds (fresh Arc<Vec> per gather/merge/present).

The suite ran on the SYSTEM allocator (only ddir_server/ddir_vec linked
mimalloc). Adding mimalloc to all suite + prof binaries (one binary per
benchmark: every column shares it; native columns move <5%, DDIR 12-36%):
  B1 250k: corgi 8.67 -> 7.07s (0.96x vec, 4.29x fair; was 5.2x fair)
  B2 150k: corgi 53.45 -> 42.94s (1.22x vec)   100k: 1.08x vec
  B3 1m:   corgi 27.24 -> 19.79s (0.82x vec, 3.17x fair)
  B4 4m:   corgi 20.09 -> 13.00s (0.45x vec, 2.96x fair)
Gate 6/6 incl. fusion oracle; [checked] rows green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin moves from f556868 to the dd-arrange-api tip, which carries the four
landed kernel additions (wip#7). Gate green; workspace passes -D warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	differential-dataflow/src/operators/int_proxy/history.rs
#	differential-dataflow/src/operators/int_proxy/join.rs
#	differential-dataflow/src/operators/int_proxy/reduce.rs
#	differential-dataflow/src/operators/mod.rs
Ten purpose-built binaries (four benchmarks + six profiling twins) collapse
into one runner: interpreted programs live in examples/programs/*.ddp where
they belong; the irreducibly compiled parts — input generators and the native
twins, including the enter_at-fair SCC — register in the runner by bench name.
BACKEND=/ITERS= select profiling mode (loop one column, a samply target),
replacing the prof twins. Output formats, env protocols (N/SEED/CHECK/CHAIN),
and all correctness assertions are unchanged; all four benches verified
[checked] plus a prof-mode smoke; gate green; workspace passes -D warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eleven milestone/experiment binaries (rungs 0-4, M1-M4, Q3 of the backend
build-up) whose questions are answered and whose coverage the survivors carry:
the gate (corgi_progs) runs reach/scc and richer programs continuously, the
scorecard owns per-operator triage, and ddir_bench owns end-to-end measurement
and profiling. SPIKE.md narrates the arc they documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .ddp extraction overwrote the pre-existing gate program with the
benchmark's embedded SCC (different export shape, comments lost). The gate
kept passing because the benchmark program is also a valid corgi==vec check —
which is how it slipped through. scc.ddp is restored byte-identical;
ddir_bench loads scc_bench.ddp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five crate-root corgi_*.rs files become src/corgi/{container,chunk,join,
reduce,logic}.rs. No code change beyond the module paths: container is the
CorgiContainer on dataflow edges (was corgi_backend.rs), chunk the columnar
Chunk + arrangement plumbing, logic the DDIR-term compiler, join/reduce the
tactic bindings. backend/corgi.rs (the Backend impl, parallel to backend/vec.rs)
and col_times.rs (a generic columnar-time utility that imports no corgi) stay
where they are. Gate 6/6 both tactics; workspace passes -D warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses review of the subsystem PR:
- corgi_progs (the corgi==vec gate) moves from examples/ to a tests/ integration
  test (tests/corgi_backend.rs) — it is a gate, so its home is cargo test / CI,
  not examples/. Runs the six canonical .ddp programs, one #[test] each.
- Remove the benchmark/measurement tooling that isn't a DDIR example: ddir_bench,
  corgi_scorecard, and the five benchmark-only programs (ast/ident/scc_bench/
  scc_compound/scc_plain .ddp — the latter three the duplicate-SCC sprawl). These
  are dev tooling, not part of the library; they stay on the dev branch and can
  land separately if the later perf PRs want reproducible in-tree numbers.
- Drop the working notes (SPIKE.md + two findings files, ~970 lines of markdown).

examples/ is back to DDIR programs plus the pre-existing server/dump binaries.
strongly_connected_at stays (a general algorithms/ addition, tested by tests/scc.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
col_times exists to serve CorgiChunk (its own doc says so) and is used only by
the corgi backend's modules — it belongs in src/corgi/, not at the crate root.
No code change beyond the module path (crate::col_times -> crate::corgi::col_times).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the local-dev path note and the reference to a findings file that no longer
exists; keep the git rev pin (explicit and reproducible) with a one-line purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header described an early milestone: Spine<Rc<CorgiBatch>> (the arrangement
is a ChunkSpine<CorgiChunk> now), reduce 'awaits' a retire design (it is
implemented via the tactics), and a 'this iteration ... = todo!()' note (nothing
is todo). Rewrite it to describe the current, complete substrate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CC/Row/Upd/CTrace were defined ~110 lines below apply_ops, whose signature reads
in terms of CC. Move them to just after the imports so the shorthands are in
scope where the reader first meets them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explain the empty enum: it is never a value, only a type carrying the Backend
impl (selected via render_tree::<CorgiBackend>); the empty enum makes it
unconstructable. Mirrors VecBackend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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