libdpf/doc/pages/compose.md
Ryan Henry 0d22946a0e Checkpoint the party/runtime stack before share-program and malicious-mode work.
Ship the TLS mesh, composer, Beaver/Yao/leaf MPC, prep/online paths, apps, and docs so the tree is pushable before elevating share_expr, security_mode, and prep resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 05:59:19 -06:00

11 KiB
Raw Permalink Blame History

Protocol composition

\htmlonly

ELI5. Record every expand, multiply, and open as a node with a share domain. Identical work is interned. Independent opens share one RoundSink round; a dependency chain becomes successive waves. The schedule is what a hand-tuned Express or Duoram party would have written by hand.
\endhtmlonly

dpf::protocol::composer ([compose.hpp](@ref dpf/compose.hpp)) records multi-protocol strands on one sink. Values are tagged with a share domain matching [secret_share.hpp](@ref dpf/secret_share.hpp):

Domain Meaning
fss FSS / DPF leaf share
a (2,2) additive
b (2,2) subtractive
rss (2,3) replicated
y (3,3) additive (RSS product factor)

Party-count changes are never implied: use reshare (or rss_from_y for y→rss). Local casts use as.

Building a schedule

dpf::protocol::composer c(/*party=*/0);
auto seed = c.input(dpf::protocol::domain::fss, 16);
auto leaf = c.fss_point(seed, /*depth=*/8, /*slot_bytes=*/16);
auto p = c.default_plan();  // == schedule(); RoundSink uses this
// p.rounds() == p.exchange_waves() == 8  (no empty compute-only sink rounds)

Drive with [drive](@ref dpf::protocol::drive) or party util::drive_composed / util::drive_composed_trio, optionally with drive_options (from_exchange_wave, compact_sink, beavers). Express/Sabre audits use util::schedule_fused_audit (or fss_point_fused).

The same plan lowers onto the RoundSink batch loop with plan_to_schedule → [schedule_session](@ref dpf::protocol::schedule_session) → finish_schedule, or the one-shot drive_via_schedule. Each exchange wave becomes a [schedule_round](@ref dpf::protocol::schedule_round) whose produce runs on this thread when that instance's peer slot is ready (the schedule_session::drive scan). Rounds carry an [edge_id](@ref dpf/net/edge_mesh.hpp) on an [edge_mesh](@ref dpf/net/edge_mesh.hpp) (star / clique / dealer), a [receive_rule](@ref dpf::protocol::receive_rule) (domain_open, copy_peer, field_sum, any_two, verify_*, eq_check), optional branch (skip send) and next (jump after an open). [session_host](@ref dpf/session_host.hpp) queues micro-plans on a durable mesh. Pad graphs live in [pad_graphs.hpp](@ref dpf/pad_graphs.hpp) (du_atallah_mul_graph, star_upload_answer_graph); IKNP setup in [iknp_graphs.hpp](@ref dpf/iknp_graphs.hpp); PIRsona / hushmap builders in [mesh_apps.hpp](@ref dpf/mesh_apps.hpp). Named application plans and drive_star / submit_and_drive live in [app_plans.hpp](@ref dpf/app_plans.hpp).

Replayable seeds and paper cost CSVs: see [Experiments](@ref experiment_costs). In short, dpf::experiment / app::measure_plan / app::run_measured attach a per-thread master seed (default random, or replay) and emit CSV breakdowns.

plan::rounds() equals exchange_waves() and slot_bytes_all().size() — the sink and the scheduler share one count. Trailing compute-only DAG waves still appear in waves() / wave(i) but do not allocate RoundSink rounds.

Composer-owned ABY sessions default to beavers::schedule_objective::rounds so online sign×polynomial stays one round. Dealer benches that want Appendix-E peels keep prep. Independent circuits use aby_lane(i). Live δ openings use drive_options::beavers (util::u64_beaver_host wraps party_batch_stepper).

Hand-schedule shapes the API covers

Shape Call What it saves
L‖R PRG stretch expand_pair / default level_walk Half the AES vs separate child expands
Shared-seed fan (Grotto LUT) fan of fss_cmp on one seed One expand per level, not N×depth
Several piecewise LUTs schedule_lut_union One fss_cmp; the union's prefix walk is local
Express / Sabre audit fss_point_fused / schedule_fused_audit Sketch in last CW — no +1 exchange wave
BGI Remark 3.4 early-stop fss_point_early_stop Drop ν interactive CW rounds
Poplar / idpf prefix checkpoints level_walk_prefixes Prefix share after each CW, no extra rounds
Adaptive idpf_agg step → drive tail → retain → step… One packed L‖R open per depth; child bit never on the wire
DCF block_width level_walk_sized Per-level CW slot bytes
Doerner–Shelat keygen level_walk_ds / level_walk_ds_sized blind‖CW‖advice‖AND + OH AND-layers
Keyword PIR / PSI buckets multipoint_fan / exchange_pack Bucket CWs pack; answers one open
RSS mul + neighbor refresh rss_product_replicated / rss_from_y One y-exchange, not a reconstructing open
FSS leaf → ABY scale aby_product after fss_point Leaf stays on the beaver barrier critical path
Multi-lane ABY aby_lane(i) / aby_product(..., lane) Independent barriers, shared wave
Round-aware Beaver composer::aby<Ring>() schedule_objective::rounds
Prepaid expand / rotate defer_expand / rotate_share Zero online FSS rounds (SUBLEQ offline)
Duoram leaf_later leaf_later_walk / apply_leaf_correction Path CWs only; leaf apply is local
RSS column, hidden order shuffle_hidden Three shuffle_send waves. aux is the left-out party: 2, then 0, then 1

