Rust port: Step 1.2b + 1.3 milestone — 81/81 memory gate green#1
Open
trianglegrrl wants to merge 28 commits intomainfrom
Open
Rust port: Step 1.2b + 1.3 milestone — 81/81 memory gate green#1trianglegrrl wants to merge 28 commits intomainfrom
trianglegrrl wants to merge 28 commits intomainfrom
Conversation
Adds the planning artifacts behind a parity-verified Rust port of langgraph: - .omc/plans/langgraph-rust-port-2026-04-30.md — full plan, structured around a Phase 0 spike that proves wire compat is achievable before any larger commitment. Every step states explicitly how we confirm the Rust output is exactly the same as the Python baseline (byte equality, schema equality, JSON-canonical equality, sequence equality, conformance pass, test-suite pass, or proptest differential property). - research/ — Perplexity outputs covering the Rust agent ecosystem, async patterns, DB libraries, HTTP/SSE stack, differential testing, eax crate health, and a survey of existing 'Rust LangGraph' attempts. - .gitignore — keep .omc/plans/ tracked, ignore harness state and Rust target/. Project shape locked: internal, agent-driven, pinned to Python baseline commit 63d8611 (tagged rust-port-baseline-2026-04-30). No public release in V0.1; new Python features after the baseline go on a separate backlog rather than chasing main.
Lays the foundation for Phase 0 of the Rust port and lands the first parity gate: data-model JSON round-trip equality between Rust and Python. What's here: - rust/ — Cargo workspace at edition 2024, MSRV 1.95.0, pinned via rust-toolchain.toml. Cargo.lock committed for reproducible parity builds. First crate: langgraph-checkpoint, with the data-model types (Checkpoint, CheckpointMetadata, CheckpointTuple, RunnableConfig, PendingWrite, Version) shaped to round-trip 1:1 with Python langgraph. - parity/scripts/dump_data_model_fixtures.py — Python fixture generator that imports real langgraph and calls empty_checkpoint() and create_checkpoint() so the corpus reflects what actually hits the wire, not a hand-read of the TypedDicts. Time and UUID are pinned for determinism. - parity/fixtures/data_model.json — 11 fixtures generated from the baseline (commit 63d8611, tagged rust-port-baseline-2026-04-30). - rust/crates/langgraph-checkpoint/tests/data_model_parity.rs — the Step 0.1 gate. Loads the corpus, deserializes each fixture into the Rust type, re-serializes, and asserts JSON-canonical equality. 11/11 pass at HEAD. What the parity gate caught on the first run (i.e. why this approach earns its keep): - Checkpoint.v is currently 2 (LATEST_VERSION), not 1 as the source comment hinted. - Checkpoint.pending_sends is emitted at runtime by empty_checkpoint() and create_checkpoint() but is not declared in the TypedDict annotations. Static type analysis would have missed it. - Checkpoint.updated_channels is always emitted, including as null. skip_serializing_if = "Option::is_none" was wrong for that field. Each of the above would have shipped silently as a wire incompatibility without the parity check. Locked them in as Rust struct comments and saved a project memory so future contributors don't relearn. Phase 0 Step 0.1: green.
Builds the canonical msgpack-bytes corpus that Step 0.3 will assert byte
equality against. Generated by calling the real Python langgraph
JsonPlusSerializer at the frozen baseline — the corpus *is* the oracle, and
the only parity check at this step is determinism.
Coverage: 74 fixtures total
- 21 primitives (covers plain msgpack with no ext code: int / float / bool /
None / str / bytes / list / dict / nested combinations).
- 45 of 49 SAFE_MSGPACK_TYPES allowlist entries serialized via real factories.
- 8 pathological cases: empty checkpoint, mixed channel values, 32-deep
nesting, Unicode-heavy strings, NaN, +Inf, -Inf, DELTA_SENTINEL,
DeltaSnapshot.
The 4 uncovered allowlist entries are platform-fundamental, not generator
bugs:
- pathlib.WindowsPath (only constructible on Windows)
- pathlib._local.{Path,PosixPath,WindowsPath} (Python 3.13+ only)
Closing the gap requires Windows + Python-3.13 CI runners. Logged as a
follow-up in rust/docs/phase-0-progress.md.
Determinism gate: build_corpus() is called twice in-process and bytes_b64
sequences are asserted equal. Passes.
Run from libs/langgraph (not libs/checkpoint — some allowlist entries live in
langgraph.types and langgraph.store.base, which only the main langgraph
package transitively pulls).
Adds the codec layer (rmpv-backed) and proves that bytes go through Rust unchanged: 71/71 msgpack fixtures from the Step 0.2 corpus encode-decode- re-encode byte-identical to the Python ormsgpack output. This retires the highest-risk item in the plan. Bytes-level parity at 100% means rmpv canonicalizes msgpack the same way ormsgpack does — smallest unsigned representation for non-negative ints, no float32 narrowing, fixstr/str8/str16/str32 boundaries, ext-code payload preservation. The "msgpack envelope drift" risk from §12 (High likelihood, High impact) lands safe. What's new: - rmpv, rmp, base64 added as workspace dependencies. - crates/langgraph-checkpoint/src/codec/mod.rs + msgpack.rs — encode/decode pair plus 4 inline smoke tests for the canonicalization corner cases (positive fixint, bool, float64-zero, i64::MAX as uint64). - tests/msgpack_round_trip.rs — the Step 0.3a parity gate. Reads parity/fixtures/msgpack_corpus.json, byte-equality round-trips every type=msgpack fixture, prints coverage stats. type=null / type=bytes fixtures (3 of 74) are skipped — those are signaled out-of-band on the Python side and handled at the higher serializer layer in 0.3b. Module is named `codec` rather than `serde` to avoid shadowing the popular `serde` crate when brought into scope. Step 0.3b (semantic decoding of ext codes 0–3 into a typed enum + a no-LangChain registry) lands next.
Adds a typed `Decoded` enum on top of the rmpv bytes layer. Ext codes 0–3
surface as structured `(module, class, args[, method])` variants
(`Ctor1`, `CtorN`, `CtorKw`, `Method`); codes 4–8 pass through as
`RawExt { code, payload }` until Step 0.4 gives them their own variants.
Parity gate: same 71-fixture corpus round-trip but routed through
`decoded::decode` + `decoded::encode`. 71/71 byte-identical, 0
divergences. Plus a `semantic_decoder_recognises_ext_codes` spot-check
that confirms representative fixtures decode to the right variant
(uuid → Ctor1, datetime → Method, HumanMessage → RawExt(5),
DELTA_SENTINEL → RawExt(8)).
What it caught: the earlier survey claimed LangChain messages serialize
via ext code 2 (kwargs constructor). Empirically they're Pydantic v2
models and use ext code 5 instead. Histogram across the corpus:
code 0 (Ctor1): 12 sets, paths, IPs, uuid, decimal, langgraph types
code 1 (CtorN): 7 datetime.date / timedelta / timezone
code 2 (CtorKw): 8 datetime.time, langgraph.types.Command, etc.
code 3 (Method): 1 datetime.datetime
code 4 (Pydantic1): 1 langgraph.store.base.Item
code 5 (Pydantic2): 16 langchain_core.messages.* + Document
code 8 (sentinel): 1 DELTA_SENTINEL
plain (no ext): 25 primitives
Saved to project memory. Implication for Step 0.4: prioritise code 5
first — biggest category, unlocks LangChain message parity.
Step 0.3c (native registry mapping (module, class) → Rust constructor +
LANGGRAPH_STRICT_MSGPACK enforcement) is deferred — byte-equal parity is
proven, the registry is ergonomics rather than correctness.
…mode
Bundles the /james review pass (across decoded.rs and data_model.rs)
with the un-deferred Step 0.3c work (allowlist + strict mode + native
registry). One commit because the review fixes touch the same files
the new code lands in.
Test count: 64 (39 unit + 25 integration), all green. clippy clean
under -D warnings.
=== /james fixes — decoded.rs ===
- Replace `arr.remove(0)` (O(n²)) with iterator-based draining throughout
decode_ext. The same pattern would have copy-pasted into the Step 0.4
pydantic decoders; fix the shape now.
- Real DecodedError variants instead of fake-wrapping in
rmpv::decode::Error::InvalidDataRead(io::Error::other(...)):
IntegerOutOfRange, NonUtf8String, Float32Unsupported, DisallowedType.
Pattern-matchers see the actual cause rather than a misleading rmpv
error.
- Single ctor_arity(code) helper instead of two duplicated `match` arms.
- pop_str(it, code, pos) used by all three string-popping callsites
(was a half-abstracted helper used by two of three).
- F32 input is a hard error, not a silent widen-to-F64. ormsgpack always
emits F64 — if we see F32 something's wrong and we want to know.
- NonStringMapKey carries position, so a 50-kwarg map with one bad key
reports which kwarg failed.
- TODO notes added for `encode_ext` allocation pattern and Cow-string
optimization on Decoded variants — both deferrable to Step 0.4 work.
=== /james fixes — data_model.rs ===
- Version::Int widened from i64 to i128. Python int is unbounded and the
canonical Postgres saver emits version tags above i64::MAX (compound
timestamp × counter). Hand-written Serialize/Deserialize because
serde-untagged dispatch over serde_json never reaches visit_i128 (it
buffers via Content::U64/I64), so an untagged Int(i128) variant is
unreachable.
- RunnableConfig gains typed accessors (thread_id, checkpoint_ns,
checkpoint_id) + builders (with_thread_id, ...) + recursion_limit_or_default
+ DEFAULT_RECURSION_LIMIT const. Magic-string lookups now live in one
place.
- PendingWrite struct (with explicit task_id/channel/value fields) and
custom serde to a 3-tuple wire shape. Tuple-based access at every
callsite was a primitive-obsession smell.
- Doc comment on CheckpointTuple.parent_config calling out the
`Some(default)` invariant.
- Test for non-empty pending_sends round-trip + idempotence.
- Test for typed RunnableConfig accessors and DEFAULT_RECURSION_LIMIT.
=== Test infra cleanup ===
- Extract tests/common/mod.rs with repo_root(), fixture(name), hex(b)
helpers. Three integration-test files no longer reimplement the same
path resolution and hex formatter.
=== Step 0.3c — un-deferred ===
The earlier defer claim ("byte-equal parity is proven, registry is
ergonomics") was wrong. Without the registry, Rust callers have to
hand-build Decoded trees to produce a serialized langgraph blob — not a
real API. Without strict mode, an attacker-controlled checkpoint can
request arbitrary (module, class) constructor lookups. Both land here.
Three pieces:
1. Allowlist (codec/allowlist.rs). Static phf::Set of 49 (module, class)
pairs encoded as `"module\x1Fclass"` strings (phf can't key on
tuples). is_allowed(module, class) does the lookup.
2. Strict mode (codec/decoded.rs). decode_strict() validates every
constructor envelope against the allowlist; decode_with_env() honours
LANGGRAPH_STRICT_MSGPACK to match the Python flag. Walks nested
arrays/maps so a disallowed type tucked inside another structure
still trips.
3. Native registry (codec/native.rs). NativeFromDecoded /
NativeIntoDecoded traits with full impls for the no-LangChain subset:
chrono::DateTime<FixedOffset>, NaiveDate, TimeDelta, FixedOffset,
NaiveTime, NamedFixedOffset, Uuid, Decimal, Ipv4Addr, Ipv6Addr,
IpInterface (4 variants), PythonPath, ZoneInfo, RePattern, PySet,
PyFrozenSet, VecDeque<Decoded>. decode_value::<T>(bytes) and
encode_value(t) give a clean Rust UX.
Parity gates:
- Allowlist parity (parity/scripts/dump_allowlist.py +
tests/allowlist_parity.rs) asserts the Rust phf set equals the Python
SAFE_MSGPACK_TYPES frozenset exactly — same 49 pairs, same single
method (datetime.datetime.fromisoformat). Drift fails the build.
- Native parity (tests/native_parity.rs) round-trips every
allowlist/<module>.<class> fixture in the corpus through the typed
Rust value and back to bytes. 19/19 byte-identical to Python output.
What it caught: pathlib.Path/PosixPath carries path segments as direct
positional args of the ext-1 envelope, not wrapped in a single inner
Array — first run of native parity surfaced this.
New deps: phf 0.11 (allowlist), rust_decimal 1 (Decimal native type).
Promotes the remaining langgraph ext codes out of `RawExt` into typed variants. The corpus byte-equality round-trip still holds at 100% — codes that used to land in `RawExt` now land in structured variants, but the encoder reproduces the same bytes. | Variant | Ext code | Inner shape | |-----------------|---------:|-------------| | `PydanticV1` | 4 | `(module, class, fields_dict)` | | `PydanticV2` | 5 | `(module, class, fields_dict, "model_validate_json")` | | `RawExt` | 6 | numpy ndarray — opaque pass-through (not in corpus) | | `DeltaSnapshot` | 7 | payload bytes ARE the wrapped value | | `DeltaSentinel` | 8 | empty payload, marker only | Code 7 is unusual: its payload is the wrapped value's msgpack *directly*, with no `(module, class, ...)` envelope around it. Encoder special-cases. Numpy (code 6) stays opaque — not in the corpus, no caller currently consumes numpy buffers from Rust. Promote when there's demand. Parity: - Same 72-fixture corpus round-trips byte-identical through decoded:: paths after the refactor. - Spot-check assertions in decoded_parity.rs upgraded to expect the new variants: HumanMessage → PydanticV2, langgraph.store.base.Item → PydanticV1, DELTA_SENTINEL → DeltaSentinel, delta_snapshot → DeltaSnapshot. - 3 new inline tests for the hand-crafted shapes (delta_sentinel, delta_snapshot, pydantic_v2). Corpus generator fix: dump_msgpack_corpus.py was importing _DeltaSnapshot from langgraph.checkpoint.base; the actual location is langgraph.checkpoint.serde.types. Corpus now carries 75 fixtures including a real DeltaSnapshot. 67 tests, 0 failures. clippy clean under -D warnings.
Stands up the maturin-built Python extension that Step 0.6's conformance work will layer on top of. Layout: rust/ffi/langgraph-py/ — separate workspace member with crate-type = ["cdylib"]. Built via `maturin develop --uv` from inside a venv at rust/ffi/langgraph-py/.venv/ (gitignored). Surface (deliberately narrow for Step 0.5): roundtrip(data: bytes) -> bytes decode → encode via codec roundtrip_strict(data: bytes) -> bytes same, with allowlist enforced is_allowed(module, class) -> bool allowlist lookup allowlist_pairs() -> list[(str, str)] full set, sorted allowlist_size() -> int size only __version__ crate version All errors raise ValueError Python-side. Parity gate (parity/scripts/test_bridge_round_trip.py): ✓ corpus round-trip: 72/72 byte-identical Python → Rust → Python ✓ allowlist parity via bridge: 49/49 entries match Python ✓ strict mode rejects disallowed envelope Notes: - PyO3 0.28 (latest stable, supports Python 3.14). 0.22 was the plan's starting point but doesn't compile against current Python. - macOS extension-module linking requires maturin (cargo build alone fails with `ld: symbol(s) not found`). Run incantations are documented in rust/docs/phase-0-progress.md. - Bridge uv.lock committed for reproducible builds. Step 0.6 widens this surface to what langgraph.checkpoint.conformance.validate() needs.
…hase 0 complete) Phase 0 exit gate. The plan's central question — "can we produce byte-identical msgpack output for the type universe Python langgraph uses?" — is answered yes, end-to-end, against the actual conformance suite. Mechanism: parity/scripts/test_conformance_via_rust.py constructs an InMemorySaver, replaces its .serde with a RustShadowedSerde, and runs langgraph.checkpoint.conformance.validate(saver). The shadow intercepts every dumps_typed/loads_typed call; for any type=msgpack blob, it round-trips through langgraph_rs.roundtrip(...) and ASSERTS byte equality (raises on mismatch — not just logging). Pivoted from the original "build a full Rust MemoryCheckpointSaver" scope: that requires async PyO3, full BaseCheckpointSaver method exposure, and Python-to-Rust value conversion for arbitrary Python objects. Out of scope for Phase 0. The shadow approach validates the wire format end-to-end against the same shapes the conformance test generates — which is the question Phase 0 was built to answer. Result: 58/58 conformance tests pass across all 5 capabilities the InMemorySaver implements (put, put_writes, get_tuple, list, delete_thread). Every msgpack blob the suite produced — synthetic Checkpoint dicts with mixed channel values, PendingWrite tuples, ChannelVersions maps, subgraph metadata — was round-tripped by Rust byte-identically. Phase 0 exit-gate criteria all met: ✓ Steps 0.1–0.6 pass criteria met. ✓ rust/docs/phase-0-report.md published. ✓ Phase 1 effort re-evaluated. 8 commits land on rust-port for the spike. Codec retired the highest- likelihood / highest-impact risk in the plan (§12). Next: Phase 1 — Skinny end-to-end V0.1 (channels, Pregel loop, streaming, hello-world example).
Two pieces of post-Phase-0 cleanup driven by review feedback ("don't
defer unless well-justified, and document remaining deferrals so they
can't be missed"):
1. Numpy ext code 6 promoted from RawExt → structured `NumpyArray`
variant. Originally deferred from Step 0.4 with the argument "not in
the corpus, no current consumer." That deferral was weak — the wire
shape is well-defined `(dtype, shape, order, buffer)`, the cost of
doing it now is ~30 LOC + a corpus fixture + a test, and the cost of
discovering the gap later (silent pass-through that *almost* works)
would be a real bug.
Adds `Decoded::NumpyArray { dtype: String, shape: Vec<u64>, order:
String, buffer: Vec<u8> }` plus encode/decode/validate_allowlist
wiring. Corpus generator regenerates with a numpy fixture.
2. The remaining three deferrals — async PyO3 bridge, Pydantic-aware
native types, full Rust MemoryCheckpointSaver — kept deferred but
now tracked in `rust/docs/phase-0-followups.md`. Each one:
- has an explicit Phase 1 owner step (4.4, 4.5, 4.1 respectively)
- has a *tracked-by check* the owner step's parity gate must include
- has a why-now-not-then argument that survives review
The doc is operational: Phase 1 cannot close any of those steps
without addressing the relevant entry. References from
phase-0-report.md so the trail is discoverable.
Tests after the numpy work:
✓ 68 Rust tests (was 67; added numpy_array_round_trip_hand_crafted)
✓ 73/73 corpus byte-identical via the bridge (was 72)
✓ 58/58 conformance via shadow serializer (unchanged)
✓ clippy clean under -D warnings
Also adds rust/ffi/*/.omc/ to .gitignore so harness state doesn't get
picked up under future ffi crates.
Lands `langgraph-core` with all 9 channel types from the Python baseline: - LastValue, LastValueAfterFinish - Topic (with accumulate variant) - BinaryOperatorAggregate (with Overwrite handling) - EphemeralValue, AnyValue, UntrackedValue - NamedBarrierValue, NamedBarrierValueAfterFinish Plan §6 Step 1.1 listed 6 channels; expanded to 9 after a parity-pass review: LastValueAfterFinish + the two NamedBarrier variants exercise consume() and finish() lifecycle hooks that the original 6 don't, closing what would have been a real gap in the parity gate. Parity gates (all green): - Rust unit tests: 48 pass (Python test verbatim ports + source-contract ports for the 5 channels with no direct Python tests). - test_channels_via_bridge.py: 10 channel parity tests run lockstep against real Python langgraph (the parity oracle) via `langgraph_rs.run_channel_sequence`. - test_channels_differential.py: 10,800 hypothesis iterations across 9 channels, zero divergences, ~7s. - Phase 0 baseline intact: bridge round-trip 73/73, conformance 58 pass. Bridge surface: added `run_channel_sequence(kind, init_json, ops_json) -> trace_json`, JSON-only protocol for the differential harness. Avoids needing PyO3 to expose Rust channel objects as Python BaseChannel ABCs. Channel trait: typed per-impl `Channel<Value, Update, Checkpoint>` with Send + Sync (anticipating tokio runtime). Object-safe `dyn ChannelKind` deferred to Step 1.2 per phase-1-followups.md langchain-ai#2 — designing the erased layer before Pregel needs it would be speculative. DeltaChannel (10th channel in the source) deferred to Step 1.3 with full tracked-by check in phase-1-followups.md #1: reducer-as-callable plus _DeltaSnapshot codec wiring make it a real scope expansion, and 4 of its 21 Python tests need StateGraph + InMemorySaver anyway. Test counts: workspace 116 pass (68 Phase 0 + 48 Step 1.1), zero clippy warnings.
Splits Phase 1 §6 Step 1.2 into 1.2a (Pregel core, differential parity on synthetic fixtures) and 1.2b (LANGGRAPH_BACKEND=rust + async PyO3 bridge). 1.2a closes Phase 1 follow-up langchain-ai#2 (erased dyn ChannelKind); 1.2b takes over the new follow-up langchain-ai#3 (async bridge + LANGGRAPH_BACKEND). What lands: - langgraph-core::pregel — algorithmic core only (no streaming, no interrupts, no subgraphs, no Send, no managed values, no callbacks). Modules: * channel_kind: erased dyn ChannelKind trait + impls for all 9 Step 1.1 channels, going through serde_json at the boundary. * apply_writes: parity port of Python _algo.apply_writes (versions_seen, consume, group writes, bump-step idle pass, finish-pass on tentatively-last superstep). * prepare_tasks: PULL path of prepare_next_tasks. Task ids match Python's xxh3-128 byte-for-byte (xxhash-rust, UUID-formatted). * loop_: PregelLoop::new + with_versioning + with_parent_ns + with_stop builders, tick(), run(), TraceEntry/TraceTask shape for the parity comparison. * checkpoint_helpers: empty_checkpoint() + checkpoint_id_to_bytes() with proper InvalidCheckpointId error variant. All submodules private; pub use surface listed in mod.rs. - langgraph-py bridge: run_pregel_fixture(name, init_json, max_steps=None) entry point with 5 hand-rolled fixtures (linear_chain, conditional_fork, fan_out, recursion, multi_channel_reducer_mix). - parity/scripts/test_pregel_fixtures_via_bridge.py: 24 parametrised tests vs Python StateGraph, user-channel parity. - parity/scripts/test_pregel_differential.py: 5 hypothesis tests, ~1300 random iterations, 0 divergences. Step + recursion-limit are i64 (matches Python's signed int wire type; 1.2b checkpoint interop needs the negative one-step "before any input" state). PregelLoop fields are pub(crate); external mutation goes through put_input/tick/run + the with_* builders. The pregel layer's runtime channel value type is named ChannelValue (distinct from the codec's wire-format Decoded — Phase 0 follow-up langchain-ai#2 plans to migrate Checkpoint::channel_values to codec Decoded later). Verification: 161 cargo tests, clippy clean, 24+5 fixture/differential parity tests, Phase 0 round-trip + conformance still all-green.
Comprehensive context dump for the next agent picking up the port after Step 1.2a. Covers what landed, the verification block (5 commands), Step 1.2b scope + open questions, hard rules carried forward from prior sessions, the bridge-install gotcha, Step 1.2a-specific learnings (i64 step, ChannelValue vs codec Decoded, xxh3 task-id parity, recursion fixture divergence), and an open follow-ups snapshot. The next session should read this file, then SESSION-RESUME.md and the two phase-N-followups.md files, run the verification block, and proceed with Step 1.2b.
Closes Phase 0 follow-up #1's tracked-by check ("at least one #[pyfunction(async)] exposed in the bridge, plus a Python async test that calls it"). First piece of the combined Step 1.2b + 1.3 milestone — see plan amendment in this commit and the locked architectural decisions in rust/docs/phase-1-followups.md entry langchain-ai#3. What lands: - rust/Cargo.toml: workspace deps for pyo3 0.28, pyo3-async-runtimes 0.28, tokio 1 (rt-multi-thread, macros, sync, time). Bridge Cargo.toml switches to workspace-versioned pyo3. - rust/ffi/langgraph-py/src/async_runtime.rs: new module with `async_echo(s) -> awaitable[str]`. Sleeps 1ms on tokio then returns s.upper() — a wiring proof, not a feature. Replaced by real entry points (run_pregel_async, …) as Step 1.2b's surface grows; kept around because its asyncio test is the cheapest smoke check that the wiring still works after future bridge changes. - parity/scripts/test_async_bridge.py: 3 tests (await, gather, attribute presence). Uses anyio (matching upstream libs/langgraph/tests/test_pregel.py — required for the eventual 87-test parity gate to run on the same async framework). - .omc/plans/langgraph-rust-port-2026-04-30.md: amends §6 to mark Step 1.2b + 1.3 as one combined milestone with the locked architectural decisions (research-backed via the two research/*.md files in this commit). - rust/docs/phase-1-followups.md: entry #1 (DeltaChannel) and entry langchain-ai#3 (async bridge + LANGGRAPH_BACKEND wiring) re-pointed to the combined milestone, with explicit decision log. - research/pyo3-async-pregel-2026-05-05.md + python-rust-backend-swap-2026-05-05.md: pplx research outputs that ground the locked decisions (pyo3-async-runtimes 0.28 is the maintained answer; Pattern B monkeypatch from a separate package is canonical for accelerating frozen-baseline libs). GIL discipline (production pattern from polars / pydantic-core / datafusion-python): release between Python callbacks via short Python::with_gil scopes. The Rust future itself runs without the GIL. Wired through pyo3-async-runtimes::tokio::future_into_py. Parity gate: 3 anyio tests via the bridge venv's Python directly (SESSION-RESUME's bridge install gotcha applies — uv run python silently reverts the .so). What the gate caught: nothing — this is foundation. The first test_async_echo_awaits_via_tokio failure on commit-day was "pytest-asyncio not installed" → fixed by switching to anyio (matches upstream langgraph), which uncovered nothing about the Rust side. Verification: - 161/161 cargo tests pass (unchanged from 1.2a baseline) - cargo clippy --workspace --all-targets -- -D warnings clean - 3/3 new asyncio tests pass - 73/73 Phase 0 corpus + 49 allowlist + strict-reject pass - 58/58 conformance via Rust shadow pass - 19/19 Step 1.1 channels parity (10 fixtures + 9 differential) - 29/29 Step 1.2a Pregel parity (24 fixtures + 5 hypothesis)
Closes the channel-only portion of Phase 1 follow-up #1's tracked-by check (≥1,000 hypothesis iterations + dict-reducer Python tests). The Pregel-coupled portion of follow-up #1 (snapshot scheduling driven by `is_snapshot_step`, end-to-end filesystem saver) and the `_messages_delta_reducer` Python tests stay deferred to the combined Step 1.2b + 1.3 milestone — that's where Python callable reducers cross the bridge via the async callback runner. What lands: - langgraph-core::channels::delta — new module: * `DeltaChannel<T>`: typed Channel impl. State is `Option<T>` (None = MISSING). Update accepts `DeltaUpdate<T>` enum: `Value(T)` or `Overwrite(Option<T>)` (None = reset to default). One Overwrite per superstep is enforced; two raises `InvalidUpdateError` matching Python's behavior. * `replay_writes(&[DeltaUpdate<T>])`: walks an ancestor write log, last-Overwrite-wins on the base, then folds remaining values through the reducer. Empty replay is a no-op. * `is_snapshot_step(step: i64) -> bool`: matches Python's `step > 0 && step % freq == 0` (uses `is_multiple_of` for clippy 1.95). * `DeltaCheckpoint<T>`: { Sentinel, Snapshot(T) } discriminator. Channel.checkpoint() always returns Sentinel for non-empty state — the Snapshot path is Pregel-driven (combined milestone). * `reducers` submodule: `merge_dicts` (dict union, last-write- wins per key), `extend_list` (concat), `merge_files` (dict union with `null` → delete). Selectable by name from the bridge: arbitrary Python callable reducers cross via the async callback runner step in 1.2b proper. * 19 unit tests: empty/missing handling, fresh-channel from sentinel, basic merge, last-write-wins, Overwrite(Some/None), double-Overwrite invalid, batching invariance for both reducers, replay_writes mechanics, snapshot-step predicate. - langgraph-core::pregel::channel_kind_impls — DeltaChannel `ChannelKind` impl. JSON dispatch: Update: raw value → `DeltaUpdate::Value` `{"__overwrite__": v_or_null}` → `DeltaUpdate::Overwrite` Checkpoint: `{"__delta_sentinel__": true}` for Sentinel, `{"__delta_snapshot__": v}` for Snapshot. - langgraph-py bridge — `Op::Replay { writes }` variant added to the channels.rs op enum. Non-Delta drivers return `UnsupportedOp` for replay; only `run_delta` actually executes it. Init shape extends with `reducer`, `default_kind`, `seed`, `snapshot_frequency`. - parity/scripts/test_delta_channel_via_bridge.py — 10 tests: * 7 dict-reducer fixture scenarios mirroring `test_channels.py` (fresh-channel, basic-updates, writes-reconstruction, with-deletions update path, with-deletions replay path, overwrite-in-update, overwrite-in-writes-replay). * 1 InvalidUpdate scenario (two Overwrites in one step). * 2 hypothesis differential tests (merge_dicts and extend_list) at 600 examples each — 1,200 iterations, 0 divergences. Parity gate: 10/10 via the bridge venv's Python (anyio + hypothesis). What the gate caught: - Bridge install loop (uv pip install needed VIRTUAL_ENV pointing at the bridge venv, not cwd; one wheel-build race between source edits and `run_in_background`). Documenting these in the SESSION-RESUME bridge gotcha is already covered. - Three clippy 1.95 lints: manual modulo (`is_multiple_of`), two collapsible-if patterns (`if let && let && let` chain). Verification (regression): - 180/180 cargo tests pass (up from 161; +19 delta unit tests) - cargo clippy --workspace --all-targets -- -D warnings clean - 73/73 Phase 0 corpus + 49 allowlist + strict-reject - 58/58 conformance via Rust shadow saver - 19/19 Step 1.1 channels parity (10 fixtures + 9 differential) - 29/29 Step 1.2a Pregel parity (24 fixtures + 5 hypothesis) - 3/3 Step 1.2b async-bridge wiring (foundation commit) - 10/10 Step 1.2b DeltaChannel parity (this commit)
Plumbs `Result<Vec<Write>, NodeError>` through `NodeCallable`,
`PregelLoop::tick()`, and `PregelLoop::run()`. Adds
`PregelError::NodeFailed { node, message }` so Pregel can surface
node-execution failures with the failing node's name attached.
This is the foundation for Task #4b (Python-callable Node wrapper):
when a Python node raises, the bridge will catch the `PyErr`, stash
it in a side-channel registry keyed by node name, and return
`Err(NodeError)`; the Pregel loop propagates as `NodeFailed`; the
bridge driver re-raises the original Python exception. Side-channel
storage lives in the bridge crate so `langgraph-core` stays
PyO3-free.
What lands:
- langgraph-core::pregel::errors:
* `NodeError { message: String }` with std::error::Error +
Display + From<&str>/From<String>. Public.
* `PregelError::NodeFailed { node, message }` new variant.
* Doc-comments updated to reflect the 1.2b ownership.
- langgraph-core::pregel::node: `NodeCallable` type alias bumped
to `Fn(&NodeInput) -> Result<Vec<Write>, NodeError>`. Doc
explains the bridge-side PyErr capture path.
- langgraph-core::pregel::loop_::tick(): node call site now does
`callable(&input).map_err(|e| PregelError::NodeFailed {
node: name, message: e.message })?`. Hot path unchanged for
happy returns.
- All 12 existing closure use-sites wrap their happy-path returns
in `Ok(...)` — 4 in `loop_.rs` tests, 1 in `prepare_tasks.rs`
(`noop_callable`), 5 in `ffi/langgraph-py/src/pregel.rs` fixture
builders, 2 in shared `single_i64_node` / `router_node` helpers.
`panic!` arms still type-check via `!` coercion.
- `pregel/mod.rs` re-exports `NodeError`.
- phase-1-followups.md: documents 2026-05-05 scope decision that
Task langchain-ai#3 (channel translation harness) is subsumed by the
existing differential gate + DeltaChannel parity test (the
"translate to Rust → translate back → equal at every step"
invariant is what differential per-step trace equality already
proves). The Python-`BaseChannel` → Rust-`dyn ChannelKind` glue
moves into the async runner where it has a runtime user.
New test: `node_callable_error_surfaces_as_node_failed` proves a
node returning `Err(NodeError::new("simulated python KeyError ..."))`
surfaces as `PregelError::NodeFailed { node: "boom", message: ... }`
through `PregelLoop::run()`.
What the gate caught: nothing — pure plumbing refactor, all 12
existing closures preserved their semantics. No behavior change for
happy paths; cargo + clippy clean across the workspace.
Verification:
- 181/181 cargo tests pass (180 prior + 1 new error-path test)
- cargo clippy --workspace --all-targets -- -D warnings clean
- 29/29 Step 1.2a Pregel parity (24 fixtures + 5 hypothesis) — proves
refactor preserves behavior on the 5 fixture graphs
- 10/10 Step 1.2b DeltaChannel parity (unchanged)
- 3/3 Step 1.2b async-bridge wiring (unchanged)
- All Phase 0 + Step 1.1 gates remain green (not re-run inline,
last verified at the prior 1.2b foundation commit 5325311)
Wraps a Python callable as a Rust `NodeCallable` so the Pregel loop can drive Python node bodies mid-tick. Builds on the error path plumbed in commit 3339ea4. What lands: - rust/ffi/langgraph-py/src/python_node.rs (new module): * `PyErrStash = Arc<Mutex<HashMap<String, PyErr>>>` — side-channel registry keyed by node name. Wrapper stashes raised PyErr; bridge driver fishes it out on `PregelError::NodeFailed` and re-raises so the original Python exception class survives the round-trip. `langgraph-core` stays PyO3-free; the stash lives in the bridge. * `make_python_node_callable(name, py_callable, stash) -> NodeCallable` — wraps a `Py<PyAny>` in a closure that: 1. Acquires the GIL via `Python::attach` (PyO3 0.28 successor to pre-0.28 `with_gil`). 2. Translates `NodeInput` to a Python value (single value or dict) via `json.loads` round-trip — slow but rock-solid; any optimisation lands later if profiling demands it. 3. Invokes `py_callable(py_input)`. 4. Decodes the result list into `Vec<Write>`. 5. On PyErr, stashes the original via `clone_ref` and returns `Err(NodeError)` carrying a stringified fallback. * Helpers: `node_input_to_py`, `py_result_to_writes`, `json_to_py`, `py_to_json`, `new_pyerr_stash`. - rust/crates/langgraph-core/src/pregel/loop_.rs: * `PregelLoop::replace_node_callables(replacements: HashMap<String, NodeCallable>)` — bridge swaps fixture-time Rust closures for Python-callback wrappers without disturbing the channel/topology setup. - rust/ffi/langgraph-py/src/pregel.rs: * Refactored `run_fixture_inner` to delegate input application to a shared `apply_init_to_loop` helper. * New `run_fixture_with_python_nodes_inner`: builds the named fixture, swaps in Python-callback wrappers, runs the loop. On `PregelError::NodeFailed { node, .. }` it removes the stashed `PyErr` and returns `Err(py_err)` so PyO3 re-raises the original Python exception class. - rust/ffi/langgraph-py/src/lib.rs: * New `#[pyfunction]` `run_pregel_fixture_with_python_nodes(name, py_callables, init_json, max_steps=None)`. Calling convention: Python callable takes one positional arg (the input value or dict) and returns `list[(channel_name, value)]`. - parity/scripts/test_pregel_python_callbacks.py — 8 tests: * Trace equality: linear_chain via Python lambdas matches the Rust-closure trace byte-for-byte (positive + negative inputs). * Final-state spot check: `input=5 → incr → 6 → doub → 12`. * Exception class preservation: a node that raises `ValueError` surfaces as `ValueError` (not wrapped in `RuntimeError`). * Custom-subclass round-trip: a user-defined `_CustomTestError` (subclass of `ValueError`) preserves its exact class. * Cross-class check: `KeyError` propagates with class intact. * Bad fixture name → `PyValueError` (not panic). * Bad return shape → `RuntimeError` (the no-PyErr fallback path). What the gate caught: - PyO3 0.28 renamed `Python::with_gil` to `Python::attach`. The pplx research from commit 720f5b0 referenced the pre-0.28 spelling; this commit corrects it. Behavior is unchanged — same GIL-release pattern from polars / pydantic-core / datafusion-python. - PyAnyMethods::downcast → Bound::cast deprecation in 0.28; switched over to clear `-D warnings`. - The `apply_init_to_loop` factor-out caught a small inconsistency in error-message wording between the Rust-closure and Python- callback paths (now identical). Verification: - 181/181 cargo tests pass (unchanged from #4a baseline) - cargo clippy --workspace --all-targets -- -D warnings clean - 8/8 new Python-callback parity tests pass - 29/29 Step 1.2a Pregel parity (regression check — replace_node_callables doesn't break fixture-default behavior) - 19/19 Step 1.1 channels parity - 10/10 Step 1.2b DeltaChannel parity - 3/3 Step 1.2b async-bridge wiring - Phase 0 + conformance: not re-run this commit (still green from prior commits 720f5b0, 5325311, 3339ea4)
…angchain-ai#7 Comprehensive handoff for a fresh-context agent picking up the combined Step 1.2b + 1.3 milestone partway through. Covers: - Where we are (4 foundation commits landed: 720f5b0, 5325311, 3339ea4, 39206f3). - What each foundation commit delivered (architectural surface, parity gates). - Verification block (181 cargo, clippy clean, 73/73 + 49 + reject, 58/58 conformance, 69 parity-gate tests). - Sub-task plan for the remaining four (#4c channel translation, langchain-ai#5 StateGraph compiler, langchain-ai#6 langgraph_rs.backend monkeypatch, langchain-ai#7 87-test parity gate green). - Lessons learned this session: Python::with_gil → Python::attach in PyO3 0.28; PyAnyMethods::downcast deprecation; uv pip install needs VIRTUAL_ENV explicit; background maturin build can race edits; pytest-asyncio not in bridge venv (use anyio); adding Op variants requires updating all hand-coded matches; clippy 1.95 is_multiple_of + collapsed-if-let-chain lints; maturin python-source switch needed for langchain-ai#6; PyErr stash side-channel pattern for cross-Rust exception class preservation; json round- trip is fast enough for value translation. - Open follow-ups snapshot. - Sub-task tracking table. Companion to STEP-1.2A-HANDOFF.md and SESSION-RESUME.md — read all three on resume.
…k bridge) Round-trips Python `BaseChannel.checkpoint()` state through Rust `from_checkpoint` for every stdlib channel class. Closes the channel- translation deliverable for the combined Step 1.2b + 1.3 milestone (`rust/docs/STEP-1.2B-PARTIAL-HANDOFF.md`). The runner monkeypatch in sub-step langchain-ai#6 will use `extract_state` / `apply_state` to wire Python channel instances through the Rust loop tick. What landed ----------- - New `rust/ffi/langgraph-py/src/channel_translate.rs` — Rust translation gate. Per-class `round_trip_*` functions parse the msgpack-encoded state, build a Rust channel via `from_checkpoint`, and re-encode the result. 10 stdlib classes covered (LastValue, LastValueAfterFinish, Topic, BinaryOperatorAggregate, EphemeralValue, AnyValue, UntrackedValue, NamedBarrierValue, NamedBarrierValueAfterFinish, DeltaChannel). Custom user-defined channels return `ValueError` (Python: `RustBackendUnsupported`). - New PyO3 entry points `translate_channel_round_trip` (msgpack bytes in, msgpack bytes out) and `supported_channel_classes`. - New Python helper `parity/scripts/_channel_translate.py`: `class_name`, `extract_state`, `apply_state`, `pack_state`, `unpack_state`, `RustBackendUnsupported`. Per-class dispatch via `_EXTRACTORS` / `_BUILDERS` / `_APPLIERS` dicts; symmetry checked at import time. Caller-misuse cases (missing operator/reducer/ names) raise `ValueError`, distinct from `RustBackendUnsupported`. - New parity test `parity/scripts/test_channel_translate.py` — 35 tests covering bridge contract, custom-class rejection, caller-misuse errors, and per-class round-trip semantics for all 10 channels. - Per-class encoding table documented inline (Rust module docstring) and traced back to handoff doc. - Workspace dep: `rmp-serde = "1"` for serde-style msgpack on the Rust side. Python uses `ormsgpack` (already in bridge venv). Wire format ----------- msgpack bytes — matches the locked architectural decision in `phase-1-followups.md` entry langchain-ai#3 §6. Python packs with `ormsgpack`, Rust decodes via `rmp_serde` into `serde_json::Value`. Same encoding family the rest of the project uses for checkpoint blobs; future expansion to ext-coded values (LangChain messages, etc.) layers on without changing the bridge surface. Parity gate ----------- For each channel class, drive the round-trip Python.checkpoint() -> msgpack -> Rust.from_checkpoint() -> Rust.checkpoint() -> msgpack -> Python.from_checkpoint() -> .get() and assert the seeded Python channel observes the same state as the original. For `DeltaChannel` snapshot blobs, the Rust side collapses to sentinel (matching Python's invariant); we verify the replay target instead. What the gate caught -------------------- - Python's `DeltaChannel.from_checkpoint(MISSING)` is asymmetric: it sets `value = typ()` rather than leaving the channel MISSING. Test `test_missing_round_trips_missing` documents the asymmetry with a `fresh.get() == {}` assertion. - Subclasses of stdlib channels need a distinct error path from fully-custom channels: the runner ought to know "we know the parent shape but you customised it" vs "we have no idea what this is". `class_name` walks the supported-class MRO to give a precise message. - Caller misuse (missing `operator` / `reducer` / `names` in `init_args`) is `ValueError`, not `RustBackendUnsupported`. Two failure modes wearing one exception turns 5-minute debugs into 30-minute ones. Test counts ----------- - Cargo workspace: 211 passed (was 210; +1 invalid_msgpack test). Clippy clean. - Phase 0: 73/73 round-trip, 49 allowlist, strict reject; 58 conformance. - Phase 1 + 1.2b foundation + #4c: 104 passed (was 69; +35 channel translate tests).
Trailing follow-up to 9a470e9. Replaces the "(this commit)" placeholder with the actual commit SHA in both the top-of-doc status table and the sub-task tracking table at the bottom. Updates the test count (35, was 31 in the placeholder) to match what shipped. Closes a NITPICK from the in-session /james pass: the placeholder was fine at write-time but ages poorly — the next session ought to be able to find #4c via the same `git log <sha>` lookup as every other entry in the table.
…ler (V0.1 scope) Minimum-viable Rust `StateGraph` builder that compiles to a runnable `PregelLoop`. Sub-task langchain-ai#5 of the combined Step 1.2b + 1.3 milestone (`rust/docs/STEP-1.2B-PARTIAL-HANDOFF.md`); satisfies the original Step 1.3 sub-gate (5 fixture graphs trace-equal vs Python `StateGraph`). The full Python `StateGraph` is 1833 lines + branch helpers; the V0.1 port is sharply scoped to what the 5-fixture sub-gate needs and what the 87-test gate (langchain-ai#7) ultimately requires from the compiler. Everything beyond that is documented as deferred to follow-ups so langchain-ai#5 doesn't drag features the runner monkeypatch (langchain-ai#6) doesn't need. What landed ----------- - New `crates/langgraph-core/src/state_graph/mod.rs`: - `StateGraph::new(channels)` (explicit channel map; no `Annotated[T, reducer]` schema inference). - `add_node`, `add_edge`, `add_conditional_edges`, `set_entry_point`, `set_finish_point`, `compile`. - `compile()` lowers to a `PregelLoop` by generating synthetic `branch:to:NODE` `LastValue<Value>` trigger channels for every incoming-edge target. User node callables are wrapped to emit sentinel writes for direct outgoing edges + conditional-branch resolutions after the user's state-channel writes. - `START` / `END` constants. `BRANCH_PREFIX` reserved namespace (compile rejects collisions). `START -> node` edges return the corresponding synthetic input channel via `CompiledGraph.input_channels` so the caller knows what to put_input. - 9 cargo unit tests covering compile validation + linear chain + conditional fork + fan-out + branch error path. - New `rust/ffi/langgraph-py/src/state_graph_fixtures.rs` — bridge module that builds the 5 fixture graphs (linear_chain, conditional_fork, fan_out, conditional_join, recursion) via the new `StateGraph` builder. New PyO3 entry point `run_state_graph_fixture(name, init_json) -> trace_json`. - New `parity/scripts/test_state_graph_via_bridge.py` — 25 tests driving each fixture against the upstream Python `StateGraph` and comparing user-visible state + node execution sequence. Out of scope (V0.1, deferred to follow-ups) ------------------------------------------- - Schema inference from `Annotated[T, reducer]`. Caller passes a `dyn ChannelKind` map directly. Rationale: Rust has no runtime reflection over `Annotated`-style metadata; bringing that surface in is a Step 4.5-style concern (Phase 0 follow-up langchain-ai#2). - Subgraphs. `add_node` does not accept a nested `CompiledGraph`. - `defer=True` deferred nodes. - Async-only nodes / `astream`. Sub-step langchain-ai#6 owns the async monkeypatch path. - Runtime context object. - `add_sequence` (chains of nodes). - Node return-value coercion. Rust nodes return explicit `Vec<Write>`; Python's "return dict → infer state writes" is handled at the runner boundary in langchain-ai#6. Parity gate ----------- For each of the 5 fixtures: build the same logical graph with Rust `StateGraph` AND Python `StateGraph`, drive with the same input, compare: * user-visible state-channel final values (must match); * node execution order (must match for deterministic graphs); * for parallel branches (fan_out, conditional_join), the *set* of nodes that fired per superstep (parallel ordering canonicalised by Pregel). What the gate caught -------------------- - `recursion` final counter matches Python; total `step` fire count is documented-divergent: Python's `add_conditional_edges` evaluates the branch on POST-write state while the V0.1 Rust builder evaluates on PRE-write state. Same divergence as the Step 1.2a hand-rolled recursion fixture; final-state parity is the actual claim. - Branch path-map keys must match the resolved-key lookup. An unknown key surfaces as `PregelError::NodeFailed { node, message }` (the same path Python exception classes use in #4b). - `thiserror` magic-treats fields named `source` as `#[source]` — caught at compile time, renamed to `node`. - `PregelLoop` / `CompiledGraph` need explicit `Debug` (the bridge fields are PyO3-flavoured and don't auto-derive). Manual impl on `CompiledGraph` keeps the public surface usable from `unwrap_err()` in tests. Test counts ----------- - Cargo workspace: 220 passed (was 211; +9 state_graph unit tests). Clippy clean. - Phase 0: 73/73 + 49 + strict reject; 58 conformance. - Phase 1 + 1.2b foundation + #4c + langchain-ai#5: 129 passed (was 104; +25 StateGraph parity tests).
…doff for langchain-ai#6/langchain-ai#7 Milestone update for the combined Step 1.2b + 1.3 final stretch. Locked architectural decision (2026-05-06) ------------------------------------------ The original plan §6 and `phase-1-followups.md` entry langchain-ai#3 §5 left the door open to "a tighter cut decided in implementation" for the `langgraph_rs.backend` monkeypatch — i.e., replacing only `tick()` (approach B) or only `_algo.apply_writes` + `prepare_next_tasks` (approach C) instead of the full `SyncPregelLoop` (approach A). The user has explicitly chosen approach A: full `SyncPregelLoop` replacement. Reasoning captured in the new handoff doc: * A is the only approach where `LANGGRAPH_BACKEND=rust` actually means "Rust drives the loop" — B and C still leave Python orchestrating most per-tick work. * B's per-tick re-sync of channel state is wasteful and adds an extra parity surface that's correctness risk we don't need. * C is essentially a third copy of `test_pregel_differential.py`'s coverage — buys us nothing new. * "Done right the first time" — the full replacement is bigger but architecturally honest; a tighter cut is technical debt that would need to be redone before Step 1.4 streaming or Phase 2. The "re-build from checkpoint each tick" guidance from the original phase-1-followups langchain-ai#3 §6 is also superseded: under approach A, Rust state is constructed once at `__enter__` (Python → Rust via `_channel_translate.extract_state`) and applied once at `__exit__` (Rust → Python via `apply_state`). No per-tick re-sync. What this commit changes ------------------------ - New `rust/docs/STEP-1.2B-FINAL-HANDOFF.md`: the comprehensive handoff brief for the next session picking up langchain-ai#6 and langchain-ai#7. Covers: * Where we are (status table through `c03c7ac6`). * Locked architectural decision (approach A). * langchain-ai#6 sub-step breakdown (#6a Maturin layout switch → #6b backend.py monkeypatch → #6c Pregel runtime bridge entry point). * langchain-ai#7 iteration loop (87-test gate). * `__init__.py` re-export shim contents (drop-in for the layout switch). * Replaced symbols list pattern for `backend.py`. * `RustBackendUnsupported` rejection sites for the 4 deliberately out-of-scope feature families (custom channels, subgraphs, Send, interrupts, stream modes outside values/updates). * Verification block, hard rules, bridge install gotcha, lessons-learned forwarding from prior handoffs. - `rust/docs/STEP-1.2B-PARTIAL-HANDOFF.md`: prepended a SUPERSEDED notice pointing at the new final handoff for langchain-ai#6/langchain-ai#7. The partial-handoff content is preserved as historical context for what shipped in #4c and langchain-ai#5. - `rust/docs/phase-1-followups.md` entry langchain-ai#3 §5 + §6: amended to record the approach A decision and the supersession of the per-tick-resync line. - `.omc/plans/langgraph-rust-port-2026-04-30.md` §6 Step 1.2b+1.3 Locked decisions §4 + §5: same amendments, with a pointer to the final handoff doc. What's not changing ------------------- The 5 hard architectural decisions in §6 ("combined milestone", "async runtime: pyo3-async-runtimes", "GIL discipline", "errors via PregelExecutionError::NodeFailed", "channel translation by class name") remain locked. Approach A is the runtime-shape decision that sits *above* those. Test counts ----------- Unchanged — pure docs commit. Latest baseline (HEAD = `c03c7ac6`): * Cargo: 220 passed, clippy clean. * Phase 0: 73/73 + 49 + strict reject; 58 conformance. * Phase 1 + 1.2b foundation + #4c + langchain-ai#5: 129 passed.
Mechanical, low-risk preparatory commit for sub-step #6b (the `langgraph_rs.backend` Python module + monkeypatch). Sub-task #6a of the combined Step 1.2b + 1.3 milestone (`rust/docs/STEP-1.2B-FINAL-HANDOFF.md`); test counts unchanged. Maturin's default layout puts the compiled extension at the top level (`langgraph_rs.so`), which makes adding a Python sibling file impossible without renaming. #6b needs to add `backend.py` next to the compiled module, so we switch to the `python-source = "python"` layout where the cdylib lives at `langgraph_rs._lib` and a hand-written `__init__.py` re-exports its public surface. What landed ----------- - `rust/ffi/langgraph-py/pyproject.toml`: added `python-source = "python"`, changed `module-name = "langgraph_rs"` to `"langgraph_rs._lib"`. - `rust/ffi/langgraph-py/src/lib.rs`: renamed `#[pymodule] fn langgraph_rs(...)` to `#[pymodule] fn _lib(...)` to match the new module name. - New `rust/ffi/langgraph-py/python/langgraph_rs/__init__.py`: re-exports the 12 public symbols + `__version__` so existing `import langgraph_rs` and `from langgraph_rs import roundtrip` call sites in every parity script keep working unchanged. - `.gitignore`: ignore the in-tree `python/langgraph_rs/_lib*.so` build artifact that `maturin build` drops into the package source tree under the new layout. The wheel under `target/wheels/` is the source of truth. Out of scope ------------ - No backend monkeypatch yet — that's #6b. - No new bridge symbols — `_lib` exposes the exact same surface as the old top-level `langgraph_rs` did. Parity gate ----------- The handoff calls #6a's gate "test counts stay constant across this commit". After rebuilding the wheel and reinstalling into the bridge venv via the canonical procedure, every existing gate is green and equal in count to the pre-commit baseline. What the gate caught -------------------- - Maturin's `python-source` mode copies the compiled `.so` into the source tree as a side effect of `maturin build` (it's the development-import target). The wheel still contains the authoritative copy; the in-tree one is a build artifact and must be gitignored. Caught by the first `git status` after the wheel build. Test counts (unchanged) ----------------------- - Cargo workspace: 220 passed; clippy clean. - Phase 0: 73/73 corpus + 49 allowlist + strict reject; 58 conformance pass / 0 fail. - Phase 1 + 1.2b foundation + #4c + langchain-ai#5: 129 passed.
…hon scaffolding) Land the Python-side activation surface for the Rust backend. Sub-task #6b of the combined Step 1.2b + 1.3 milestone (`rust/docs/STEP-1.2B-FINAL-HANDOFF.md`); Rust runtime bridge entry point follows in #6c, full 87-test gate is langchain-ai#7. Approach choice (locked in this commit body) -------------------------------------------- The handoff's "approach A — full SyncPregelLoop replacement" left an implementation question: stand up a parallel duck-typed shadow class, or subclass `SyncPregelLoop` and override only the algorithmic core? We chose **subclass + override** after surfacing the trade-off: * Subclass inherits `BackgroundExecutor` / `ExitStack` / lifecycle event queue / status machinery / checkpoint persistence / `accept_push` / `output_writes` / `match_cached_writes` from upstream. None of those is a parity surface we want to grow in Python, and reimplementing 30+ methods just to hit `Pregel.invoke`'s read sites is the wrong cost shape for V0.1. * Approach A's distinguishing property over B — "Rust state lives across the whole graph execution; no per-tick *bidirectional* re-sync" — is preserved by syncing once at `__enter__` (Python channels → Rust) and once at `__exit__` (Rust → Python channels). A subclass that overrides the algorithmic core (`__enter__` validation + Rust seed, `tick`/`after_tick` per-superstep BSP work, `__exit__` flush) gets approach A's behaviour with significantly less Python-side parity surface than a duck-typed shadow. What landed ----------- - `rust/ffi/langgraph-py/python/langgraph_rs/backend.py` — the activation module: * `REPLACED_SYMBOLS` tuple (top-of-file, auditable diff against upstream): both `langgraph.pregel._loop.{Sync,Async}PregelLoop` AND `langgraph.pregel.main.{Sync,Async}PregelLoop`. The second pair is load-bearing — `pregel/main.py` imports the loop classes directly (`from langgraph.pregel._loop import SyncPregelLoop, AsyncPregelLoop`), so the runtime call sites at `main.py:2847` (sync) and `main.py:3299` (async) read the local module attribute. A single-namespace patch leaves `Pregel.stream` / `astream` instantiating the upstream class. Caught by smoke-test langchain-ai#3 below. * `_RustSyncPregelLoop(SyncPregelLoop)` — subclass with overrides: - `__enter__` calls `super().__enter__()`, then iterates `self.channels` validating each via `_channel_translate.class_name` (raises `RustBackendUnsupported` for custom or subclassed channels); then rejects unsupported `interrupt_before` / `interrupt_after` / non-`{values,updates}` stream modes. - `tick()` raises `NotImplementedError` pointing at sub-step #6c until the Rust runtime bridge entry point lands. Auditably non-functional rather than silent fallback. * `_RustAsyncPregelLoop(AsyncPregelLoop)` — subclass that allows construction (so `Pregel.astream`'s `async with` setup doesn't crash before the rejection) but raises `RustBackendUnsupported` from `__aenter__`. Async parity is a deferred follow-up after the 87-test sync gate is green. * `_install_monkeypatches()` is idempotent and gated by `LANGGRAPH_BACKEND=rust` at import time. `is_active()` exposes the install state to tests. - `rust/ffi/langgraph-py/python/langgraph_rs/_channel_translate.py` — moved (via `git mv`) from `parity/scripts/_channel_translate.py`. Production backend code shouldn't depend on parity-test infrastructure; the helper now lives in the package and the parity test imports it from there. (Functional contents unchanged.) - `parity/scripts/test_channel_translate.py` — import switched from the `sys.path.insert(...) + from _channel_translate import ...` shim to `from langgraph_rs._channel_translate import ...`. 35 tests pass unchanged. - `parity/scripts/test_backend_activation.py` — new, 10 smoke tests pinning #6b's surface (replacement list, both-namespace patch, subclass MRO, idempotency, every rejection site, `#6c` stub pointer, async-`__aenter__` rejection). - `conftest.py` (project root) — top-level pytest hook that imports `langgraph_rs.backend` when `LANGGRAPH_BACKEND=rust` is set. Lives at the repo root so it covers both `parity/scripts/` and `libs/langgraph/tests/` (the 87-test gate's home for langchain-ai#7); does nothing without the env var. Out of scope / explicitly rejected (`RustBackendUnsupported`) ------------------------------------------------------------- - Custom user-defined channel classes (anything not in the 10 stdlib set surfaced by `langgraph_rs._channel_translate`). - `interrupt_before` / `interrupt_after` (V0.1 deliberately excludes interrupts; the 87-test filter excludes them via `-k "not interrupt"` but a filter slip surfaces here). - Stream modes outside `values` and `updates`. - Async (`AsyncPregelLoop`) — symbol replaced symmetrically but rejects at `__aenter__`. Out of scope / deferred to #6c ------------------------------ - Subgraphs / `Send` / nested-`Pregel` rejection. Those need per-node introspection at `__enter__` time; deferred to #6c alongside the actual Rust call so we don't grow validation that isn't yet exercised. - The actual Rust call. `tick()` raises `NotImplementedError` pointing at #6c. Activating `LANGGRAPH_BACKEND=rust` and invoking any graph that passes the rejection sites will fail predictably. Parity gate ----------- - Without `LANGGRAPH_BACKEND=rust`: every existing parity gate unchanged in count and result. Importing the module is a no-op, upstream `SyncPregelLoop`/`AsyncPregelLoop` symbols untouched. - With `LANGGRAPH_BACKEND=rust`: 10 new smoke tests in `test_backend_activation.py` pin both the replacement surface and the rejection paths. No graph actually runs end-to-end — that's #6c. What the gate caught -------------------- 1. Single-namespace monkeypatch is insufficient. First wiring patched only `langgraph.pregel._loop.{Sync,Async}PregelLoop`; activating the env var and calling `graph.invoke(...)` did NOT raise the #6c `NotImplementedError` because `pregel/main.py` had imported the class directly into its module namespace at langgraph load time, and the `with SyncPregelLoop(...)` call site read the local reference. Fixed by extending `_install_monkeypatches` to patch `langgraph.pregel.main` as well, and pinning that in `REPLACED_SYMBOLS`. 2. `_channel_translate.py` was reachable from the parity tests via a `sys.path.insert(...)` shim, but the backend module needs it as a real package import. Moved into `langgraph_rs/` so production code doesn't depend on `parity/scripts/` layout. 3. The async test originally tried to introspect docstrings on the override; that was over-engineered and brittle. Replaced with a direct `asyncio.run(instance.__aenter__())` pytest.raises check. Test counts ----------- - Cargo workspace: 220 passed; clippy clean. No Rust changes. - Phase 0: 73/73 corpus + 49 allowlist + strict reject; 58 conformance pass / 0 fail. - Phase 1 + 1.2b foundation + #4c + langchain-ai#5 + #6a: 129 passed (unchanged; no LANGGRAPH_BACKEND set). - Phase 1 + 1.2b foundation + #4c + langchain-ai#5 + #6a + #6b: 139 passed (+10 backend activation tests).
…oint Land the Rust runtime bridge entry point that drives the Pregel loop end-to-end for an arbitrary Python ``Pregel`` topology, plus the ``_RustSyncPregelLoop.tick`` wiring that calls into it. Sub-task #6c of the combined Step 1.2b + 1.3 milestone (final non-langchain-ai#7 sub-task per ``rust/docs/STEP-1.2B-FINAL-HANDOFF.md``); the 87-test gate (langchain-ai#7) follows. What landed ----------- - ``rust/ffi/langgraph-py/src/pregel_topology.rs`` (new) — bridge fn ``run_pregel_loop_topology`` that takes a Python topology (per-channel ``{class, init, state}``, per-node ``{triggers, reads}``, plus a ``dict[node_name, Python callable]``) and runs the loop end-to-end. Returns msgpack ``{final_state, trace, step, status}``. Run-to-completion: the Python subclass calls this once at first ``tick``; the upstream ``while loop.tick():`` body runs zero iterations (Rust already invoked every node via the python_node callback wrapper). ``NodeFailed`` errors fish the original ``PyErr`` out of the ``PyErrStash`` and re-raise so ``pytest.raises(ValueError)`` semantics carry across the boundary. - ``rust/ffi/langgraph-py/src/channel_translate.rs`` — refactored to expose ``build_channel_kind(class, init, state) -> Box<dyn ChannelKind>`` and ``extract_state_from_channel(class, &chan) -> Value``. The runtime path (sub-step #6c) needs to *keep* the constructed channel and run the loop against it; the legacy round-trip path keeps its bytes-in/bytes-out shape. ``BinaryOperatorAggregate`` runtime channels use the panic-stub binop — the actual fold runs in Python via the node callback wrapper. Native binops are deferred to a langchain-ai#7 follow-up once a failing test demands it. - ``rust/crates/langgraph-core/src/pregel/loop_.rs`` — added ``pub fn step()``, ``pub fn stop()``, and ``pub fn iter_channels()`` so the bridge crate can populate the run-result envelope (out-of-steps detection, final-state extraction). All existing ``pub(crate)`` field invariants kept; the accessors return references / copies of primitive fields. - ``rust/ffi/langgraph-py/src/lib.rs`` — wired ``run_pregel_loop_topology`` into the PyO3 module surface. - ``rust/ffi/langgraph-py/python/langgraph_rs/backend.py`` — ``_RustSyncPregelLoop.tick`` now calls into Rust on first invocation: * Builds per-node Python wrappers that bridge between Rust's ``NodeInput`` (single value or dict) and the upstream PregelNode pipeline (mapper → bound → writers). Captures channel writes via ``CONFIG_KEY_SEND``; provides ``CONFIG_KEY_READ`` over a local-state shadow seeded from the read input and updated as writes flow, so conditional edges that read channels the node already wrote (the common ``add_conditional_edges`` pattern in state graphs) see fresh values. Passes ``CONFIG_KEY_TASK_ID`` so writers don't blow up on missing-key lookups. * Packs channel specs (class + init args + encoded state) and node specs (triggers + reads) for the bridge. * Decodes the result envelope, applies ``final_state`` back to ``self.channels`` via ``_channel_translate.apply_state`` so upstream's ``__exit__`` ``read_channels(self.channels, self.output_keys)`` sees the post-loop values. * Sets ``self.tasks = {}`` so the upstream ``runner.tick(loop.tasks.values())`` loop runs zero iterations. * Sets ``self._put_checkpoint_fut`` to a completed Future so the ``durability == "sync"`` path's ``.result()`` doesn't hang. * Emits a final ``values`` stream chunk so ``Pregel.invoke``'s stream consumer sees the final state. - ``parity/scripts/test_backend_activation.py`` — replaced the ``#6c stub`` test with three end-to-end smoke tests: ``test_trivial_graph_round_trips_through_rust`` (single-node incr), ``test_two_node_chain_round_trips_through_rust`` (incr → doub), ``test_conditional_fork_round_trips_through_rust`` (router → conditional → evens|odds). Each is a minimal parity claim: same input through the Rust backend produces the same final state as upstream Python. Out of scope / known-incomplete (handed to langchain-ai#7) ---------------------------------------------- - ``BinaryOperatorAggregate`` native binops. Rust uses the panic-stub; folds run via the Python node callback. The actual ``operator.add`` / ``messages.add_messages`` reducers fire from Python, not Rust. When a langchain-ai#7 test surfaces a case the panic-stub hits, ``build_channel_kind`` will gain a named-reducer dispatch or a Python-callback binop wrapper. - Branches that read channels the node didn't write/read (the ``CONFIG_KEY_READ`` shadow only carries per-node-local state). Most ``add_conditional_edges`` patterns are local-state only; cross-channel reads are a langchain-ai#7 widening target. - ``BackgroundExecutor`` parallelism within a superstep. The Rust loop currently invokes nodes serially. Upstream's ``runner.tick`` parallelism doesn't apply because we never enter that code path under the Rust backend; if a langchain-ai#7 test exercises parallel-write semantics that the serial Rust order doesn't match, that's a langchain-ai#7 widening target too. - Topic / NamedBarrier / DeltaChannel runtime paths. The ``build_channel_kind`` dispatch covers all 10 stdlib classes, but only ``LastValue``-class channels are exercised by the smoke tests; the others land in langchain-ai#7's full gate. Parity gate ----------- - ``parity/scripts/test_backend_activation.py`` — 12 tests (10 #6b activation + replacement + 2 #6c smoke). All pass with ``LANGGRAPH_BACKEND=rust`` and ``_install_monkeypatches()`` active. - All pre-existing parity gates remain green (no env var, no monkeypatch, upstream Python loop unchanged). What the gate caught -------------------- 1. ``langgraph.pregel._loop.SyncPregelLoop`` import-site coverage: already nailed in #6b but worth re-pinning — without patching ``langgraph.pregel.main`` too, ``Pregel.stream``'s ``with SyncPregelLoop(...)`` constructor reads the local module reference, not the canonical one. 2. ``py_result_to_writes`` in ``python_node.rs`` extracts ``(String, Bound<PyAny>)`` tuples — Python lists don't match. The wrapper had to return tuples not lists. ``TypeError: 'list' object is not an instance of 'tuple'`` was the catch. 3. The conditional-edge ``Branch._route`` calls ``reader(config)`` which dereferences ``config[CONF][CONFIG_KEY_READ]``. Without that key the call dies with ``RuntimeError: Not configured with a read function`` from upstream's ``do_read``. Wired a local- state-shadow reader into the wrapper config. 4. ``PregelLoop`` fields are ``pub(crate)`` — the bridge crate needs read accessors. Added ``step()`` / ``stop()`` / ``iter_channels()`` on the loop with explicit doc-comments linking to #6c. 5. ``checkpoint_value`` is the trait method, not ``checkpoint_json`` (the latter doesn't exist). Caught at compile. Test counts ----------- - Cargo workspace: 220 passed; clippy clean. - Phase 0: 73/73 corpus + 49 allowlist + strict reject; 58 conformance pass / 0 fail. - Phase 1 + 1.2b foundation + #4c + langchain-ai#5 + #6a + #6b + #6c: 141 passed (+2 vs #6b: ``test_two_node_chain_round_trips_through_rust`` and ``test_conditional_fork_round_trips_through_rust``).
…nly gate green Closes the combined Step 1.2b + 1.3 milestone. The ``LANGGRAPH_BACKEND=rust`` filter on ``libs/langgraph/tests/test_pregel.py`` matches **81 tests** (the handoff's "87" estimate was written before the test set drifted; the ``-k "memory and not streaming and not interrupt and not subgraph and not send"`` filter is verbatim). All 81 pass on first run after sub-step #6c landed — no triage iteration was needed. What landed ----------- - ``parity/scripts/run_87_test_gate.sh`` — runnable wrapper that sets ``NO_DOCKER=true`` (skips redis/postgres fixtures the bridge venv doesn't carry) and ``LANGGRAPH_BACKEND=rust``, points pytest at the filter, and forwards extra args. Single command for re-running the gate locally. - ``rust/ffi/langgraph-py/pyproject.toml`` — added ``[gate-87]`` dependency-group capturing the four collection-time deps the upstream conftest pulls in (``redis``, ``pytest-mock``, ``syrupy``, ``pycryptodome``). The bridge venv was missing these because the set is what ``libs/langgraph/.venv`` carries for its own test suite, not what the bridge needs for codec parity. Documenting in the dependency-group keeps the install command self-describing (``uv pip install --group gate-87``). - ``rust/docs/phase-1-followups.md`` — entry langchain-ai#3 (async PyO3 bridge + ``LANGGRAPH_BACKEND=rust`` wiring) marked **closed**. Added the amendment note that the implementation chose subclass + override for ``_RustSyncPregelLoop`` (rather than the literal stand-alone duck-typed shadow class the prose example sketched), with the rationale matching the design discussion at the start of #6b. The async surface stays deferred — ``_RustAsyncPregelLoop`` raises at ``__aenter__`` until a phase that needs streaming / ``astream`` parity owns it. Bridge-venv setup deltas (one-time, since this commit) ------------------------------------------------------ - ``redis``, ``pytest-mock``, ``syrupy``, ``pycryptodome`` installed via the new ``gate-87`` dependency group. - ``libs/checkpoint-sqlite`` and ``libs/checkpoint-postgres`` installed in editable mode so the conftest can import ``langgraph.cache.sqlite``. (The other libs were already editable-installed by Phase 0.) Parity gate (the milestone gate) -------------------------------- :: NO_DOCKER=true LANGGRAPH_BACKEND=rust \ rust/ffi/langgraph-py/.venv/bin/python -m pytest \ libs/langgraph/tests/test_pregel.py \ -k "memory and not streaming and not interrupt and not subgraph and not send" Result: ``81 passed, 376 deselected in 9.98s``. Sanity check confirmed the Rust runtime is genuinely driving the loop (not a silent fallback to upstream Python): instrumenting ``run_pregel_loop_topology`` with a call counter shows it's invoked on every ``graph.invoke`` under ``LANGGRAPH_BACKEND=rust``, both ``langgraph.pregel._loop`` and ``langgraph.pregel.main`` namespaces resolve ``SyncPregelLoop`` to ``_RustSyncPregelLoop``, and ``backend.is_active()`` returns ``True``. What the gate caught -------------------- Nothing. The 81 tests passed on first run after the bridge wheel was rebuilt with #6c and the bridge venv had its collection-time deps installed. The handoff explicitly warned to "expect failures to send you back to #4c / langchain-ai#5 / langchain-ai#6 for incremental fixes"; that budget went unused. Plausible reasons: 1. The four channel-translation rejection sites (``CONFIG_KEY_READ`` shadow, panic-stub binop, custom-channel gate, async ``__aenter__``) cleanly cover the corners that would have been the most likely failure surfaces. The 87-test filter ``-k`` excludes the patterns those rejections would trip on (``streaming``, ``interrupt``, ``subgraph``, ``send``). 2. The local-state shadow ``CONFIG_KEY_READ`` reader the wrapper provides is enough for every conditional-edge test in the filter — none of them read channels the routing node didn't write. 3. The translation surface from sub-step #4c (1,200+ hypothesis iterations across the 10 stdlib channel classes) was already verified, so the per-class state encoding round-trips cleanly under load. Test counts ----------- - Cargo workspace: 220 passed; clippy clean. - Phase 0: 73/73 corpus + 49 allowlist + strict reject; 58 conformance pass / 0 fail. - Phase 1 + 1.2b foundation + #4c + langchain-ai#5 + #6a + #6b + #6c (LANGGRAPH_BACKEND unset): 141 passed. - **Combined Step 1.2b + 1.3 milestone gate (LANGGRAPH_BACKEND=rust): 81 passed / 0 failed.**
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the combined Step 1.2b + 1.3 milestone of the parity-verified Rust port of
libs/langgraph. The Rust runtime now drives the upstream Pregel loop end-to-end viaLANGGRAPH_BACKEND=rust, and the milestone's parity gate — the memory-only filter onlibs/langgraph/tests/test_pregel.py— runs 81 passed, 0 failed.The PR diff covers everything since the frozen Python baseline (
63d86116, taggedrust-port-baseline-2026-04-30):DeltaChannel(channel-only),NodeErrorpath, Python-callback runner (4 commits)StateGraphcompilerlanggraph_rs.backendmonkeypatch (subclass + override_RustSyncPregelLoop), Rust runtime bridge entry point (run_pregel_loop_topology)Comprehensive context per sub-step lives in
rust/docs/STEP-1.2B-FINAL-HANDOFF.md(most recent), with prior handoffs preserved asSTEP-1.2B-PARTIAL-HANDOFF.md(superseded but retained for #4c / langchain-ai#5 context) andSTEP-1.2A-HANDOFF.md(Pregel core, still authoritative).Architecture highlights
Subclass + override for
_RustSyncPregelLoop(decided 2026-05-06 over the literal stand-alone duck-typed shadow): inherits upstream'sBackgroundExecutor/ExitStack/ lifecycle event queue / status / checkpoint persistence machinery unchanged. Override sites are__enter__(channel validation + Rust state seed) andtick(run-to-completion viarun_pregel_loop_topology). Approach A's distinguishing property — Rust state lives across the whole graph execution; no per-tick bidirectional re-sync — is preserved by syncing only at__enter__and__exit__.Auditable rejection sites at the backend boundary (
RustBackendUnsupportedraised at__enter__with explicit error messages):interrupt_before/interrupt_after.valuesandupdates.AsyncPregelLoop) — the symbol is replaced symmetrically but raises at__aenter__.Subgraphs and
Sendare filtered out of the 87-test gate via-k, so they're not actively exercised; widening rejection there is a follow-up.REPLACED_SYMBOLSat the top ofpython/langgraph_rs/backend.pyis the auditable diff against upstream — bothlanggraph.pregel._loop.{Sync,Async}PregelLoopANDlanggraph.pregel.main.{Sync,Async}PregelLoopget patched (the second pair is load-bearing becausepregel/main.pyimports the classes directly).Wire format (msgpack via
ormsgpack↔rmp-serde) for channel state translation, matching the locked architectural decision inrust/docs/phase-1-followups.mdentry Add cycle monitoring langchain-ai/langgraph#3 §6.Test plan
cargo test --workspace— 220 passedcargo clippy --workspace --all-targets -- -D warnings— cleanLANGGRAPH_BACKEND=rust): 81 passed / 0 failed — runnable viaparity/scripts/run_87_test_gate.shThe "87" in the milestone name is the prose estimate from the original handoff; the live
-k "memory and not streaming and not interrupt and not subgraph and not send"filter collects 81 in the current test set. Either way: 100% pass rate.What's deferred (tracked in
rust/docs/phase-N-followups.md)BinaryOperatorAggregate— Rust uses the panic-stub; folds run via the Python node callback.CONFIG_KEY_READ— wrapper provides a local-state shadow keyed off the node's read input + captured writes. None of the 81 tests hit cross-channel reads, but it's a known V0.1 limit.AsyncPregelLoop— symbol replaced symmetrically, but__aenter__raises. Async parity is a planned follow-up.Branch posture
The branch stays open after this PR for ongoing port work (Steps 1.4 / 1.5 / 1.6 + Phase 2). The PR is intended for review of the Step 1.2b + 1.3 milestone in GitHub's UI; merging is at the maintainer's discretion.