Skip to content

Rust port: Step 1.2b + 1.3 milestone — 81/81 memory gate green#1

Open
trianglegrrl wants to merge 28 commits intomainfrom
rust-port
Open

Rust port: Step 1.2b + 1.3 milestone — 81/81 memory gate green#1
trianglegrrl wants to merge 28 commits intomainfrom
rust-port

Conversation

@trianglegrrl
Copy link
Copy Markdown
Owner

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 via LANGGRAPH_BACKEND=rust, and the milestone's parity gate — the memory-only filter on libs/langgraph/tests/test_pregel.py — runs 81 passed, 0 failed.

The PR diff covers everything since the frozen Python baseline (63d86116, tagged rust-port-baseline-2026-04-30):

  • Phase 0 — msgpack codec + conformance (10 commits)
  • Phase 1 Step 1.1 — channel types
  • Phase 1 Step 1.2a — Pregel core + differential gate
  • Phase 1 Step 1.2b foundation — async PyO3 surface, DeltaChannel (channel-only), NodeError path, Python-callback runner (4 commits)
  • Phase 1 Step 1.2b/1.3 sub-steps #4c, Nc/single out message langchain-ai/langgraph#5 — channel translation, StateGraph compiler
  • Phase 1 Step 1.2b/1.3 sub-steps #6a, #6b, #6c — Maturin python-source layout, langgraph_rs.backend monkeypatch (subclass + override _RustSyncPregelLoop), Rust runtime bridge entry point (run_pregel_loop_topology)
  • Phase 1 Step 1.2b/1.3 sub-step Async runnables langchain-ai/langgraph#7 — milestone gate green

Comprehensive context per sub-step lives in rust/docs/STEP-1.2B-FINAL-HANDOFF.md (most recent), with prior handoffs preserved as STEP-1.2B-PARTIAL-HANDOFF.md (superseded but retained for #4c / langchain-ai#5 context) and STEP-1.2A-HANDOFF.md (Pregel core, still authoritative).

Architecture highlights

  1. Subclass + override for _RustSyncPregelLoop (decided 2026-05-06 over the literal stand-alone duck-typed shadow): inherits upstream's BackgroundExecutor / ExitStack / lifecycle event queue / status / checkpoint persistence machinery unchanged. Override sites are __enter__ (channel validation + Rust state seed) and tick (run-to-completion via run_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__.

  2. Auditable rejection sites at the backend boundary (RustBackendUnsupported raised at __enter__ with explicit error messages):

    • Custom user-defined channel classes (anything not in the 10 stdlib set).
    • interrupt_before / interrupt_after.
    • Stream modes outside values and updates.
    • Async (AsyncPregelLoop) — the symbol is replaced symmetrically but raises at __aenter__.

    Subgraphs and Send are filtered out of the 87-test gate via -k, so they're not actively exercised; widening rejection there is a follow-up.

  3. REPLACED_SYMBOLS at the top of python/langgraph_rs/backend.py is the auditable diff against upstream — both langgraph.pregel._loop.{Sync,Async}PregelLoop AND langgraph.pregel.main.{Sync,Async}PregelLoop get patched (the second pair is load-bearing because pregel/main.py imports the classes directly).

  4. Wire format (msgpack via ormsgpackrmp-serde) for channel state translation, matching the locked architectural decision in rust/docs/phase-1-followups.md entry Add cycle monitoring langchain-ai/langgraph#3 §6.

Test plan

  • cargo test --workspace — 220 passed
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • Phase 0 corpus round-trip: 73/73 byte-identical
  • Phase 0 allowlist: 49 entries match Python
  • Phase 0 strict-mode rejection: pass
  • Phase 0 conformance: 58 pass / 0 fail (via Rust shadow serializer)
  • Phase 1 parity (no env var, upstream Python loop): 141 passed
  • Combined Step 1.2b + 1.3 milestone gate (LANGGRAPH_BACKEND=rust): 81 passed / 0 failed — runnable via parity/scripts/run_87_test_gate.sh

The "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)

  • Native binops for BinaryOperatorAggregate — Rust uses the panic-stub; folds run via the Python node callback.
  • Cross-channel 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.
  • Streaming (Step 1.4), encrypted serializer (Step 1.5), hello world (Step 1.6) — next milestones.

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.

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.**
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