diff --git a/CHANGELOG.md b/CHANGELOG.md index 9094164..3418cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **Cross-component `stream` pairing detection** (issue #141, + ADR-3, `meld-core/src/p3_stream.rs`). Foundation for the in-module + stream adapter: when meld fuses two components that share a + `stream` endpoint, today both sides still route every chunk + through host `pulseengine:async` imports. This PR adds the + detection half — a `StreamPairGraph` built at resolve time that + inventories which fused components form producer → consumer stream + pairings. The graph is attached to `DependencyGraph` next to the + resource graph. A `StreamPair` is a conservative *candidate*: it is + recorded only when two fusion-connected components have + complementary roles (one `StreamWrite`, one `StreamRead`) on a + stream of the **same element type** — pairing on matching element + type is the line between an honest candidate and a hallucinated + one. The ring-buffer (same-memory) and copy-chain (cross-memory) + adapter *emitter* is a runtime-verified follow-up (ADR-3 Path N); + cross-component stream codegen is only correct once executed on a + real runtime, so it is deliberately not in this PR. ADR-3 records + the design; SR-33's detection half is satisfied here, the codegen + half by the follow-up. 9 unit tests including the 4 ADR-3 gating + fixtures. Registering `p3_stream.rs` in the Mythos Tier-5 path + lists is a separate follow-up — claude-code-action self-validates + that the invoking workflow matches `main`, so a PR cannot both + modify `mythos-auto.yml` and be scanned by it. + ### Changed - **CI workflows now skip on docs/safety-only changes** diff --git a/meld-core/src/lib.rs b/meld-core/src/lib.rs index a3e2d76..c0ef26c 100644 --- a/meld-core/src/lib.rs +++ b/meld-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod component_wrap; mod error; pub mod merger; pub mod p3_async; +pub mod p3_stream; pub mod parser; pub mod resolver; pub mod resource_graph; diff --git a/meld-core/src/merger.rs b/meld-core/src/merger.rs index 07585ba..308e1ef 100644 --- a/meld-core/src/merger.rs +++ b/meld-core/src/merger.rs @@ -3692,6 +3692,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -3906,6 +3907,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -3958,6 +3960,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4018,6 +4021,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4069,6 +4073,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4124,6 +4129,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4256,6 +4262,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4414,6 +4421,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ @@ -4565,6 +4573,7 @@ mod tests { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), unresolved_imports: vec![ diff --git a/meld-core/src/p3_stream.rs b/meld-core/src/p3_stream.rs new file mode 100644 index 0000000..7802136 --- /dev/null +++ b/meld-core/src/p3_stream.rs @@ -0,0 +1,430 @@ +//! Cross-component `stream` pairing detection — ADR-3, issue #141. +//! +//! When meld fuses two components that share a `stream` end-to-end +//! (one holds the writable end, the other the readable end), both sides +//! today still lower their stream operations to host imports under +//! `pulseengine:async` (ADR-1). Every chunk crosses the host boundary +//! twice even though both ends now live in the merged module. +//! +//! This module is the **detection foundation** for the in-module stream +//! adapter (ADR-3, Path N). It builds a [`StreamPairGraph`]: the +//! merge-time inventory of which fused components form producer → +//! consumer stream pairings. The adapter *emitter* — the ring-buffer +//! (same-memory) and copy-chain (cross-memory) wasm codegen — is a +//! runtime-verified follow-up that consumes this graph; it is not in +//! this module. +//! +//! `stream` data flow is inherently runtime — `stream.new` mints the +//! handle pair at runtime. What is *static* is the pairing: the +//! resolver knows component A's `stream`-bearing export resolved to +//! component B's import. The detection here keys off that static fact +//! plus each component's canonical stream operations. +//! +//! ## Precision boundary +//! +//! A [`StreamPair`] is a **candidate** pairing, not a proof that two +//! endpoints carry the same runtime handle (unknowable at build time). +//! It is recorded only when two fusion-connected components have +//! complementary roles — one writes, one reads — on a stream of the +//! **same element type**. Pairing only on matching element type is the +//! line between an honest candidate and a hallucinated one: a +//! `stream` and a `stream` between the same two components are +//! two different streams. See ADR-3. + +use crate::parser::{CanonicalEntry, ComponentTypeKind, ParsedComponent}; +use std::collections::HashMap; + +/// The element type carried by a `stream`, parsed from the +/// component-type descriptor the parser records. +/// +/// The parser stores stream types as +/// [`ComponentTypeKind::P3Async`] descriptor strings such as +/// `"stream"` or bare `"stream"`. Element types are compared by +/// descriptor string, never by component-local type index — index 5 in +/// component A and index 5 in component B are unrelated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamElement { + /// `stream` with a concrete element descriptor (the raw text + /// between the angle brackets, e.g. `"U8"`). + Typed(String), + /// Bare `stream` with no element type. + Untyped, +} + +impl StreamElement { + /// Parse from a [`ComponentTypeKind::P3Async`] descriptor. + /// + /// Returns `None` if the descriptor is not a stream (e.g. a + /// `future<...>` or `map<...>` descriptor). `strip_suffix` removes + /// exactly one `>`, so nested descriptors like `stream>` + /// parse to the element `list`. + pub fn from_descriptor(desc: &str) -> Option { + let desc = desc.trim(); + if desc == "stream" { + return Some(StreamElement::Untyped); + } + let inner = desc.strip_prefix("stream<")?.strip_suffix('>')?; + Some(StreamElement::Typed(inner.trim().to_string())) + } +} + +/// A component's role on a particular stream element type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamRole { + /// Declares [`CanonicalEntry::StreamWrite`] — writes the stream. + Producer, + /// Declares [`CanonicalEntry::StreamRead`] — reads the stream. + Consumer, +} + +/// Whether a fused pair's two endpoints share a linear memory. +/// +/// Mirrors the synchronous-data multi-memory / shared-memory split. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamMemoryMode { + /// `MemoryStrategy::SharedMemory` — ring-buffer adapter, zero-copy. + SameMemory, + /// `MemoryStrategy::MultiMemory` — `stream_read` → copy → `stream_write`. + CrossMemory, +} + +/// One endpoint of a detected cross-component stream pairing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamEndpoint { + /// Index of the component into the fused `&[ParsedComponent]` slice. + pub component: usize, + /// Producer or consumer. + pub role: StreamRole, +} + +/// A detected cross-component stream pairing: one producer, one +/// consumer, fusion-connected, carrying the same element type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamPair { + /// The writing endpoint. + pub producer: StreamEndpoint, + /// The reading endpoint. + pub consumer: StreamEndpoint, + /// Element type both endpoints carry (equal by construction — the + /// builder only pairs matching element types). + pub element: StreamElement, + /// Whether the two endpoints share a memory. + pub mode: StreamMemoryMode, +} + +/// The merge-time inventory of cross-component stream pairings. +/// +/// Attached to `DependencyGraph` next to the resource graph. The +/// adapter emitter (ADR-3 follow-up) and issue #142's static validation +/// both consume it. +#[derive(Debug, Clone, Default)] +pub struct StreamPairGraph { + /// All detected candidate pairings. + pub pairs: Vec, +} + +impl StreamPairGraph { + /// `true` if no cross-component stream pairings were detected. + pub fn is_empty(&self) -> bool { + self.pairs.is_empty() + } +} + +/// Extract a component's `(element type, role)` pairs from its +/// canonical stream operations. +/// +/// `CanonicalEntry::StreamWrite` ⇒ the component is a producer for that +/// stream's element type; `StreamRead` ⇒ a consumer. A component can be +/// both (a pipe). `StreamNew` / `StreamCancel*` / `StreamDrop*` carry no +/// producer/consumer signal and are ignored. Duplicates are collapsed. +pub fn component_stream_roles(comp: &ParsedComponent) -> Vec<(StreamElement, StreamRole)> { + let mut out: Vec<(StreamElement, StreamRole)> = Vec::new(); + for entry in &comp.canonical_functions { + let (ty, role) = match entry { + CanonicalEntry::StreamWrite { ty, .. } => (*ty, StreamRole::Producer), + CanonicalEntry::StreamRead { ty, .. } => (*ty, StreamRole::Consumer), + _ => continue, + }; + let Some(element) = stream_element_of_type(comp, ty) else { + continue; + }; + let key = (element, role); + if !out.contains(&key) { + out.push(key); + } + } + out +} + +/// Resolve a component-local type index to its stream element type, or +/// `None` if the index does not name a `stream` type. +fn stream_element_of_type(comp: &ParsedComponent, ty: u32) -> Option { + match &comp.types.get(ty as usize)?.kind { + ComponentTypeKind::P3Async(desc) => StreamElement::from_descriptor(desc), + _ => None, + } +} + +/// Derive the unordered set of fusion-connected component pairs from the +/// resolver's `resolved_imports` map. +/// +/// Two components are fusion-connected if any resolved import links +/// them. Self-links (`importer == exporter`) are dropped; each unordered +/// pair appears once. +pub fn fusion_connections( + resolved_imports: &HashMap<(usize, String), (usize, String)>, +) -> Vec<(usize, usize)> { + let mut connected: Vec<(usize, usize)> = Vec::new(); + for ((importer, _), (exporter, _)) in resolved_imports { + if importer == exporter { + continue; + } + let pair = if importer < exporter { + (*importer, *exporter) + } else { + (*exporter, *importer) + }; + if !connected.contains(&pair) { + connected.push(pair); + } + } + connected.sort_unstable(); + connected +} + +/// Pure pairing logic — the unit the ADR-3 gating fixtures pin. +/// +/// `roles[c]` is component `c`'s `(element, role)` list (from +/// [`component_stream_roles`]). `connections` is the unordered +/// fusion-connected pairs (from [`fusion_connections`]). A +/// [`StreamPair`] is emitted for every connected `(producer, consumer)` +/// component pair that shares a stream element type — in both +/// directions, since either component of a connected pair may be the +/// producer. +pub fn pair_streams( + roles: &[Vec<(StreamElement, StreamRole)>], + connections: &[(usize, usize)], + mode: StreamMemoryMode, +) -> Vec { + let mut pairs: Vec = Vec::new(); + for &(a, b) in connections { + // Either endpoint of the connected pair may hold the writable + // end, so try both directions. + for &(producer_c, consumer_c) in &[(a, b), (b, a)] { + let (Some(producer_roles), Some(consumer_roles)) = + (roles.get(producer_c), roles.get(consumer_c)) + else { + continue; + }; + for (p_elem, p_role) in producer_roles { + if *p_role != StreamRole::Producer { + continue; + } + for (c_elem, c_role) in consumer_roles { + if *c_role != StreamRole::Consumer { + continue; + } + // Honest candidate only when element types match — + // see the ADR-3 precision boundary. + if p_elem != c_elem { + continue; + } + let candidate = StreamPair { + producer: StreamEndpoint { + component: producer_c, + role: StreamRole::Producer, + }, + consumer: StreamEndpoint { + component: consumer_c, + role: StreamRole::Consumer, + }, + element: p_elem.clone(), + mode, + }; + if !pairs.contains(&candidate) { + pairs.push(candidate); + } + } + } + } + } + pairs +} + +/// Build the [`StreamPairGraph`] for a set of fused components. +/// +/// Pure function over the parsed components, the resolver's +/// `resolved_imports` map, and the chosen memory mode. Does not mutate +/// anything. +pub fn build_stream_pair_graph( + components: &[ParsedComponent], + resolved_imports: &HashMap<(usize, String), (usize, String)>, + mode: StreamMemoryMode, +) -> StreamPairGraph { + let roles: Vec> = + components.iter().map(component_stream_roles).collect(); + let connections = fusion_connections(resolved_imports); + StreamPairGraph { + pairs: pair_streams(&roles, &connections, mode), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn typed(s: &str) -> StreamElement { + StreamElement::Typed(s.to_string()) + } + + #[test] + fn from_descriptor_parses_typed_untyped_and_rejects_non_stream() { + assert_eq!( + StreamElement::from_descriptor("stream"), + Some(typed("U8")) + ); + assert_eq!( + StreamElement::from_descriptor("stream"), + Some(StreamElement::Untyped) + ); + // Nested element type — strip_suffix removes exactly one '>'. + assert_eq!( + StreamElement::from_descriptor("stream>"), + Some(typed("list")) + ); + // Not a stream. + assert_eq!(StreamElement::from_descriptor("future"), None); + assert_eq!(StreamElement::from_descriptor("map"), None); + } + + #[test] + fn fusion_connections_dedups_and_drops_self_links() { + let mut resolved: HashMap<(usize, String), (usize, String)> = HashMap::new(); + // 0 imports from 1 (two different functions — one connection). + resolved.insert((0, "f".into()), (1, "f".into())); + resolved.insert((0, "g".into()), (1, "g".into())); + // 2 imports from 0. + resolved.insert((2, "h".into()), (0, "h".into())); + // Self-link — must be dropped. + resolved.insert((3, "k".into()), (3, "k".into())); + + let conns = fusion_connections(&resolved); + assert_eq!(conns, vec![(0, 1), (0, 2)]); + } + + /// ADR-3 gating fixture: a producer component and a consumer + /// component linked by a resolved import yield exactly one + /// `StreamPair` with the correct roles and shared element type. + #[test] + fn stream_pair_detected_for_connected_producer_consumer() { + // Component 0 produces stream; component 1 consumes it. + let roles = vec![ + vec![(typed("U8"), StreamRole::Producer)], + vec![(typed("U8"), StreamRole::Consumer)], + ]; + let pairs = pair_streams(&roles, &[(0, 1)], StreamMemoryMode::CrossMemory); + assert_eq!(pairs.len(), 1, "exactly one pair expected; got {pairs:?}"); + let p = &pairs[0]; + assert_eq!(p.producer.component, 0); + assert_eq!(p.producer.role, StreamRole::Producer); + assert_eq!(p.consumer.component, 1); + assert_eq!(p.consumer.role, StreamRole::Consumer); + assert_eq!(p.element, typed("U8")); + assert_eq!(p.mode, StreamMemoryMode::CrossMemory); + } + + /// ADR-3 gating fixture: a producer and a consumer of the same + /// stream element type that are NOT linked by a resolved import + /// yield no pair. + #[test] + fn no_pair_when_components_not_fusion_connected() { + let roles = vec![ + vec![(typed("U8"), StreamRole::Producer)], + vec![(typed("U8"), StreamRole::Consumer)], + ]; + // No connections at all. + let pairs = pair_streams(&roles, &[], StreamMemoryMode::CrossMemory); + assert!(pairs.is_empty(), "unconnected components must not pair"); + } + + /// ADR-3 gating fixture: two connected components that both only + /// produce (or both only consume) a stream yield no pair. + #[test] + fn no_pair_without_producer_consumer_complementarity() { + // Both components are producers — no consumer end. + let both_produce = vec![ + vec![(typed("U8"), StreamRole::Producer)], + vec![(typed("U8"), StreamRole::Producer)], + ]; + assert!( + pair_streams(&both_produce, &[(0, 1)], StreamMemoryMode::CrossMemory).is_empty(), + "two producers must not pair" + ); + + // Both components are consumers. + let both_consume = vec![ + vec![(typed("U8"), StreamRole::Consumer)], + vec![(typed("U8"), StreamRole::Consumer)], + ]; + assert!( + pair_streams(&both_consume, &[(0, 1)], StreamMemoryMode::CrossMemory).is_empty(), + "two consumers must not pair" + ); + } + + /// ADR-3 gating fixture: the recorded memory mode follows the + /// caller-supplied strategy. + #[test] + fn memory_mode_follows_strategy() { + let roles = vec![ + vec![(typed("U8"), StreamRole::Producer)], + vec![(typed("U8"), StreamRole::Consumer)], + ]; + let same = pair_streams(&roles, &[(0, 1)], StreamMemoryMode::SameMemory); + assert_eq!(same[0].mode, StreamMemoryMode::SameMemory); + let cross = pair_streams(&roles, &[(0, 1)], StreamMemoryMode::CrossMemory); + assert_eq!(cross[0].mode, StreamMemoryMode::CrossMemory); + } + + #[test] + fn mismatched_element_types_do_not_pair() { + // Connected producer of stream + consumer of stream: + // two different streams, not a pair. The honest-candidate rule. + let roles = vec![ + vec![(typed("U8"), StreamRole::Producer)], + vec![(typed("S32"), StreamRole::Consumer)], + ]; + assert!( + pair_streams(&roles, &[(0, 1)], StreamMemoryMode::CrossMemory).is_empty(), + "stream and stream are different streams — no pair" + ); + } + + #[test] + fn bidirectional_pipe_pairs_in_both_directions() { + // Component 0 produces AND consumes; component 1 produces AND + // consumes; connected. Two distinct pairings: 0→1 and 1→0. + let roles = vec![ + vec![ + (typed("U8"), StreamRole::Producer), + (typed("U8"), StreamRole::Consumer), + ], + vec![ + (typed("U8"), StreamRole::Producer), + (typed("U8"), StreamRole::Consumer), + ], + ]; + let pairs = pair_streams(&roles, &[(0, 1)], StreamMemoryMode::SameMemory); + assert_eq!(pairs.len(), 2, "pipe pairs both directions; got {pairs:?}"); + assert!( + pairs + .iter() + .any(|p| p.producer.component == 0 && p.consumer.component == 1) + ); + assert!( + pairs + .iter() + .any(|p| p.producer.component == 1 && p.consumer.component == 0) + ); + } +} diff --git a/meld-core/src/resolver.rs b/meld-core/src/resolver.rs index ce724c1..8ca44a1 100644 --- a/meld-core/src/resolver.rs +++ b/meld-core/src/resolver.rs @@ -30,6 +30,13 @@ pub struct DependencyGraph { /// which resource and how handles should be routed. pub resource_graph: Option, + /// Cross-component `stream` pairings detected at merge time + /// (ADR-3, issue #141). The detection foundation populates this; the + /// in-module stream adapter emitter (ADR-3 follow-up) and issue + /// #142's static stream validation consume it. `None` until the + /// resolver runs stream-pair detection. + pub stream_pair_graph: Option, + /// Module-level resolution within components pub module_resolutions: Vec, @@ -1412,6 +1419,7 @@ impl Resolver { adapter_sites: Vec::new(), module_resolutions: Vec::new(), resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), }; @@ -1459,6 +1467,19 @@ impl Resolver { components, )); + // Build the cross-component stream-pair graph (ADR-3, #141). + // Detection foundation only — the in-module stream adapter + // emitter that consumes this is a runtime-verified follow-up. + let stream_mode = match self.memory_strategy { + MemoryStrategy::SharedMemory => crate::p3_stream::StreamMemoryMode::SameMemory, + MemoryStrategy::MultiMemory => crate::p3_stream::StreamMemoryMode::CrossMemory, + }; + graph.stream_pair_graph = Some(crate::p3_stream::build_stream_pair_graph( + components, + &graph.resolved_imports, + stream_mode, + )); + // Identify adapter sites (cross-component) self.identify_adapter_sites(components, &mut graph)?; @@ -4948,6 +4969,7 @@ mod tests { }, ], resource_graph: None, + stream_pair_graph: None, reexporter_components: Vec::new(), reexporter_resources: Vec::new(), }; diff --git a/safety/adr/ADR-3-cross-component-stream-adapter.md b/safety/adr/ADR-3-cross-component-stream-adapter.md new file mode 100644 index 0000000..dda8ac6 --- /dev/null +++ b/safety/adr/ADR-3-cross-component-stream-adapter.md @@ -0,0 +1,176 @@ +--- +id: ADR-3 +type: design-question +title: Cross-component stream adapter — fusing paired stream endpoints at merge time +status: open +gating-fixtures: + - p3_stream::stream_pair_detected_for_connected_producer_consumer + - p3_stream::no_pair_when_components_not_fusion_connected + - p3_stream::no_pair_without_producer_consumer_complementarity + - p3_stream::memory_mode_follows_strategy +design-paths: + - N — Detection foundation now, runtime-verified emitter follow-up (chosen, this PR) + - O — Full ring-buffer / copy-chain emitter in a single PR (rejected — cross-component stream codegen cannot be runtime-verified at foundation time) + - P — Keep host routing, never fuse stream endpoints (rejected — every fused stream pays a host round-trip even when both ends live in the merged module) +--- + +# ADR-3 — Cross-component `stream` adapter + +## Context + +ADR-1 fixed the host-intrinsic ABI: every component's `stream` / +`future` operations lower to imports under module +`pulseengine:async`. ADR-2 fixed the error / backpressure conventions +those intrinsics follow. + +Both ADRs treat each component in isolation. They leave a gap that +issue #94's audit (deliverable A) and issue #141 call out: + +> When two fused components share a `stream` end-to-end — one holds +> the writable end, the other the readable end — meld still lowers both +> sides to *host* imports. Every byte crosses the host boundary twice +> (a `stream_write` on the producer, a `stream_read` on the consumer) +> even though both ends now live in the same merged module. + +This is the stream-plane analogue of the synchronous-data problem meld +already solves: when component A calls component B's exported function, +meld emits an in-module adapter rather than routing the call through +the host. The same should hold for a `stream` that A produces and B +consumes. + +`stream` is *runtime* data flow — `stream.new` mints the handle pair +at runtime, and which bytes flow when is not statically knowable. But +the **pairing** is static: the resolver already knows that component +A's `stream`-bearing export was resolved to component B's import. +That static pairing is what an in-module stream adapter keys off. + +## Decision + +**Path N — land the detection foundation now; land the emitter as a +runtime-verified follow-up.** + +This PR ships everything that can be proven correct with unit tests and +no wasm runtime: + +1. A new `meld-core/src/p3_stream.rs` module (sibling to `p3_async.rs`) + with the data model and a pure detection function. +2. `StreamPairGraph` — the merge-time inventory of cross-component + stream pairings, attached to `DependencyGraph` next to the existing + `resource_graph`. +3. The detection itself: a pure function over + `(&[ParsedComponent], &resolved_imports, MemoryStrategy)`. + +The **emitter** — the wasm codegen for the two adapter shapes below — +is deferred to a follow-up PR. Cross-component stream codegen is data- +plane wasm that is only *correct* if a real P3 component runs through +it on kiln / wasmtime. Landing emitter code that compiles and passes +structural byte-pattern assertions but has never executed would put an +unverified data-plane transform into the merged module — precisely the +H-1 (semantic divergence) / H-3 (misrouted call) hazard class meld's +Mythos and LS-N gates exist to prevent. The repo already shipped P3 in +exactly this staged shape: #124 was the lowering *foundation*, #129 the +full lowering *pass*. + +### Why not Path O + +A single PR carrying the full emitter would be unreviewable against the +"trust but verify" bar: neither the author nor a reviewer can confirm +the emitted ring buffer or copy chain executes correctly without a +cross-repo runtime loop that does not exist yet. The foundation/emitter +split lets the emitter PR be small, focused, and paired with the +runtime fixture that proves it. + +### Why not Path P + +Leaving every fused stream on host routing means a `stream` passed +between two components that meld has already merged into one module +still costs a host `stream_write` + host `stream_read` per chunk. That +is the cost fusion exists to remove. Path P is the status quo and the +thing #141 is filed to change. + +## The two adapter shapes (emitter follow-up — documented here so the +## foundation records the right data) + +The emitter mirrors the existing synchronous multi-memory / +shared-memory split (`generate_direct_adapter` vs +`generate_memory_copy_adapter`): + +| Mode | Trigger | Adapter shape | +|---|---|---| +| **Same memory** | `MemoryStrategy::SharedMemory` | Direct ring buffer. Producer and consumer share an in-module queue page; the producer's `stream_write` and the consumer's `stream_read` become ring-buffer cursor operations. Zero-copy. | +| **Cross memory** | `MemoryStrategy::MultiMemory` | `stream_read` from the producer's memory → in-module copy loop → `stream_write` into the consumer's memory. `cabi_realloc`-style allocation on the receiver side. Identical null-guard policy as LS-A-7 (a null realloc result aborts the copy rather than writing through a null pointer). | + +Both must preserve the ADR-2 backpressure and EOF contracts end to +end: a `Partial` write on the consumer side propagates back as +backpressure to the producer; an `Eof` from the producer propagates +as a clean stream close to the consumer. + +For the foundation to feed the emitter, `StreamPair` records each +endpoint's component index and role (producer / consumer), the shared +parsed element type, and the memory mode. + +## What the foundation detects — and its precision boundary + +The detection is deliberately conservative. A `StreamPair` is recorded +only when **all** hold: + +1. Two distinct components are *fusion-connected* — there is a + `resolved_imports` entry linking them (in either direction). +2. One component has a **producer** role on a stream — it declares a + `CanonicalEntry::StreamWrite { ty }` whose `ty` resolves to a + `stream` component type. +3. The other has a **consumer** role — a `CanonicalEntry::StreamRead` + on a stream carrying the **same element type** T. + +The detection does **not** prove the two endpoints carry the *same +runtime handle* — that is unknowable at build time. It proves the +weaker, sufficient-for-fusion fact: two merged, connected components +where one writes and one reads a stream of the same element type. Each +recorded `StreamPair` is therefore a *candidate* pairing the emitter +follow-up (and #142) refines with signature-level matching. + +Pairing on matching element type — rather than any producer with any +consumer — is the line between an honest candidate and a hallucinated +one: a `stream` and a `stream` between the same two +components are unambiguously two *different* streams, and recording +that as a pair would be a finding meld's Mythos protocol would reject +("hallucinations are more expensive than silence"). Issue #142's +check (i) (`stream` resolved to `stream`, A ≠ B) operates at the +finer signature granularity #142 itself builds; the foundation does +not manufacture cross-type pairs for it to reject. + +Stream element-type indices (`CanonicalEntry::Stream*{ty}`) are +component-local; the foundation matches endpoints by parsed element +type *descriptor*, never by raw index, because component A's type +index 5 and component B's type index 5 are unrelated. + +## Out of scope (this PR) + +- The ring-buffer and copy-chain **emitter** — follow-up, runtime-verified. +- `future` pair fusion — `future` is the degenerate single-value + stream; the same graph generalises but the emitter shape differs. +- Static validation of the detected pairs (type compatibility, capacity, + cycles, lifetime) — that is issue #142, which consumes this graph. + +## Rivet artifact + +New requirement **SR-33** — "Cross-component `stream` fusion at +merge time". The foundation satisfies the detection half; the emitter +follow-up satisfies the codegen half. + +## Test fixtures + +The `gating-fixtures` above pin the foundation's contract: + +- `stream_pair_detected_for_connected_producer_consumer` — a producer + component and a consumer component linked by a resolved import yield + exactly one `StreamPair` with the correct producer/consumer roles + and shared element type. +- `no_pair_when_components_not_fusion_connected` — a producer and a + consumer of the same stream element type that are *not* linked by a + resolved import yield no pair. +- `no_pair_without_producer_consumer_complementarity` — two connected + components that both only produce (or both only consume) a stream + yield no pair. +- `memory_mode_follows_strategy` — `SharedMemory` ⇒ `SameMemory`, + `MultiMemory` ⇒ `CrossMemory`.