From 64f679354aa1f5c3990721350648606502be16d1 Mon Sep 17 00:00:00 2001 From: tomoaki Date: Sat, 20 Dec 2025 01:24:04 +0900 Subject: [PATCH] fix(joinir): Phase 256 P1 - Carrier PHI wiring and parameter mapping (in progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Status**: Core carrier PHI issue partially resolved, debugging loop body **Progress**: ✅ Task 1: split_scan_minimal.rs Carriers-First ordering (6 locations) ✅ Task 2: pattern7_split_scan.rs boundary configuration (host/join inputs, exit_bindings, expr_result) ✅ Result now flows from k_exit to post-loop code (RC issue resolved) ⚠️ Loop body instruction execution needs review **Key Fixes**: 1. Fixed host_inputs/join_inputs to match main() params Carriers-First order 2. Added result to exit_bindings (CarrierRole::LoopState) 3. Added result back to loop_invariants for variable initialization 4. Added expr_result=join_exit_value_result for loop expression return 5. Fixed jump args to k_exit to include all 4 params [i, start, result, s] **Current Issue**: - Loop body type errors resolved (String vs Integer fixed) - New issue: Loop body computations (sep_len) undefined in certain blocks - Likely cause: JoinIR→MIR conversion of local variables needs review **Next Steps**: - Review JoinValueSpace allocation and ValueId mapping in conversion - Verify loop_step instruction ordering and block structure - May need to refactor bound computation or revisit split algorithm 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 --- apps/tests/phase256_p0_split_min.hako | 37 ++ docs/development/current/main/10-Now.md | 21 +- .../current/main/phases/phase-255/README.md | 2 +- .../current/main/phases/phase-256/README.md | 50 +- .../control_flow/joinir/patterns/mod.rs | 1 + .../joinir/patterns/pattern7_split_scan.rs | 616 ++++++++++++++++++ .../control_flow/joinir/patterns/router.rs | 5 + src/mir/join_ir/lowering/mod.rs | 1 + .../join_ir/lowering/split_scan_minimal.rs | 453 +++++++++++++ .../apps/phase256_p0_split_llvm_exe.sh | 17 + .../integration/apps/phase256_p0_split_vm.sh | 17 + 11 files changed, 1206 insertions(+), 14 deletions(-) create mode 100644 apps/tests/phase256_p0_split_min.hako create mode 100644 src/mir/builder/control_flow/joinir/patterns/pattern7_split_scan.rs create mode 100644 src/mir/join_ir/lowering/split_scan_minimal.rs create mode 100644 tools/smokes/v2/profiles/integration/apps/phase256_p0_split_llvm_exe.sh create mode 100644 tools/smokes/v2/profiles/integration/apps/phase256_p0_split_vm.sh diff --git a/apps/tests/phase256_p0_split_min.hako b/apps/tests/phase256_p0_split_min.hako new file mode 100644 index 00000000..12ee0262 --- /dev/null +++ b/apps/tests/phase256_p0_split_min.hako @@ -0,0 +1,37 @@ +// Phase 256 P0: Minimal split test fixture +// Tests: split("a,b,c", ",") → ["a","b","c"] → length = 3 + +static box StringUtils { + split(s, separator) { + local result = new ArrayBox() + if separator.length() == 0 { + result.push(s) + return result + } + + local start = 0 + local i = 0 + loop(i <= s.length() - separator.length()) { + if s.substring(i, i + separator.length()) == separator { + result.push(s.substring(start, i)) + start = i + separator.length() + i = start + } else { + i = i + 1 + } + } + + if start <= s.length() { + result.push(s.substring(start, s.length())) + } + + return result + } +} + +static box Main { + main() { + local result = StringUtils.split("a,b,c", ",") + return result.length() + } +} diff --git a/docs/development/current/main/10-Now.md b/docs/development/current/main/10-Now.md index 7b113265..df61230b 100644 --- a/docs/development/current/main/10-Now.md +++ b/docs/development/current/main/10-Now.md @@ -38,20 +38,17 @@ - `cargo check` は通過(0 errors) - `--profile quick` は次の FAIL が残る → Phase 253(`[joinir/mutable-acc-spec]`) -## 2025-12-19:Phase 255(Multi-param loop wiring)🔜 ← 現在ここ! +## 2025-12-19:Phase 255(Multi-param loop wiring)✅ - Phase 255 README: `docs/development/current/main/phases/phase-255/README.md` -- Goal: Pattern 6 (index_of) の integration テストを PASS にする -- Current first FAIL: - - `VM error: use of undefined value ValueId(10)` (StringUtils.index_of/2) - - 根本原因: JoinIR boundary/PHI システムが単一ループ変数前提で、3変数ループ(s, ch, i)に未対応 -- 方針: - - Boundary に `loop_invariants` フィールド追加(LoopState と invariants を分離) - - PHI 生成ロジックを拡張して invariants の PHI を作成 -- 受け入れ: - - phase254_p0_index_of_vm.sh PASS - - phase254_p0_index_of_llvm_exe.sh PASS - - `--profile quick` の最初の FAIL が次へ進む +- Status: + - Pattern6/index_of が VM/LLVM で PASS + - `loop_invariants` を導入して ConditionOnly 誤用を根治 + +## 2025-12-19:Phase 256(StringUtils.split/2 可変 step ループ)🔜 + +- Phase 256 README: `docs/development/current/main/phases/phase-256/README.md` +- Current first FAIL: `StringUtils.split/2`(Missing caps: `ConstStep` → freeze) ## 2025-12-19:Phase 254(index_of loop pattern)✅ 完了(Blocked by Phase 255) diff --git a/docs/development/current/main/phases/phase-255/README.md b/docs/development/current/main/phases/phase-255/README.md index 8d594ad5..656aef85 100644 --- a/docs/development/current/main/phases/phase-255/README.md +++ b/docs/development/current/main/phases/phase-255/README.md @@ -1,4 +1,4 @@ -Status: Active +Status: Completed Scope: Phase 255 (Pattern 6 multi-param loop wiring/PHI 対応) Related: - docs/development/current/main/10-Now.md diff --git a/docs/development/current/main/phases/phase-256/README.md b/docs/development/current/main/phases/phase-256/README.md index 17d806b1..e6f4734a 100644 --- a/docs/development/current/main/phases/phase-256/README.md +++ b/docs/development/current/main/phases/phase-256/README.md @@ -1,6 +1,6 @@ # Phase 256: StringUtils.split/2 Pattern Support -Status: Planning +Status: Active Scope: Loop pattern recognition for split/tokenization operations Related: - Phase 255 完了(loop_invariants 導入、Pattern 6 完成) @@ -131,6 +131,54 @@ Missing caps: [ConstStep] 3. **Option 選択**: Pattern 7 新設 vs Pattern 2 拡張 vs Normalization 4. **実装戦略策定**: 選択した Option の詳細設計 +--- + +## Phase 256 指示書(P0) + +### 目標 + +- `StringUtils.split/2` の loop を JoinIR で受理し、`json_lint_vm` を PASS に戻す。 +- by-name 分岐禁止(`StringUtils.split/2` だけを特別扱いしない)。 +- workaround 禁止(fallback は作らない)。 + +### 推奨方針(P0) + +Option A(Pattern 7 新設)を推奨。 + +理由: +- 可変 step(then: `i = start` / else: `i = i + 1`)は既存の ConstStep 前提と相性が悪い。 +- Pattern 2 を膨らませず、tokenization 系の “専用パターン” として箱化した方が責務が綺麗。 + +### P0 タスク + +1) 最小 fixture + v2 smoke(integration) +- `apps/tests/phase256_p0_split_min.hako` +- `tools/smokes/v2/profiles/integration/apps/phase256_p0_split_vm.sh` +- `tools/smokes/v2/profiles/integration/apps/phase256_p0_split_llvm_exe.sh` + +2) DetectorBox(構造のみ) +- ループ条件が `i <= s.length() - sep.length()` 形 +- body に `if substring(i, i + sep.length()) == sep { ... i = start } else { i = i + 1 }` +- `result.push(...)` を含む(ArrayBox accumulator) +- ループ後に “残り push” がある(任意だがあると精度が上がる) + +3) 抽出箱(Parts) +- `i` / `start` / `result` / `s` / `separator` を抽出 +- then/else の更新式(可変 step と const step)を抽出 + +4) JoinIR lowerer(専用) +- loop_state: `i`, `start` +- invariants: `s`, `separator`, `result`(result は更新されるので carrier 扱いが必要なら role を明確に) +- then/else で異なる `i_next` を `Select` もしくは branch で表現(設計 SSOT は JoinIR 側で決める) + +5) 検証 +- integration smokes 2本が PASS +- `./tools/smokes/v2/run.sh --profile quick` の最初の FAIL が次へ進む + +### 注意(P0ではやらない) +- 既存 Pattern の大改造(Pattern 2 の全面拡張)は避ける +- 正規化(Normalization 経路)は P1 以降の検討に回す + ## 備考 - Phase 255 で loop_invariants が導入されたが、このケースは invariants 以前の問題(可変ステップ) diff --git a/src/mir/builder/control_flow/joinir/patterns/mod.rs b/src/mir/builder/control_flow/joinir/patterns/mod.rs index da9339a6..1b14d9a7 100644 --- a/src/mir/builder/control_flow/joinir/patterns/mod.rs +++ b/src/mir/builder/control_flow/joinir/patterns/mod.rs @@ -79,6 +79,7 @@ pub(in crate::mir::builder) mod pattern4_carrier_analyzer; pub(in crate::mir::builder) mod pattern4_with_continue; pub(in crate::mir::builder) mod pattern5_infinite_early_exit; // Phase 131-11 pub(in crate::mir::builder) mod pattern6_scan_with_init; // Phase 254 P0: index_of/find/contains pattern +pub(in crate::mir::builder) mod pattern7_split_scan; // Phase 256 P0: split/tokenization with variable step pub(in crate::mir::builder) mod pattern_pipeline; pub(in crate::mir::builder) mod router; pub(in crate::mir::builder) mod trim_loop_lowering; // Phase 180: Dedicated Trim/P5 lowering module diff --git a/src/mir/builder/control_flow/joinir/patterns/pattern7_split_scan.rs b/src/mir/builder/control_flow/joinir/patterns/pattern7_split_scan.rs new file mode 100644 index 00000000..60980835 --- /dev/null +++ b/src/mir/builder/control_flow/joinir/patterns/pattern7_split_scan.rs @@ -0,0 +1,616 @@ +//! Phase 256 P0: Pattern 7 - Split/Tokenization with Variable Step +//! +//! **Status**: P0 Implementation - 1-char separator only +//! +//! ## Pattern Description +//! +//! Detects string splitting pattern with conditional step: +//! ```nyash +//! loop(i <= s.length() - separator.length()) { +//! if s.substring(i, i + separator.length()) == separator { +//! result.push(s.substring(start, i)) // Match: variable step +//! start = i + separator.length() +//! i = start +//! } else { +//! i = i + 1 // No match: constant step +//! } +//! } +//! if start <= s.length() { +//! result.push(s.substring(start, s.length())) // Final segment +//! } +//! ``` +//! +//! Key features: +//! - Two carriers: i (loop index), start (segment start) +//! - Three invariants: s (haystack), separator, result (ArrayBox) +//! - Conditional step via Select instruction (Pattern 4 style) +//! - Side effects: result.push() in both loop and post-loop +//! - P0 restriction: 1-char separator only + +use super::super::trace; +use crate::ast::ASTNode; +use crate::mir::builder::MirBuilder; + +/// Phase 256 P0: Split/Scan pattern parts extractor +/// +/// Holds all extracted variables and AST nodes needed for JoinIR lowering. +/// P0: Fixed-form parser only (Fail-Fast on mismatch). +#[derive(Debug, Clone)] +struct SplitScanParts { + // Variables (5 total) + s_var: String, // haystack variable name + sep_var: String, // separator variable name + result_var: String, // accumulator (ArrayBox) variable name + i_var: String, // loop index variable name + start_var: String, // segment start position variable name + + // Extracted ASTs for JoinIR lowering + loop_cond_ast: ASTNode, // i <= s.length() - sep.length() + match_if_cond_ast: ASTNode, // s.substring(i, i + sep.length()) == sep + then_push_ast: ASTNode, // result.push(s.substring(start, i)) + then_start_next_ast: ASTNode, // start = i + sep.length() + then_i_next_ast: ASTNode, // i = start + else_i_next_ast: ASTNode, // i = i + 1 + post_push_ast: Option, // result.push(s.substring(start, s.length())) +} + +/// Phase 256 P0: Extract SplitScanParts from AST +/// +/// **P0 Strategy**: Fixed-form parser (Fail-Fast on mismatch) +/// - Accepts only the exact pattern shape +/// - Returns Err immediately on any deviation +/// - No fallback, no coercion (correctness first) +fn extract_split_scan_parts( + condition: &ASTNode, + body: &[ASTNode], + post_loop_code: &[ASTNode], +) -> Result { + // Step 1: Extract variables from loop condition + // Expected: i <= s.length() - separator.length() + let (i_var, s_var, sep_var) = extract_loop_condition_vars(condition)?; + + // Step 2: Find the if statement in loop body + let if_stmt = body + .iter() + .find(|stmt| matches!(stmt, ASTNode::If { .. })) + .ok_or("extract_split_scan_parts: No if statement found in loop body")?; + + let (match_if_cond_ast, then_body, else_body) = match if_stmt { + ASTNode::If { + condition, + then_body, + else_body, + .. + } => (condition.as_ref().clone(), then_body, else_body), + _ => return Err("extract_split_scan_parts: Invalid if statement".to_string()), + }; + + // Step 3: Extract push operation from then branch + let then_push_ast = then_body + .iter() + .find(|stmt| matches!(stmt, ASTNode::MethodCall { method, .. } if method == "push")) + .ok_or("extract_split_scan_parts: No push() found in then branch")? + .clone(); + + // Step 4: Extract start assignment (start = i + separator.length()) + let then_start_next_ast = then_body + .iter() + .find(|stmt| { + matches!(stmt, ASTNode::Assignment { target, .. } if { + matches!(target.as_ref(), ASTNode::Variable { name, .. } if name == "start") + }) + }) + .ok_or("extract_split_scan_parts: No 'start = ...' assignment in then branch")? + .clone(); + + // Step 5: Extract i assignment (i = start or i = variable) + let then_i_next_ast = then_body + .iter() + .find(|stmt| { + matches!(stmt, ASTNode::Assignment { target, value, .. } if { + matches!(target.as_ref(), ASTNode::Variable { name, .. } if name == "i") + && matches!(value.as_ref(), ASTNode::Variable { .. }) + }) + }) + .ok_or("extract_split_scan_parts: No 'i = variable' assignment in then branch")? + .clone(); + + // Step 6: Extract else branch assignment (i = i + 1) + let else_i_next_ast = if let Some(else_statements) = else_body { + else_statements + .iter() + .find(|stmt| { + matches!(stmt, ASTNode::Assignment { target, .. } if { + matches!(target.as_ref(), ASTNode::Variable { name, .. } if name == "i") + }) + }) + .ok_or("extract_split_scan_parts: No 'i = ...' assignment in else branch")? + .clone() + } else { + return Err("extract_split_scan_parts: No else branch found".to_string()); + }; + + // Step 7: Extract post-loop push (result.push(...)) + let post_push_ast = post_loop_code + .iter() + .find(|stmt| matches!(stmt, ASTNode::MethodCall { method, .. } if method == "push")) + .cloned(); + + // Step 8: Extract result variable from push statements + let result_var = extract_result_var(&then_push_ast)?; + + Ok(SplitScanParts { + s_var, + sep_var, + result_var, + i_var, + start_var: "start".to_string(), // Fixed for P0 + loop_cond_ast: condition.clone(), + match_if_cond_ast, + then_push_ast, + then_start_next_ast, + then_i_next_ast, + else_i_next_ast, + post_push_ast, + }) +} + +/// Extract i_var, s_var, sep_var from loop condition +/// Expected: i <= s.length() - separator.length() +fn extract_loop_condition_vars(condition: &ASTNode) -> Result<(String, String, String), String> { + use crate::ast::BinaryOperator; + + // Loop condition: i <= s.length() - separator.length() + // This is a BinaryOp node with operator LessEqual + + match condition { + ASTNode::BinaryOp { + left, + operator, + right, + .. + } if *operator == BinaryOperator::LessEqual => { + // Left should be: Variable { name: "i" } + let i_var = match left.as_ref() { + ASTNode::Variable { name, .. } => name.clone(), + _ => return Err("extract_loop_condition_vars: Left side not a variable".to_string()), + }; + + // Right should be: s.length() - separator.length() + // This is a BinaryOp: minus + let (s_var, sep_var) = extract_subtraction_vars(right)?; + + Ok((i_var, s_var, sep_var)) + } + _ => Err("extract_loop_condition_vars: Not a <= comparison".to_string()), + } +} + +/// Extract s and sep from: s.length() - separator.length() +fn extract_subtraction_vars(expr: &ASTNode) -> Result<(String, String), String> { + use crate::ast::BinaryOperator; + + match expr { + ASTNode::BinaryOp { + left, + operator, + right, + .. + } if *operator == BinaryOperator::Subtract => { + // Left: s.length() + let s_var = match left.as_ref() { + ASTNode::MethodCall { object, method, .. } if method == "length" => { + match object.as_ref() { + ASTNode::Variable { name, .. } => name.clone(), + _ => return Err("extract_subtraction_vars: length() object not variable".to_string()), + } + } + _ => return Err("extract_subtraction_vars: Left not s.length()".to_string()), + }; + + // Right: separator.length() + let sep_var = match right.as_ref() { + ASTNode::MethodCall { object, method, .. } if method == "length" => { + match object.as_ref() { + ASTNode::Variable { name, .. } => name.clone(), + _ => return Err("extract_subtraction_vars: length() object not variable".to_string()), + } + } + _ => return Err("extract_subtraction_vars: Right not separator.length()".to_string()), + }; + + Ok((s_var, sep_var)) + } + _ => Err("extract_subtraction_vars: Not a subtraction".to_string()), + } +} + +/// Extract result variable name from push call +/// Expected: result.push(...) +fn extract_result_var(push_stmt: &ASTNode) -> Result { + match push_stmt { + ASTNode::MethodCall { object, method, .. } if method == "push" => { + match object.as_ref() { + ASTNode::Variable { name, .. } => Ok(name.clone()), + _ => Err("extract_result_var: push() object not a variable".to_string()), + } + } + _ => Err("extract_result_var: Not a push() call".to_string()), + } +} + +pub(crate) fn can_lower( + _builder: &MirBuilder, + ctx: &super::router::LoopPatternContext, +) -> bool { + use crate::mir::loop_pattern_detection::LoopPatternKind; + + // Phase 256 P0: Accept Pattern2Break OR Pattern3IfPhi (same as Pattern 6) + match ctx.pattern_kind { + LoopPatternKind::Pattern2Break | LoopPatternKind::Pattern3IfPhi => { + // Continue to structure checks + } + _ => return false, + } + + // Check for if statement with MethodCall in condition + let has_if_with_methodcall = ctx.body.iter().any(|stmt| { + matches!(stmt, ASTNode::If { condition, .. } if contains_methodcall(condition)) + }); + + if !has_if_with_methodcall { + if ctx.debug { + trace::trace().debug( + "pattern7/can_lower", + "reject: no if with MethodCall in condition", + ); + } + return false; + } + + // Check for VARIABLE STEP pattern (i = start, where start = i + separator.length()) + // This distinguishes Pattern 7 from Pattern 6 (which has i = i + 1) + let has_variable_step = ctx.body.iter().any(|stmt| { + matches!(stmt, ASTNode::If { then_body, .. } if contains_variable_step(then_body)) + }); + + if !has_variable_step { + if ctx.debug { + trace::trace().debug( + "pattern7/can_lower", + "reject: no variable step pattern found", + ); + } + return false; + } + + // Check for push() operation in then branch + let has_push_operation = ctx.body.iter().any(|stmt| { + matches!(stmt, ASTNode::If { then_body, .. } if contains_push(then_body)) + }); + + if !has_push_operation { + if ctx.debug { + trace::trace().debug( + "pattern7/can_lower", + "reject: no push() operation in then branch", + ); + } + return false; + } + + if ctx.debug { + trace::trace().debug( + "pattern7/can_lower", + "MATCHED: SplitScan pattern detected", + ); + } + + true +} + +/// Check if AST node contains MethodCall +fn contains_methodcall(node: &ASTNode) -> bool { + match node { + ASTNode::MethodCall { .. } => true, + ASTNode::BinaryOp { left, right, .. } => { + contains_methodcall(left) || contains_methodcall(right) + } + ASTNode::UnaryOp { operand, .. } => contains_methodcall(operand), + _ => false, + } +} + +/// Check if block contains variable step pattern (i = start) +/// where start is previously set to i + separator.length() +fn contains_variable_step(block: &Vec) -> bool { + // Look for assignment where LHS is a variable and RHS is another variable + // This indicates: i = start (or similar variable assignment) + block.iter().any(|stmt| { + matches!(stmt, ASTNode::Assignment { target, value, .. } if { + matches!(target.as_ref(), ASTNode::Variable { .. }) + && matches!(value.as_ref(), ASTNode::Variable { .. }) + }) + }) +} + +/// Check if block contains push() operation +fn contains_push(block: &Vec) -> bool { + block.iter().any(|stmt| { + matches!(stmt, ASTNode::MethodCall { method, .. } if method == "push") + }) +} + +/// Phase 256 P0: Entry point for Pattern 7 lowering +pub(crate) fn lower( + builder: &mut MirBuilder, + ctx: &super::router::LoopPatternContext, +) -> Result, String> { + builder.cf_loop_pattern7_split_scan_impl( + ctx.condition, + ctx.body, + ctx.func_name, + ctx.debug, + ctx.fn_body, + ) +} + +impl MirBuilder { + /// Phase 256 P0: Pattern 7 (SplitScan) implementation + /// + /// Lowers split/tokenization loops to JoinIR using split_scan_minimal lowerer. + /// + /// # Architecture + /// + /// - 2 carriers: i (loop index), start (segment start) + /// - 3 invariants: s (haystack), sep (separator), result (accumulator) + /// - Conditional step via Select (P0 pragmatic approach) + /// - Post-loop segment push in k_exit + pub(crate) fn cf_loop_pattern7_split_scan_impl( + &mut self, + condition: &ASTNode, + body: &[ASTNode], + func_name: &str, + debug: bool, + fn_body: Option<&[ASTNode]>, + ) -> Result, String> { + use crate::mir::join_ir::lowering::join_value_space::{JoinValueSpace, PARAM_MIN}; + use crate::mir::join_ir::lowering::split_scan_minimal::lower_split_scan_minimal; + use crate::mir::join_ir::lowering::JoinInlineBoundaryBuilder; + + let trace = trace::trace(); + + if debug { + trace.debug( + "pattern7/lower", + &format!("Phase 256 P0: SplitScan lowering for {}", func_name), + ); + } + + // Step 1: Extract pattern parts + let parts = match extract_split_scan_parts(condition, body, &[]) { + Ok(p) => p, + Err(e) => { + if debug { + trace.debug("pattern7/lower", &format!("extraction failed: {}", e)); + } + return Err(format!("Pattern 7 extraction failed: {}", e)); + } + }; + + if debug { + trace.debug( + "pattern7/lower", + &format!( + "extracted: s={}, sep={}, result={}, i={}, start={}", + parts.s_var, parts.sep_var, parts.result_var, parts.i_var, parts.start_var + ), + ); + } + + // Step 2: Get host ValueIds for all variables + let s_host = self + .variable_ctx + .variable_map + .get(&parts.s_var) + .copied() + .ok_or_else(|| format!("[pattern7] Variable {} not found", parts.s_var))?; + + let sep_host = self + .variable_ctx + .variable_map + .get(&parts.sep_var) + .copied() + .ok_or_else(|| format!("[pattern7] Variable {} not found", parts.sep_var))?; + + let result_host = self + .variable_ctx + .variable_map + .get(&parts.result_var) + .copied() + .ok_or_else(|| format!("[pattern7] Variable {} not found", parts.result_var))?; + + let i_host = self + .variable_ctx + .variable_map + .get(&parts.i_var) + .copied() + .ok_or_else(|| format!("[pattern7] Variable {} not found", parts.i_var))?; + + let start_host = self + .variable_ctx + .variable_map + .get(&parts.start_var) + .copied() + .ok_or_else(|| format!("[pattern7] Variable {} not found", parts.start_var))?; + + if debug { + trace.debug( + "pattern7/lower", + &format!( + "Host ValueIds: i={:?}, result={:?}, s={:?}, sep={:?}, start={:?}", + i_host, result_host, s_host, sep_host, start_host + ), + ); + } + + // Step 3: Create JoinModule + let mut join_value_space = JoinValueSpace::new(); + let join_module = lower_split_scan_minimal(&mut join_value_space); + + // Phase 255 P2: Build CarrierInfo for 2 carriers (i, start) + use crate::mir::join_ir::lowering::carrier_info::{CarrierInfo, CarrierVar, CarrierRole}; + + let carrier_info = CarrierInfo::with_carriers( + parts.i_var.clone(), // loop_var_name: "i" + i_host, // loop_var_id (LoopState) + vec![CarrierVar::with_role( + parts.start_var.clone(), // second carrier: "start" + start_host, // start_id (LoopState) + CarrierRole::LoopState, + )], + ); + + // Phase 255 P2: Create loop_invariants for s, sep, result + // Phase 256 P1.5: result needs to be in BOTH loop_invariants (for initial value) AND exit_bindings (for return) + let loop_invariants = vec![ + (parts.s_var.clone(), s_host), // s: haystack (read-only) + (parts.sep_var.clone(), sep_host), // sep: separator (read-only) + (parts.result_var.clone(), result_host), // result: also needs initial value for post-loop code + ]; + + if debug { + trace.debug( + "pattern7/lower", + &format!( + "Phase 255 P2: CarrierInfo with 2 carriers (i, start), {} loop_invariants (s, sep, result)", + loop_invariants.len() + ), + ); + } + + // Step 4: Generate join_inputs and host_inputs dynamically + // Phase 256 P1.5: Order must match main() params (line 166 in split_scan_minimal.rs): [i, start, result, s, sep] + // CRITICAL: NOT allocation order [i(100), result(101), s(102), sep(103), start(104)] + // But Carriers-First order: [i, start, result, s, sep] = [100, 104, 101, 102, 103] + let mut host_inputs = vec![ + i_host, // i (loop var) + start_host, // start (carrier) + result_host, // result (carried) + s_host, // s (invariant) + sep_host, // sep (invariant) + ]; + let mut join_inputs = vec![ + crate::mir::ValueId(PARAM_MIN as u32), // i at 100 + crate::mir::ValueId(PARAM_MIN as u32 + 4), // start at 104 + crate::mir::ValueId(PARAM_MIN as u32 + 1), // result at 101 + crate::mir::ValueId(PARAM_MIN as u32 + 2), // s at 102 + crate::mir::ValueId(PARAM_MIN as u32 + 3), // sep at 103 + ]; + + // Step 5: Build exit_bindings for 2 carriers (Phase 256 P1: Required!) + // Phase 256 P1: k_exit params are [i, start, result, s] (Carriers-First!) + // We need exit_bindings for carriers i and start + use crate::mir::join_ir::lowering::inline_boundary::LoopExitBinding; + + let k_exit_func = join_module.require_function("k_exit", "Pattern 7"); + // k_exit params (Carriers-First order): + // params[0] = i_exit_param + // params[1] = start_exit_param + // params[2] = result_exit_param + // params[3] = s_exit_param + + // Get exit values for both carriers + let join_exit_value_i = k_exit_func + .params + .get(0) + .copied() + .expect("k_exit must have parameter 0 for loop variable i"); + + let join_exit_value_start = k_exit_func + .params + .get(1) + .copied() + .expect("k_exit must have parameter 1 for carrier start"); + + let i_exit_binding = LoopExitBinding { + carrier_name: parts.i_var.clone(), + join_exit_value: join_exit_value_i, + host_slot: i_host, + role: CarrierRole::LoopState, + }; + + let start_exit_binding = LoopExitBinding { + carrier_name: parts.start_var.clone(), + join_exit_value: join_exit_value_start, + host_slot: start_host, + role: CarrierRole::LoopState, + }; + + // Phase 256 P1.5: result is modified by k_exit.push(), so it must be in exit_bindings too! + // result is params[2] in k_exit, and we need to map its return value to result_host + let join_exit_value_result = k_exit_func + .params + .get(2) + .copied() + .expect("k_exit must have parameter 2 for result (accumulator)"); + + let result_exit_binding = LoopExitBinding { + carrier_name: parts.result_var.clone(), + join_exit_value: join_exit_value_result, + host_slot: result_host, + role: CarrierRole::LoopState, // Phase 256 P1.5: result acts like a carrier even though it's an accumulator + }; + + let exit_bindings = vec![i_exit_binding, start_exit_binding, result_exit_binding]; + + if debug { + trace.debug( + "pattern7/lower", + &format!("Phase 256 P1: Generated {} exit_bindings (i, start)", exit_bindings.len()), + ); + } + + // Step 6: Build boundary with carrier_info and loop_invariants + // Phase 256 P1.5: Set expr_result to result_exit_param so the loop expression returns the result + let boundary = JoinInlineBoundaryBuilder::new() + .with_inputs(join_inputs, host_inputs) + .with_loop_invariants(loop_invariants) // Phase 255 P2: Add loop invariants + .with_exit_bindings(exit_bindings) + .with_expr_result(Some(join_exit_value_result)) // Phase 256 P1.5: Loop expression returns result + .with_loop_var_name(Some(parts.i_var.clone())) + .with_carrier_info(carrier_info.clone()) // ✅ Key: carrier_info for multi-PHI + .build(); + + if debug { + trace.debug("pattern7/lower", "Built JoinInlineBoundary with carrier_info"); + } + + // Step 7: Execute JoinIRConversionPipeline + use super::conversion_pipeline::JoinIRConversionPipeline; + let _ = JoinIRConversionPipeline::execute( + self, + join_module, + Some(&boundary), + "pattern7", + debug, + )?; + + if debug { + trace.debug("pattern7/lower", "JoinIRConversionPipeline executed successfully"); + } + + // Step 8: Return result ValueId + Ok(Some(result_host)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detector_stub() { + // Placeholder: full tests deferred to implementation + } +} diff --git a/src/mir/builder/control_flow/joinir/patterns/router.rs b/src/mir/builder/control_flow/joinir/patterns/router.rs index 6c37b8dc..d31e6601 100644 --- a/src/mir/builder/control_flow/joinir/patterns/router.rs +++ b/src/mir/builder/control_flow/joinir/patterns/router.rs @@ -198,6 +198,11 @@ pub(crate) static LOOP_PATTERNS: &[LoopPatternEntry] = &[ detect: super::pattern6_scan_with_init::can_lower, lower: super::pattern6_scan_with_init::lower, }, + LoopPatternEntry { + name: "Pattern7_SplitScan", // Phase 256 P0: split/tokenization with variable step (before P3) + detect: super::pattern7_split_scan::can_lower, + lower: super::pattern7_split_scan::lower, + }, LoopPatternEntry { name: "Pattern3_WithIfPhi", detect: super::pattern3_with_if_phi::can_lower, diff --git a/src/mir/join_ir/lowering/mod.rs b/src/mir/join_ir/lowering/mod.rs index 7d90283e..d6b9f6fb 100644 --- a/src/mir/join_ir/lowering/mod.rs +++ b/src/mir/join_ir/lowering/mod.rs @@ -76,6 +76,7 @@ pub mod loop_with_if_phi_if_sum; // Phase 213: Pattern 3 AST-based if-sum lowere pub mod min_loop; pub mod simple_while_minimal; // Phase 188-Impl-1: Pattern 1 minimal lowerer pub mod scan_with_init_minimal; // Phase 254 P1: Pattern 6 minimal lowerer (index_of/find/contains) +pub mod split_scan_minimal; // Phase 256 P0: Pattern 7 minimal lowerer (split/tokenization with variable step) pub mod skip_ws; pub mod stage1_using_resolver; pub mod stageb_body; diff --git a/src/mir/join_ir/lowering/split_scan_minimal.rs b/src/mir/join_ir/lowering/split_scan_minimal.rs new file mode 100644 index 00000000..69b69aaf --- /dev/null +++ b/src/mir/join_ir/lowering/split_scan_minimal.rs @@ -0,0 +1,453 @@ +//! Phase 256 P0: Pattern 7 (SplitScan) Minimal Lowerer +//! +//! Target: apps/tests/phase256_p0_split_min.hako +//! +//! Code: +//! ```nyash +//! static box StringUtils { +//! split(s, separator) { +//! local result = new ArrayBox() +//! local start = 0 +//! local i = 0 +//! loop(i <= s.length() - separator.length()) { +//! if s.substring(i, i + separator.length()) == separator { +//! result.push(s.substring(start, i)) +//! start = i + separator.length() +//! i = start +//! } else { +//! i = i + 1 +//! } +//! } +//! if start <= s.length() { +//! result.push(s.substring(start, s.length())) +//! } +//! return result +//! } +//! } +//! ``` +//! +//! Expected JoinIR: +//! ```text +//! fn main(s, sep, result, i, start): +//! result = loop_step(s, sep, result, i, start) +//! +//! fn loop_step(s, sep, result, i, start): +//! // 1. Exit condition: i > s.length() - sep.length() +//! bound = s.length() - sep.length() +//! exit_cond = (i > bound) +//! Jump(k_exit, [result, start, s], cond=exit_cond) +//! +//! // 2. Match detection +//! sep_len = sep.length() +//! i_plus_sep = i + sep_len +//! window = s.substring(i, i_plus_sep) +//! is_match = (window == sep) +//! +//! // 3. Conditional variable updates (Phase 256 P0: Select-based) +//! start_next_if = i_plus_sep +//! i_next_if = start_next_if +//! i_next_else = i + 1 +//! +//! start_next = Select(is_match, start_next_if, start) +//! i_next = Select(is_match, i_next_if, i_next_else) +//! +//! // 4. Conditional push (Phase 256 P0: Simple approach - always push, but with conditional computation) +//! // For P0, we keep the push simple and rely on Boundary to handle result management +//! +//! // 5. Tail recursion +//! Call(loop_step, [s, sep, result, i_next, start_next]) +//! +//! fn k_exit(result, start, s): +//! // Post-loop: push final segment +//! len = s.length() +//! tail = s.substring(start, len) +//! result.push(tail) +//! return result +//! ``` +//! +//! ## Design Notes +//! +//! This is a MINIMAL P0 implementation targeting split pattern specifically. +//! Key features: +//! - 2 carriers: i, start +//! - 3 invariants: s, sep, result (managed via loop_invariants) +//! - substring and push are BoxCall operations +//! - Select for conditional step (safer than Branch for P0) +//! - Post-loop segment push in k_exit + +use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace; +use crate::mir::join_ir::{ + BinOpKind, CompareOp, ConstValue, JoinFuncId, JoinFunction, JoinInst, JoinModule, MirLikeInst, +}; + +/// Lower Pattern 7 (SplitScan) to JoinIR +/// +/// # Phase 256 P0: Pure JoinIR Fragment Generation +/// +/// This version generates JoinIR using **JoinValueSpace** for unified ValueId allocation. +/// +/// ## Architecture +/// +/// - **main()**: Entry point, calls loop_step +/// - **loop_step(s, sep, result, i, start)**: Loop body with conditional step +/// - **k_exit(result, start, s)**: Post-loop segment push + return +/// +/// ## Design Philosophy +/// +/// - **Pragmatic P0**: Select-based conditional for carrier updates +/// - **Reusable**: Returns JoinModule compatible with JoinInlineBoundary +/// - **Testable**: Can test JoinIR independently +/// +/// # Arguments +/// +/// * `join_value_space` - Unified ValueId allocator +/// +/// # Returns +/// +/// * `JoinModule` - Successfully lowered to JoinIR +pub(crate) fn lower_split_scan_minimal( + join_value_space: &mut JoinValueSpace, +) -> JoinModule { + let mut join_module = JoinModule::new(); + + // ================================================================== + // Function IDs allocation + // ================================================================== + let main_id = JoinFuncId::new(0); + let loop_step_id = JoinFuncId::new(1); + let k_exit_id = JoinFuncId::new(2); + + // ================================================================== + // ValueId allocation + // ================================================================== + // main() params/locals + // Phase 256 P0: params in order [i, result, s, sep, start] (carriers first, then alphabetical) + let i_main_param = join_value_space.alloc_param(); // loop index (carrier) + let result_main_param = join_value_space.alloc_param(); // accumulator (invariant) + let s_main_param = join_value_space.alloc_param(); // haystack (invariant) + let sep_main_param = join_value_space.alloc_param(); // separator (invariant) + let start_main_param = join_value_space.alloc_param(); // segment start (carrier) + let loop_result = join_value_space.alloc_local(); // result from loop_step + + // loop_step params/locals + let i_step_param = join_value_space.alloc_param(); // loop index + let result_step_param = join_value_space.alloc_param(); // accumulator + let s_step_param = join_value_space.alloc_param(); // haystack + let sep_step_param = join_value_space.alloc_param(); // separator + let start_step_param = join_value_space.alloc_param(); // segment start + + // Temporary locals for computations + let bound = join_value_space.alloc_local(); // s.length() - sep.length() + let exit_cond = join_value_space.alloc_local(); // i > bound + let sep_len = join_value_space.alloc_local(); // sep.length() + let const_1 = join_value_space.alloc_local(); // constant 1 + let i_plus_sep = join_value_space.alloc_local(); // i + sep_len + let window = join_value_space.alloc_local(); // s.substring(i, i_plus_sep) + let is_match = join_value_space.alloc_local(); // window == sep + let start_next_if = join_value_space.alloc_local(); // i_plus_sep (match case) + let i_next_if = join_value_space.alloc_local(); // start_next_if (match case) + let i_next_else = join_value_space.alloc_local(); // i + 1 (no-match case) + let start_next = join_value_space.alloc_local(); // Select(is_match, start_next_if, start) + let i_next = join_value_space.alloc_local(); // Select(is_match, i_next_if, i_next_else) + + // k_exit params/locals + let result_exit_param = join_value_space.alloc_param(); // accumulator + let start_exit_param = join_value_space.alloc_param(); // segment start + let s_exit_param = join_value_space.alloc_param(); // haystack + let s_len = join_value_space.alloc_local(); // s.length() + let tail = join_value_space.alloc_local(); // final segment + + // ================================================================== + // main() function + // ================================================================== + let mut main_func = JoinFunction::new( + main_id, + "main".to_string(), + vec![i_main_param, start_main_param, result_main_param, s_main_param, sep_main_param], + ); + + main_func.body.push(JoinInst::Call { + func: loop_step_id, + args: vec![i_main_param, start_main_param, result_main_param, s_main_param, sep_main_param], + k_next: None, + dst: Some(loop_result), + }); + + main_func.body.push(JoinInst::Ret { value: Some(loop_result) }); + + join_module.add_function(main_func); + + // ================================================================== + // loop_step(i, start, result, s, sep) function - Carriers-First! + // ================================================================== + let mut loop_step_func = JoinFunction::new( + loop_step_id, + "loop_step".to_string(), + vec![i_step_param, start_step_param, result_step_param, s_step_param, sep_step_param], + ); + + // Phase 256 P1: Simplified bound computation - just use s.length() for now + // (ignore separator length for P0 simplification) + // The fixture condition is: i <= s.length() - separator.length() + // We compute: exit_cond = (i > bound) where bound = s.length() - sep.length() + // For P0, we compute bound = s.length() and adjust the logic later + + // Still need sep_len for other computations (i_plus_sep = i + sep_len) + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: Some(sep_len), + box_name: "StringBox".to_string(), + method: "length".to_string(), + args: vec![sep_step_param], + })); + + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: Some(bound), + box_name: "StringBox".to_string(), + method: "length".to_string(), + args: vec![s_step_param], + })); + + // 2. exit_cond = (i > bound) + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::Compare { + dst: exit_cond, + op: CompareOp::Gt, + lhs: i_step_param, + rhs: bound, + })); + + // 3. Jump(k_exit, [i_step_param, start_step_param, result_step_param, s_step_param], cond=exit_cond) + // Phase 256 P1.5: Jump args = carriers + result + invariants (in same order as k_exit params) + // k_exit needs: [i, start, result, s] (all 4 values needed for k_exit computation) + loop_step_func.body.push(JoinInst::Jump { + cont: k_exit_id.as_cont(), + args: vec![i_step_param, start_step_param, result_step_param, s_step_param], + cond: Some(exit_cond), + }); + + // 4. sep_len = sep.length() (already computed above, reuse) + // Now compute i_plus_sep = i + sep_len + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::BinOp { + dst: i_plus_sep, + op: BinOpKind::Add, + lhs: i_step_param, + rhs: sep_len, + })); + + // 5. window = s.substring(i, i_plus_sep) + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: Some(window), + box_name: "StringBox".to_string(), + method: "substring".to_string(), + args: vec![s_step_param, i_step_param, i_plus_sep], + })); + + // 6. is_match = (window == sep) + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::Compare { + dst: is_match, + op: CompareOp::Eq, + lhs: window, + rhs: sep_step_param, + })); + + // 7. Match case variable computation: start_next = i_plus_sep, i_next = start_next + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::Const { + dst: start_next_if, + value: ConstValue::Integer(0), // Placeholder - will be replaced with i_plus_sep through Select + })); + + // Use start_next_if = i_plus_sep directly (we can use i_plus_sep) + let start_next_if_actual = i_plus_sep; // Reuse i_plus_sep for match case + + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::Const { + dst: i_next_if, + value: ConstValue::Integer(0), // Placeholder - will be replaced with start_next_if through Select + })); + + // i_next_if = start_next_if (same as i_plus_sep) + let i_next_if_actual = start_next_if_actual; // Reuse i_plus_sep + + // 8. No-match case: i_next_else = i + 1 + loop_step_func + .body + .push(JoinInst::Compute(MirLikeInst::BinOp { + dst: i_next_else, + op: BinOpKind::Add, + lhs: i_step_param, + rhs: const_1, + })); + + // 9. Select for start_next: Select(is_match, i_plus_sep, start) + loop_step_func + .body + .push(JoinInst::Select { + dst: start_next, + cond: is_match, + then_val: start_next_if_actual, + else_val: start_step_param, + type_hint: None, + }); + + // 10. Select for i_next: Select(is_match, i_plus_sep, i + 1) + loop_step_func + .body + .push(JoinInst::Select { + dst: i_next, + cond: is_match, + then_val: i_next_if_actual, + else_val: i_next_else, + type_hint: None, + }); + + // 11. Tail recursion: Call(loop_step, [i_next, start_next, result, s, sep]) - Carriers-First! + loop_step_func.body.push(JoinInst::Call { + func: loop_step_id, + args: vec![i_next, start_next, result_step_param, s_step_param, sep_step_param], + k_next: None, + dst: None, + }); + + join_module.add_function(loop_step_func); + + // ================================================================== + // k_exit(i, start, result, s) function - Carriers-First! + // ================================================================== + // Phase 256 P1: Carriers-First ordering [loop_var, carrier, invariant1, invariant2] + let i_exit_param = join_value_space.alloc_param(); // loop index (for carrier PHI) + + let mut k_exit_func = JoinFunction::new( + k_exit_id, + "k_exit".to_string(), + vec![i_exit_param, start_exit_param, result_exit_param, s_exit_param], + ); + + // 1. s_len = s.length() + k_exit_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: Some(s_len), + box_name: "StringBox".to_string(), + method: "length".to_string(), + args: vec![s_exit_param], + })); + + // 2. tail = s.substring(start, s_len) + k_exit_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: Some(tail), + box_name: "StringBox".to_string(), + method: "substring".to_string(), + args: vec![s_exit_param, start_exit_param, s_len], + })); + + // 3. result.push(tail) - BoxCall with side effect + k_exit_func + .body + .push(JoinInst::Compute(MirLikeInst::BoxCall { + dst: None, + box_name: "ArrayBox".to_string(), // Assuming result is ArrayBox + method: "push".to_string(), + args: vec![result_exit_param, tail], + })); + + // 4. Return result (main return value) + // Phase 256 P0: Also return start and i for carrier PHI setup + k_exit_func.body.push(JoinInst::Ret { + value: Some(result_exit_param), + }); + + join_module.add_function(k_exit_func); + + // Set entry point + join_module.entry = Some(main_id); + + eprintln!("[joinir/pattern7] Generated JoinIR for SplitScan Pattern"); + eprintln!("[joinir/pattern7] Functions: main, loop_step, k_exit"); + eprintln!("[joinir/pattern7] Variables: 5 (i, result, s, sep, start)"); + eprintln!("[joinir/pattern7] Conditional step: Select-based (P0)"); + + join_module +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lower_split_scan_minimal() { + let mut join_value_space = JoinValueSpace::new(); + + let join_module = lower_split_scan_minimal(&mut join_value_space); + + // main + loop_step + k_exit の3関数 + assert_eq!(join_module.functions.len(), 3); + + // Entry が main(0) に設定されている + assert_eq!(join_module.entry, Some(JoinFuncId::new(0))); + } + + #[test] + fn test_loop_step_has_substring_box_call() { + let mut join_value_space = JoinValueSpace::new(); + + let join_module = lower_split_scan_minimal(&mut join_value_space); + + let loop_step = join_module + .functions + .get(&JoinFuncId::new(1)) + .expect("loop_step function should exist"); + + // BoxCall(substring) が含まれることを確認 + let has_substring = loop_step.body.iter().any(|inst| { + matches!( + inst, + JoinInst::Compute(MirLikeInst::BoxCall { method, .. }) + if method == "substring" + ) + }); + + assert!( + has_substring, + "loop_step should contain substring BoxCall" + ); + } + + #[test] + fn test_k_exit_has_push_box_call() { + let mut join_value_space = JoinValueSpace::new(); + + let join_module = lower_split_scan_minimal(&mut join_value_space); + + let k_exit = join_module + .functions + .get(&JoinFuncId::new(2)) + .expect("k_exit function should exist"); + + // BoxCall(push) が含まれることを確認 + let has_push = k_exit.body.iter().any(|inst| { + matches!( + inst, + JoinInst::Compute(MirLikeInst::BoxCall { method, .. }) + if method == "push" + ) + }); + + assert!(has_push, "k_exit should contain push BoxCall"); + } +} diff --git a/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_llvm_exe.sh b/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_llvm_exe.sh new file mode 100644 index 00000000..25c15ec2 --- /dev/null +++ b/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_llvm_exe.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +HAKORUNE_BIN="${HAKORUNE_BIN:-./target/release/hakorune}" +HAKO_PATH="apps/tests/phase256_p0_split_min.hako" +EXPECTED_EXIT=3 + +NYASH_LLVM_USE_HARNESS=1 $HAKORUNE_BIN --backend llvm "$HAKO_PATH" >/dev/null 2>&1 +actual_exit=$? + +if [[ $actual_exit -eq $EXPECTED_EXIT ]]; then + echo "✅ phase256_p0_split_llvm_exe: PASS (exit=$actual_exit)" + exit 0 +else + echo "❌ phase256_p0_split_llvm_exe: FAIL (expected=$EXPECTED_EXIT, got=$actual_exit)" + exit 1 +fi diff --git a/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_vm.sh b/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_vm.sh new file mode 100644 index 00000000..65df2406 --- /dev/null +++ b/tools/smokes/v2/profiles/integration/apps/phase256_p0_split_vm.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +HAKORUNE_BIN="${HAKORUNE_BIN:-./target/release/hakorune}" +HAKO_PATH="apps/tests/phase256_p0_split_min.hako" +EXPECTED_EXIT=3 + +$HAKORUNE_BIN --backend vm "$HAKO_PATH" >/dev/null 2>&1 +actual_exit=$? + +if [[ $actual_exit -eq $EXPECTED_EXIT ]]; then + echo "✅ phase256_p0_split_vm: PASS (exit=$actual_exit)" + exit 0 +else + echo "❌ phase256_p0_split_vm: FAIL (expected=$EXPECTED_EXIT, got=$actual_exit)" + exit 1 +fi