Adaptive prefixes: step_adaptive_prefix schedules only the next packed open; drive with from_exchange_wave = exchanges_flushed and a sink sized by plan::slot_bytes_from (compact_sink = true); then retain_adaptive_prefix adds one local tip. Never unroll a full-depth adaptive walk into one static schedule.

3PC RSS: rss_from_y schedules a domain::y exchange (neighbor receive). util::drive_composed_trio splits peer maps per exchange — y on the neighbor ring, dealer_deliver from p2, everything else on p0↔p1. Cross-party reshare refuses a reconstructing open; use reshare_with_mask. Opens use domain algebra (a/fss sum, b subtractive, y copy, optional field_open::fp61). Authenticated Beaver sessions size barriers as auth_opening and drive through u64_auth_beaver_host. exchange_fuse packs extra payloads into one open. schedule_cuckoo_probes turns occupied bucket ids into multipoint_fan.

Client and servers

Keyword PIR and both three-server PIRs are a star, not a correction-word walk. client_servers(servers, query_bytes, answer_bytes) records two waves and nothing between the servers:

  1. Upload. The slot is servers * query_bytes (every key in one round).
  2. Answer. The slot is servers * answer_bytes. It depends on the upload, so it cannot share that wave.

drive copies the peer's message into the exchange node. It does not add the shares. A two-party sink stands in for one client and one server; the slot width is still the full parallel payload, which is what dpf::app::exercise reports as bytes.

auto q = c.client_servers(/*servers=*/2, /*query_bytes=*/256, /*answer_bytes=*/4);
auto p = c.default_plan();
// p.rounds() == 2
// p.slot_bytes(0) == 512, p.slot_bytes(1) == 8

Parking and the fleet

drive used to spin until the peer flushed, then throw. A step that simply takes a long time looks like that failure, and a pool that always resumes the side already waiting on a receive never runs the peer who could unblock it.

drive_options::park_if_waiting returns instead. drive_cursor remembers the wave, the exchange index, and whether the submit already happened. The next drive with that cursor receives if the peer has caught up, or parks again. one_exchange stops after a single completed round so a scheduler can interleave other instances.

dpf::app::run_fleet(composer, instances, chaos_seed) is that scheduler. Each instance is a pair of parties on an in-process sink. Workers prefer a side that still has a submit to do over a side parked on a receive, and among those they prefer the one further behind. Delays are per side and per step. They do not line up, which is the case that makes round-robin and "always resume the waiter" stall.

dpf::app::run(name, composer, expect_rounds) is the one-shot experiment used by examples/applications/. It drives both parties and prints

name rounds=R bytes=B

bytes is the sum of one lane's exchange slots.

Opcodes from beaver_delta (300) through beaver_delta + 1023 are δ barriers. user_base (1000) sits inside that window, so a hand-rolled kernel opcode in that range is skipped as a beaver. Use 5000 and up for kernels drive must run or reject.

What eval_full reuses

eval_full(key, buffer) keeps a thread-local workspace for that key type. The same key evaluated again does not rebuild the interior. A different key still does. Leaves of one block are stretched eight at a time (eval_x8). Pass your own memoizer when two evaluations on one thread must not share that cache.

DS OH: level_walk_ds(..., oh=true) emits net::ds_oh_exchanges_per_level (80) AND-layer opens per level. Size sinks with net::compose_ds_slot_bytes (schedule-exact) or the conservative net::ds_walk_slot_bytes for hand dist_ds paths.

\include{cpp} protocol/compose_schedule.cpp

Gap analysis

Item Status
Point-key stretch Builtin fss_expand_pair runs prg::aes128; fss_step+1 XORs the opened correction onto the children
Setup plus walk iknp_setup_graph / plan_with_pad_setup — pad frames are schedule_rounds
Edge mesh edge_mesh + make_memory_star / make_memory_clique; drive_via_schedule(plan, mesh, …)
Receive rules field_sum, any_two, verify_sketch / verify_proof, eq_check in apply_peer_slot
Jump / host schedule_round::next + session_host for SUBLEQ / idpf_agg / hushmap ops
PIRsona / hushmap pirsona_bitmore_fetch, pirsona_gd_update_graph, hushmap_add_schedule
2PC → RSS reshare casts to a y share (p2 holds 0) and rss_from_y. reshare_fresh adds a dealer-sampled zero mask
Auth openings auth_beaver_host<Ring> / u64_auth_beaver_host. party_auth_batch_stepper::apply_peer rejects a bad tag
One DS sink ds_walk_slot_bytes is the envelope of the hand walk and compose_ds_slot_bytes (5 peer opens per level + OH + mux)
Wide opens Every multiple of 8 bytes is a lane add/sub (field_open::fp61 for the Mersenne field)
Fuse offset exchange_fuse stores the segment offset in aux (uint32_t)

Still outside this scheduler: the cuckoo PRP, OPRF evaluation, the full IKNP wire body inside each pad produce (frames and count are in the schedule; iknp::sample still owns the crypto), and key-specific control-bit / verifiable / Doerner–Shelat advice logic. Those last ones override the builtin kernels. Trio multi-edge drive_via_schedule still needs edge_sinks wired from the party helper.

Closed earlier

Incremental drive, domain-correct a/b/y opens, split y/2PC peer maps, OH AND-layers, level_walk_ds_sized, default_plan, staged adaptive retain, multipoint fan, multi-lane ABY, defer_expand / leaf_later_walk, Express trailer fuse.

Go deeper: [compose.hpp](@ref dpf/compose.hpp), [beaver.hpp](@ref dpf/beaver.hpp), [sink_exchange.hpp](@ref dpf/net/sink_exchange.hpp), [Application sketches](@ref applications), [guided tour](@ref tour_party).