diff --git a/src/mir/builder/control_flow/joinir/patterns/condition_env_builder.rs b/src/mir/builder/control_flow/joinir/patterns/condition_env_builder.rs index f0694af6..8a75fefb 100644 --- a/src/mir/builder/control_flow/joinir/patterns/condition_env_builder.rs +++ b/src/mir/builder/control_flow/joinir/patterns/condition_env_builder.rs @@ -28,6 +28,7 @@ use crate::ast::ASTNode; use crate::mir::join_ir::lowering::condition_env::{ConditionBinding, ConditionEnv}; use crate::mir::join_ir::lowering::condition_to_joinir::extract_condition_variables; use crate::mir::join_ir::lowering::inline_boundary_builder::JoinInlineBoundaryBuilder; +use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace; use crate::mir::loop_pattern_detection::function_scope_capture::CapturedEnv; use crate::mir::ValueId; use std::collections::BTreeMap; @@ -125,6 +126,82 @@ impl ConditionEnvBuilder { env } + /// Phase 201: Build ConditionEnv using JoinValueSpace (disjoint ValueId regions) + /// + /// This method uses JoinValueSpace to allocate ValueIds, ensuring that + /// param IDs (100+) never collide with local IDs (1000+) used by JoinIR lowering. + /// + /// # Arguments + /// + /// * `break_condition` - AST node for the break condition + /// * `loop_var_name` - Loop parameter name (excluded from condition-only variables) + /// * `variable_map` - HOST function's variable_map (for looking up HOST ValueIds) + /// * `loop_var_id` - HOST ValueId for the loop parameter + /// * `space` - JoinValueSpace for unified ValueId allocation + /// + /// # Returns + /// + /// Tuple of: + /// - ConditionEnv: Variable name → JoinIR ValueId mapping + /// - Vec: HOST↔JoinIR value mappings for merge + /// - loop_var_join_id: The JoinIR ValueId allocated for the loop parameter + pub fn build_for_break_condition_v2( + break_condition: &ASTNode, + loop_var_name: &str, + variable_map: &BTreeMap, + _loop_var_id: ValueId, + space: &mut JoinValueSpace, + ) -> Result<(ConditionEnv, Vec, ValueId), String> { + // Extract all variables used in the condition (excluding loop parameter) + let condition_var_names = extract_condition_variables( + break_condition, + &[loop_var_name.to_string()], + ); + + let mut env = ConditionEnv::new(); + let mut bindings = Vec::new(); + + // Phase 201: Allocate loop parameter ValueId from JoinValueSpace (Param region) + let loop_var_join_id = space.alloc_param(); + env.insert(loop_var_name.to_string(), loop_var_join_id); + + // For each condition variable, allocate JoinIR-local ValueId and build binding + for var_name in &condition_var_names { + let host_id = variable_map + .get(var_name) + .copied() + .ok_or_else(|| { + format!( + "Condition variable '{}' not found in variable_map. \ + Loop condition references undefined variable.", + var_name + ) + })?; + + // Phase 201: Allocate from Param region to avoid collision with locals + let join_id = space.alloc_param(); + + env.insert(var_name.clone(), join_id); + bindings.push(ConditionBinding { + name: var_name.clone(), + host_value: host_id, + join_value: join_id, + }); + } + + Ok((env, bindings, loop_var_join_id)) + } + + /// Phase 201: Build ConditionEnv with loop parameter only using JoinValueSpace + /// + /// Uses JoinValueSpace to allocate the loop parameter ValueId. + pub fn build_loop_param_only_v2(loop_var_name: &str, space: &mut JoinValueSpace) -> (ConditionEnv, ValueId) { + let mut env = ConditionEnv::new(); + let loop_var_join_id = space.alloc_param(); + env.insert(loop_var_name.to_string(), loop_var_join_id); + (env, loop_var_join_id) + } + /// Build ConditionEnv with optional captured variables (Phase 200-B implementation) /// /// # Phase 200-B Implementation @@ -351,4 +428,65 @@ mod tests { .unwrap_err() .contains("undefined_var")); } + + /// Phase 201: Test that v2 API uses JoinValueSpace correctly + #[test] + fn test_build_for_break_condition_v2_uses_param_region() { + use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace; + + // Condition: i < max + let condition = ASTNode::BinaryOp { + operator: crate::ast::BinaryOperator::Less, + left: Box::new(ASTNode::Variable { + name: "i".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Variable { + name: "max".to_string(), + span: Span::unknown(), + }), + span: Span::unknown(), + }; + + let mut variable_map = BTreeMap::new(); + variable_map.insert("i".to_string(), ValueId(100)); + variable_map.insert("max".to_string(), ValueId(200)); + + let mut space = JoinValueSpace::new(); + let (env, bindings, loop_var_join_id) = ConditionEnvBuilder::build_for_break_condition_v2( + &condition, + "i", + &variable_map, + ValueId(100), + &mut space, + ) + .unwrap(); + + // Phase 201: Loop param should be in Param region (100+) + assert_eq!(loop_var_join_id, ValueId(100)); // First param allocation + assert_eq!(env.get("i"), Some(ValueId(100))); + + // Phase 201: Condition variable should also be in Param region + assert_eq!(env.get("max"), Some(ValueId(101))); // Second param allocation + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].join_value, ValueId(101)); + + // Phase 201: Verify no collision with Local region (1000+) + let local_id = space.alloc_local(); + assert_eq!(local_id, ValueId(1000)); // Locals start at 1000 + assert_ne!(local_id, loop_var_join_id); + assert_ne!(local_id, bindings[0].join_value); + } + + #[test] + fn test_build_loop_param_only_v2() { + use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace; + + let mut space = JoinValueSpace::new(); + let (env, loop_var_join_id) = ConditionEnvBuilder::build_loop_param_only_v2("i", &mut space); + + // Phase 201: Should use Param region + assert_eq!(loop_var_join_id, ValueId(100)); + assert_eq!(env.get("i"), Some(ValueId(100))); + } } diff --git a/src/mir/builder/control_flow/joinir/patterns/pattern2_with_break.rs b/src/mir/builder/control_flow/joinir/patterns/pattern2_with_break.rs index 8661a022..48748ea8 100644 --- a/src/mir/builder/control_flow/joinir/patterns/pattern2_with_break.rs +++ b/src/mir/builder/control_flow/joinir/patterns/pattern2_with_break.rs @@ -144,6 +144,11 @@ impl MirBuilder { // Phase 195: Use unified trace trace::trace().varmap("pattern2_start", &self.variable_map); + // Phase 201: Create JoinValueSpace for unified ValueId allocation + // This ensures Param IDs (100+) never collide with Local IDs (1000+) + use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace; + let mut join_value_space = JoinValueSpace::new(); + // Phase 200-C: Integrate capture analysis use crate::mir::loop_pattern_detection::function_scope_capture::{analyze_captured_vars_v2, CapturedEnv}; use super::condition_env_builder::ConditionEnvBuilder; @@ -170,21 +175,24 @@ impl MirBuilder { var.name, var.host_id, var.is_immutable); } - // Phase 200-C: Use existing path and manually add captured variables - // TODO Phase 200-D: Refactor to use build_with_captures with boundary builder - let (mut env, mut condition_bindings) = ConditionEnvBuilder::build_for_break_condition( + // Phase 201: Use v2 API with JoinValueSpace + // This allocates loop param and condition vars from Param region (100+) + let (mut env, mut condition_bindings, _loop_var_join_id) = ConditionEnvBuilder::build_for_break_condition_v2( condition, &loop_var_name, &self.variable_map, loop_var_id, + &mut join_value_space, )?; - // Phase 200-C: Manually add captured variables to env for E2E testing - // This is a temporary approach until Phase 200-D refactors the boundary creation + eprintln!("[pattern2/phase201] Using JoinValueSpace: loop_var '{}' → {:?}", + loop_var_name, env.get(&loop_var_name)); + + // Phase 201: Add captured variables using JoinValueSpace for var in &captured_env.vars { if let Some(&host_id) = self.variable_map.get(&var.name) { - // Allocate a JoinIR ValueId for this captured variable - let join_id = crate::mir::ValueId(env.len() as u32); + // Phase 201: Allocate from Param region to avoid collision with locals + let join_id = join_value_space.alloc_param(); env.insert(var.name.clone(), join_id); // Add to condition_bindings for boundary processing @@ -194,46 +202,34 @@ impl MirBuilder { join_value: join_id, }); - eprintln!("[pattern2/capture] Manually added captured '{}' to env: host={:?}, join={:?}", + eprintln!("[pattern2/capture] Phase 201: Added captured '{}': host={:?}, join={:?}", var.name, host_id, join_id); } } - // Phase 190-impl-D: Calculate ValueId offset for body-local variables - // JoinIR main() params are: [ValueId(0), ValueId(1), ...] for (loop_var, carrier1, carrier2, ...) - // Body-local variables must start AFTER all carrier params to avoid collision. - // At this point carrier_info.carriers contains all potential carriers (before filtering). - // We reserve space for: env.len() (condition vars) + carrier_info.carriers.len() (carriers) - let body_local_start_offset = (env.len() + carrier_info.carriers.len()) as u32; - - // Create allocator for body-local variables (starts after reserved param space) - let mut body_local_counter = body_local_start_offset; - let mut alloc_body_local_value = || { - let id = crate::mir::ValueId(body_local_counter); - body_local_counter += 1; - id - }; + // Phase 201: Allocate carrier ValueIds from Param region + // This ensures carriers are also in the safe Param region + for carrier in &carrier_info.carriers { + let _carrier_join_id = join_value_space.alloc_param(); + eprintln!("[pattern2/phase201] Allocated carrier '{}' param ID: {:?}", + carrier.name, _carrier_join_id); + } // Phase 191: Create empty body-local environment // LoopBodyLocalInitLowerer will populate it during init lowering use crate::mir::join_ir::lowering::loop_body_local_env::LoopBodyLocalEnv; let mut body_local_env = LoopBodyLocalEnv::new(); - eprintln!("[pattern2/body-local] Phase 191: Created empty body-local environment (offset={})", - body_local_start_offset); + eprintln!("[pattern2/body-local] Phase 201: Created empty body-local environment (param_count={})", + join_value_space.param_count()); - // Create allocator for other JoinIR-local ValueIds (Trim pattern, etc.) - // Continues from where body_local_counter left off - let mut join_value_counter = body_local_counter; - let mut alloc_join_value = || { - let id = crate::mir::ValueId(join_value_counter); - join_value_counter += 1; - id - }; + // Phase 201: Create alloc_join_value closure using JoinValueSpace + // This ensures all param allocations go through the unified space + let mut alloc_join_value = || join_value_space.alloc_param(); // Debug: Log condition bindings - eprintln!("[cf_loop/pattern2] Phase 171-172: ConditionEnv contains {} variables:", env.len()); - eprintln!(" Loop param '{}' → JoinIR ValueId(0)", loop_var_name); + eprintln!("[cf_loop/pattern2] Phase 201: ConditionEnv contains {} variables:", env.len()); + eprintln!(" Loop param '{}' → JoinIR {:?}", loop_var_name, env.get(&loop_var_name)); if !condition_bindings.is_empty() { eprintln!(" {} condition-only bindings:", condition_bindings.len()); for binding in &condition_bindings { diff --git a/src/mir/join_ir/lowering/inline_boundary_builder.rs b/src/mir/join_ir/lowering/inline_boundary_builder.rs index f49da08e..0a3972cd 100644 --- a/src/mir/join_ir/lowering/inline_boundary_builder.rs +++ b/src/mir/join_ir/lowering/inline_boundary_builder.rs @@ -27,6 +27,7 @@ use crate::mir::ValueId; use super::inline_boundary::{JoinInlineBoundary, LoopExitBinding}; use super::condition_to_joinir::ConditionBinding; +use super::join_value_space::JoinValueSpace; /// Role of a parameter in JoinIR lowering (Phase 200-A) /// diff --git a/src/mir/join_ir/lowering/join_value_space.rs b/src/mir/join_ir/lowering/join_value_space.rs new file mode 100644 index 00000000..6be8435a --- /dev/null +++ b/src/mir/join_ir/lowering/join_value_space.rs @@ -0,0 +1,393 @@ +//! Phase 201: JoinValueSpace - Single source of truth for JoinIR ValueId allocation +//! +//! This module provides a unified ValueId allocator for JoinIR lowering to prevent +//! collisions between different allocation contexts (param vs local vs PHI). +//! +//! ## Problem Solved +//! +//! Before Phase 201, Pattern 2 frontend used `alloc_join_value()` for env variables, +//! while JoinIR lowering used a separate `alloc_value()` starting from 0. Both could +//! produce the same ValueId for different purposes, causing PHI corruption after remapping. +//! +//! ## ValueId Space Layout +//! +//! ```text +//! 0 100 1000 u32::MAX +//! ├──────────┼──────────┼──────────────────────────┤ +//! │ PHI │ Param │ Local │ +//! │ Reserved│ Region │ Region │ +//! └──────────┴──────────┴──────────────────────────┘ +//! ``` +//! +//! - **PHI Reserved (0-99)**: Pre-reserved for LoopHeader PHI dst +//! - **Param Region (100-999)**: For ConditionEnv, CarrierInfo.join_id, CapturedEnv +//! - **Local Region (1000+)**: For Const, BinOp, etc. in pattern lowerers +//! +//! ## Usage +//! +//! ```ignore +//! let mut space = JoinValueSpace::new(); +//! +//! // Pattern frontend allocates param IDs +//! let i_param = space.alloc_param(); // ValueId(100) +//! let v_param = space.alloc_param(); // ValueId(101) +//! +//! // PHI builder reserves PHI dst +//! space.reserve_phi(ValueId(0)); // Mark as reserved +//! +//! // JoinIR lowerer allocates local IDs +//! let const_100 = space.alloc_local(); // ValueId(1000) +//! let binop_result = space.alloc_local(); // ValueId(1001) +//! +//! // No collision possible! +//! ``` + +use crate::mir::ValueId; +use std::collections::HashSet; + +/// Region boundaries (can be tuned based on actual usage) +const PHI_MAX: u32 = 99; // PHI dst range: 0-99 +const PARAM_BASE: u32 = 100; // Param range: 100-999 +const LOCAL_BASE: u32 = 1000; // Local range: 1000+ + +/// Single source of truth for JoinIR ValueId allocation +/// +/// All JoinIR ValueId allocation should go through this box to ensure +/// disjoint regions for Param, Local, and PHI dst IDs. +#[derive(Debug, Clone)] +pub struct JoinValueSpace { + /// Next available param ID (starts at PARAM_BASE) + next_param: u32, + /// Next available local ID (starts at LOCAL_BASE) + next_local: u32, + /// Reserved PHI dst IDs (debug verification only) + reserved_phi: HashSet, +} + +/// Region classification for ValueIds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Region { + /// PHI Reserved region (0-99) + PhiReserved, + /// Param region (100-999) + Param, + /// Local region (1000+) + Local, + /// Unknown/invalid region + Unknown, +} + +impl JoinValueSpace { + /// Create a new JoinValueSpace with default regions + pub fn new() -> Self { + Self { + next_param: PARAM_BASE, + next_local: LOCAL_BASE, + reserved_phi: HashSet::new(), + } + } + + /// Allocate a parameter ValueId (for ConditionEnv, CarrierInfo, etc.) + /// + /// Returns ValueId in Param Region (100-999). + /// Panics in debug mode if param region overflows. + pub fn alloc_param(&mut self) -> ValueId { + let id = self.next_param; + debug_assert!( + id < LOCAL_BASE, + "Param region overflow: {} >= {}", + id, + LOCAL_BASE + ); + self.next_param += 1; + ValueId(id) + } + + /// Allocate a local ValueId (for Const, BinOp, etc. in lowerers) + /// + /// Returns ValueId in Local Region (1000+). + pub fn alloc_local(&mut self) -> ValueId { + let id = self.next_local; + self.next_local += 1; + ValueId(id) + } + + /// Reserve a PHI dst ValueId (called by PHI builder before allocation) + /// + /// No allocation - just marks the ID as reserved for PHI use. + /// This is for debug verification only; the actual PHI dst comes from + /// MirBuilder (host side), not JoinValueSpace. + pub fn reserve_phi(&mut self, id: ValueId) { + debug_assert!( + id.0 <= PHI_MAX, + "PHI reservation out of range: {} > {}", + id.0, + PHI_MAX + ); + self.reserved_phi.insert(id.0); + } + + /// Check if a ValueId is reserved as PHI dst + pub fn is_phi_reserved(&self, id: ValueId) -> bool { + self.reserved_phi.contains(&id.0) + } + + /// Determine which region a ValueId belongs to + pub fn region_of(&self, id: ValueId) -> Region { + if id.0 <= PHI_MAX { + Region::PhiReserved + } else if id.0 < LOCAL_BASE { + Region::Param + } else { + Region::Local + } + } + + /// Get the current param counter (for debugging) + pub fn param_count(&self) -> u32 { + self.next_param - PARAM_BASE + } + + /// Get the current local counter (for debugging) + pub fn local_count(&self) -> u32 { + self.next_local - LOCAL_BASE + } + + /// Get the number of reserved PHI IDs (for debugging) + pub fn phi_reserved_count(&self) -> usize { + self.reserved_phi.len() + } + + /// Verify no overlap between regions (debug assertion) + /// + /// This checks that: + /// 1. Param region hasn't overflowed into Local region + /// 2. Reserved PHI IDs are within PHI region + /// + /// Returns Ok(()) if valid, Err(message) if invalid. + #[cfg(debug_assertions)] + pub fn verify_no_overlap(&self) -> Result<(), String> { + // Check param region hasn't overflowed + if self.next_param >= LOCAL_BASE { + return Err(format!( + "Param region overflow: next_param={} >= LOCAL_BASE={}", + self.next_param, LOCAL_BASE + )); + } + + // Check all reserved PHI IDs are in PHI region + for &phi_id in &self.reserved_phi { + if phi_id > PHI_MAX { + return Err(format!( + "PHI ID {} is out of PHI region (max={})", + phi_id, PHI_MAX + )); + } + } + + Ok(()) + } + + /// Create an allocator closure for local IDs + /// + /// This is a convenience method to create a closure compatible with + /// existing lowerer signatures that expect `FnMut() -> ValueId`. + /// + /// # Example + /// + /// ```ignore + /// let mut space = JoinValueSpace::new(); + /// let mut alloc_local = space.local_allocator(); + /// let id1 = alloc_local(); // ValueId(1000) + /// let id2 = alloc_local(); // ValueId(1001) + /// ``` + pub fn local_allocator(&mut self) -> impl FnMut() -> ValueId + '_ { + move || self.alloc_local() + } + + /// Create an allocator closure for param IDs + /// + /// Similar to local_allocator(), but for param region. + pub fn param_allocator(&mut self) -> impl FnMut() -> ValueId + '_ { + move || self.alloc_param() + } +} + +impl Default for JoinValueSpace { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_space_has_correct_initial_values() { + let space = JoinValueSpace::new(); + assert_eq!(space.next_param, PARAM_BASE); + assert_eq!(space.next_local, LOCAL_BASE); + assert!(space.reserved_phi.is_empty()); + } + + #[test] + fn test_alloc_param_returns_correct_ids() { + let mut space = JoinValueSpace::new(); + let id1 = space.alloc_param(); + let id2 = space.alloc_param(); + let id3 = space.alloc_param(); + + assert_eq!(id1, ValueId(100)); + assert_eq!(id2, ValueId(101)); + assert_eq!(id3, ValueId(102)); + } + + #[test] + fn test_alloc_local_returns_correct_ids() { + let mut space = JoinValueSpace::new(); + let id1 = space.alloc_local(); + let id2 = space.alloc_local(); + let id3 = space.alloc_local(); + + assert_eq!(id1, ValueId(1000)); + assert_eq!(id2, ValueId(1001)); + assert_eq!(id3, ValueId(1002)); + } + + #[test] + fn test_param_and_local_do_not_overlap() { + let mut space = JoinValueSpace::new(); + + // Allocate many params + for _ in 0..100 { + space.alloc_param(); + } + + // Allocate many locals + for _ in 0..100 { + space.alloc_local(); + } + + // Param should be in range [100, 200) + assert_eq!(space.next_param, 200); + // Local should be in range [1000, 1100) + assert_eq!(space.next_local, 1100); + + // No overlap possible + assert!(space.next_param < LOCAL_BASE); + } + + #[test] + fn test_reserve_phi() { + let mut space = JoinValueSpace::new(); + space.reserve_phi(ValueId(0)); + space.reserve_phi(ValueId(5)); + space.reserve_phi(ValueId(10)); + + assert!(space.is_phi_reserved(ValueId(0))); + assert!(space.is_phi_reserved(ValueId(5))); + assert!(space.is_phi_reserved(ValueId(10))); + assert!(!space.is_phi_reserved(ValueId(1))); + assert!(!space.is_phi_reserved(ValueId(100))); + } + + #[test] + fn test_region_of() { + let space = JoinValueSpace::new(); + + // PHI region + assert_eq!(space.region_of(ValueId(0)), Region::PhiReserved); + assert_eq!(space.region_of(ValueId(50)), Region::PhiReserved); + assert_eq!(space.region_of(ValueId(99)), Region::PhiReserved); + + // Param region + assert_eq!(space.region_of(ValueId(100)), Region::Param); + assert_eq!(space.region_of(ValueId(500)), Region::Param); + assert_eq!(space.region_of(ValueId(999)), Region::Param); + + // Local region + assert_eq!(space.region_of(ValueId(1000)), Region::Local); + assert_eq!(space.region_of(ValueId(5000)), Region::Local); + assert_eq!(space.region_of(ValueId(u32::MAX)), Region::Local); + } + + #[test] + fn test_counters() { + let mut space = JoinValueSpace::new(); + + assert_eq!(space.param_count(), 0); + assert_eq!(space.local_count(), 0); + assert_eq!(space.phi_reserved_count(), 0); + + space.alloc_param(); + space.alloc_param(); + space.alloc_local(); + space.reserve_phi(ValueId(0)); + + assert_eq!(space.param_count(), 2); + assert_eq!(space.local_count(), 1); + assert_eq!(space.phi_reserved_count(), 1); + } + + #[cfg(debug_assertions)] + #[test] + fn test_verify_no_overlap_success() { + let mut space = JoinValueSpace::new(); + space.alloc_param(); + space.alloc_local(); + space.reserve_phi(ValueId(0)); + + assert!(space.verify_no_overlap().is_ok()); + } + + #[test] + fn test_local_allocator_closure() { + let mut space = JoinValueSpace::new(); + let id1; + let id2; + { + let mut alloc = space.local_allocator(); + id1 = alloc(); + id2 = alloc(); + } + + assert_eq!(id1, ValueId(1000)); + assert_eq!(id2, ValueId(1001)); + } + + #[test] + fn test_param_allocator_closure() { + let mut space = JoinValueSpace::new(); + let id1; + let id2; + { + let mut alloc = space.param_allocator(); + id1 = alloc(); + id2 = alloc(); + } + + assert_eq!(id1, ValueId(100)); + assert_eq!(id2, ValueId(101)); + } + + /// Phase 201-A scenario: Verify that the bug case is impossible + /// + /// Previously: env['v'] = ValueId(7), const 100 dst = ValueId(7) -> collision + /// Now: env['v'] = alloc_param() -> ValueId(100+), const 100 = alloc_local() -> ValueId(1000+) + #[test] + fn test_phase201a_scenario_no_collision() { + let mut space = JoinValueSpace::new(); + + // Pattern 2 frontend allocates param for carrier 'v' + let v_param = space.alloc_param(); // ValueId(100) + + // JoinIR lowering allocates local for const 100 + let const_100 = space.alloc_local(); // ValueId(1000) + + // They are in different regions - no collision! + assert_ne!(v_param, const_100); + assert_eq!(space.region_of(v_param), Region::Param); + assert_eq!(space.region_of(const_100), Region::Local); + } +} diff --git a/src/mir/join_ir/lowering/mod.rs b/src/mir/join_ir/lowering/mod.rs index ed7589af..ff5f37db 100644 --- a/src/mir/join_ir/lowering/mod.rs +++ b/src/mir/join_ir/lowering/mod.rs @@ -47,6 +47,7 @@ pub mod if_phi_spec; // Phase 61-2 pub(crate) mod if_select; // Phase 33: Internal If/Select lowering pub mod inline_boundary; // Phase 188-Impl-3: JoinIR→Host boundary pub mod inline_boundary_builder; // Phase 200-2: Builder pattern for JoinInlineBoundary +pub mod join_value_space; // Phase 201: Unified JoinIR ValueId allocation pub(crate) mod loop_form_intake; // Internal loop form intake pub(crate) mod loop_pattern_router; // Phase 33-12: Loop pattern routing (re-exported) pub(crate) mod loop_pattern_validator; // Phase 33-23: Loop structure validation