feat(joinir): Phase 94 - P5b escape full E2E (derived ch + +1/+2)

This commit is contained in:
nyash-codex
2025-12-17 00:59:33 +09:00
parent c213ecc3c0
commit 7ab459503b
25 changed files with 1498 additions and 238 deletions

View File

@ -45,6 +45,8 @@ JoinIR の箱構造と責務、ループ/if の lowering パターンを把握
- 実装Phase 137-2: `src/mir/loop_canonicalizer/mod.rs`
6. Phase 93: ConditionOnly Derived SlotTrim / body-local
- `docs/development/current/main/phases/phase-93/README.md`
7. Phase 94: P5b “完全E2E”escape skip / derived
- `docs/development/current/main/phases/phase-94/README.md`
6. MIR BuilderContext 分割の入口)
- `src/mir/builder/README.md`
7. Scope/BindingIdshadowing・束縛同一性の段階移行

View File

@ -49,6 +49,13 @@
- schedule: `body-init → derived → break` を評価順SSOTとして強制
- Phase 記録(入口): `docs/development/current/main/phases/phase-93/README.md`
## 20251216Phase 94短報
- P5b escape`ch` 再代入 + `i` の +1/+2を “derivedSelect” として扱い、VM E2E を固定。
- 新箱: `BodyLocalDerivedEmitter` + 明示ポリシーstrict で理由付き Fail-Fast
- integration smoke: `tools/smokes/v2/profiles/integration/apps/phase94_p5b_escape_e2e.sh`
- Phase 記録(入口): `docs/development/current/main/phases/phase-94/README.md`
## 20251214現状サマリ
補足docs が増えて迷子になったときの「置き場所ルールSSOT」:

View File

@ -9,8 +9,8 @@ Related:
## 直近JoinIR/selfhost
- **P5b “完全E2E”**escape skip の実ループを end-to-end で固定)
- 現状: 認識(Phase 91+ lowering基盤Phase 92は完了、promotion が未整備で E2E を保留
- 入口: `docs/development/current/main/phases/phase-92/README.md`
- 現状: Phase 94 で VM E2E まで固定済み。次は selfhost 実コード(`apps/selfhost-vm/json_loader.hako`)へ横展開して回帰を減らす。
- 入口: `docs/development/current/main/phases/phase-94/README.md`
- **制御の再帰合成docs-only → dev-only段階投入**
- ねらい: `loop/if` ネストの “構造” を SSOTControlTree/StepTreeで表せるようにする
- 注意: canonicalizer は観測/構造SSOTまでValueId/PHI配線は Normalized 側へ)

View File

@ -107,6 +107,7 @@ flowchart LR
- Error tagsSSOT: [`src/mir/join_ir/lowering/error_tags.rs`](../../../../../src/mir/join_ir/lowering/error_tags.rs)
- Loop Canonicalizer前処理 SSOT: [`src/mir/loop_canonicalizer/mod.rs`](../../../../../src/mir/loop_canonicalizer/mod.rs)
- ConditionOnly Derived SlotPhase 93: [`src/mir/join_ir/lowering/common/condition_only_emitter.rs`](../../../../../src/mir/join_ir/lowering/common/condition_only_emitter.rs)
- BodyLocalDerived SlotPhase 94 / P5b: [`src/mir/join_ir/lowering/common/body_local_derived_emitter.rs`](../../../../../src/mir/join_ir/lowering/common/body_local_derived_emitter.rs)
---

View File

@ -1,6 +1,6 @@
# Phase 93: ConditionOnly Derived SlotTrim / body-local
Status: Active
Status: ✅ DoneP0+P1
Scope: Pattern2Loop with Breakで「ConditionOnlyPHIで運ばない派生値」を毎イテレーション再計算できるようにする。
Related:
- 設計地図(入口): `docs/development/current/main/design/joinir-design-map.md`
@ -27,6 +27,19 @@ JoinIR で “初回の計算値が固定される” 事故を避ける。
- Trim: `src/mir/builder/control_flow/joinir/patterns/trim_loop_lowering.rs`
- ConditionOnly 用 break 生成(反転の有無を明示)
## 成果P1: 箱化・語彙のSSOT化
コミット: `c213ecc3 refactor(mir): Phase 93 リファクタリング - 箱化モジュール化`
- schedule:
- `decide_pattern2_schedule()` に判定を集約し、理由ConditionOnly / body-local / loop-local / defaultをSSOT化
- 決定→生成を分離decision→buildしてテスト容易性を上げた
- ConditionOnlyRecipe:
- `BreakSemantics`WhenMatch / WhenNotMatchを recipe に保持し、break 条件生成の責務を recipe 側へ移動
- `trim_loop_lowering.rs` 側の重複ヘルパーを削除
- Debug新 env 追加なし):
- 既存の `NYASH_JOINIR_DEBUG=1` の範囲で、`[phase93/*]` prefix に統一
## 受け入れ基準P0
- `apps/tests/loop_min_while.hako` が退行しないPattern2 baseline

View File

@ -0,0 +1,6 @@
# Phase 94: P5b “完全E2E” のための `ch` 再代入対応
- 目的: `tools/selfhost/test_pattern5b_escape_minimal.hako` を JoinIRPattern2Breakで VM E2E PASS に固定する。
- 新箱: `BodyLocalDerivedEmitter``src/mir/join_ir/lowering/common/body_local_derived_emitter.rs`)で `ch` を Select ベースの derived 値として表現する。
- 契約: `escape_cond` は base 値で評価し、override は副作用なし・評価順を SSOT 化。`HAKO_JOINIR_STRICT=1` では未対応形を理由付き Fail-Fast。

View File

@ -0,0 +1,20 @@
# JoinIR Merge Coordinator箱の地図
責務: JoinIR 生成物をホスト MIR に統合するフェーズblock/ValueId remap → rewrite → PHI/ExitLine 再接続)。
主な箱と入口:
- `instruction_rewriter.rs` — JoinIR→MIR 命令を書き換えつつブロックを組み立てるメイン導線
- `phi_block_remapper.rs` — PHI の block-id だけを再マップする専用箱ValueId は remapper 済みを前提)
- `loop_header_phi_builder.rs` / `exit_phi_builder.rs` — ヘッダ/出口 PHI の組み立て
- `inline_boundary_injector.rs` — Host↔JoinIR の ValueId 接続Boundary 注入)
- `value_collector.rs` / `block_allocator.rs` — 再マップ前の収集と ID 割り当て
- `tail_call_classifier.rs` — tail call 判定loop_step の末尾呼び出し検出)
Fail-Fast の基本:
- block/ValueId 衝突や PHI 契約違反は握りつぶさず `Err` で止める
- remap は「ValueId remap」→「block-id remap」の順で一貫させるPHI 二度 remap は禁止)
拡張時のチェックリスト:
1. 新しい JoinInst → MIR 変換を追加する場合、`instruction_rewriter` に閉じて追加し、PHI/ExitLine 契約が壊れないか確認。
2. PHI の入力ブロックを触るときは `phi_block_remapper` 経由に寄せて二重 remap を防ぐ。
3. 増やした box/契約はここREADMEに一言追記して入口を明示。

View File

@ -348,25 +348,10 @@ pub(super) fn merge_and_rewrite(
then_bb: local_block_map.get(&then_bb).copied().unwrap_or(then_bb),
else_bb: local_block_map.get(&else_bb).copied().unwrap_or(else_bb),
},
MirInstruction::Phi {
dst,
inputs,
type_hint: None,
} => MirInstruction::Phi {
dst,
// Phase 196: Fix Select expansion PHI - ValueIds are ALREADY remapped by remap_instruction()
// We only need to remap block IDs here (not ValueIds!)
inputs: inputs
.iter()
.map(|(bb, val)| {
let remapped_bb = local_block_map.get(bb).copied().unwrap_or(*bb);
// Phase 196 FIX: Don't double-remap values!
// remapper.remap_instruction() already remapped *val
(remapped_bb, *val)
})
.collect(),
type_hint: None,
},
MirInstruction::Phi { dst, inputs, type_hint } => {
use super::phi_block_remapper::remap_phi_instruction;
remap_phi_instruction(dst, &inputs, type_hint, &local_block_map)
}
other => other,
};

View File

@ -23,6 +23,7 @@ mod instruction_rewriter;
mod loop_header_phi_builder;
mod loop_header_phi_info;
mod merge_result;
mod phi_block_remapper; // Phase 94: Phi block-id remap box
mod tail_call_classifier;
mod value_collector;

View File

@ -0,0 +1,86 @@
//! Box: PHI block remapper (Phase 94 tidy-up)
//!
//! Responsibility: remap only the block ids of MIR `Phi` inputs using the
//! caller-provided `local_block_map`, leaving ValueIds untouched (already
//! remapped upstream). Handles both `type_hint = None` and `Some(...)`.
use std::collections::BTreeMap;
use crate::mir::{BasicBlockId, MirInstruction, MirType, ValueId};
/// Remap a single PHI instruction's block ids.
pub(crate) fn remap_phi_instruction(
dst: ValueId,
inputs: &[(BasicBlockId, ValueId)],
type_hint: Option<MirType>,
local_block_map: &BTreeMap<BasicBlockId, BasicBlockId>,
)-> MirInstruction {
MirInstruction::Phi {
dst,
inputs: remap_phi_inputs(inputs, local_block_map),
type_hint,
}
}
fn remap_phi_inputs(
inputs: &[(BasicBlockId, ValueId)],
local_block_map: &BTreeMap<BasicBlockId, BasicBlockId>,
) -> Vec<(BasicBlockId, ValueId)> {
inputs
.iter()
.map(|(bb, val)| {
let remapped_bb = local_block_map.get(bb).copied().unwrap_or(*bb);
(remapped_bb, *val)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn bb(id: u32) -> BasicBlockId {
BasicBlockId(id)
}
#[test]
fn remaps_blocks_preserves_values_and_type_none() {
let inputs = vec![(bb(1), ValueId(10)), (bb(2), ValueId(11))];
let mut map = BTreeMap::new();
map.insert(bb(1), bb(10));
map.insert(bb(99), bb(100)); // unused entry should not matter
let inst = remap_phi_instruction(ValueId(5), &inputs, None, &map);
match inst {
MirInstruction::Phi { dst, inputs, type_hint } => {
assert_eq!(dst, ValueId(5));
assert_eq!(
inputs,
vec![(bb(10), ValueId(10)), (bb(2), ValueId(11))]
);
assert!(type_hint.is_none());
}
other => panic!("expected Phi, got {:?}", other),
}
}
#[test]
fn remaps_blocks_preserves_type_hint() {
let inputs = vec![(bb(3), ValueId(20))];
let mut map = BTreeMap::new();
map.insert(bb(3), bb(30));
let inst = remap_phi_instruction(ValueId(7), &inputs, Some(MirType::Integer), &map);
match inst {
MirInstruction::Phi { dst, inputs, type_hint } => {
assert_eq!(dst, ValueId(7));
assert_eq!(inputs, vec![(bb(30), ValueId(20))]);
assert_eq!(type_hint, Some(MirType::Integer));
}
other => panic!("expected Phi, got {:?}", other),
}
}
}

View File

@ -20,6 +20,10 @@ pub struct EscapeSkipPatternInfo {
pub counter_name: String,
pub normal_delta: i64,
pub escape_delta: i64,
/// Index of the break-guard `if ... { break }` within the loop body.
pub break_idx: usize,
/// Index of the escape `if` within the loop body.
pub escape_idx: usize,
/// Phase 92 P0-3: The condition expression for conditional increment
/// e.g., `ch == '\\'` for escape sequence handling
pub escape_cond: Box<ASTNode>,
@ -74,6 +78,8 @@ pub fn detect_escape_skip_pattern(body: &[ASTNode]) -> Option<EscapeSkipPatternI
counter_name,
normal_delta,
escape_delta,
break_idx,
escape_idx,
escape_cond, // Phase 92 P0-3: Condition for JoinIR Select
body_stmts,
})

View File

@ -44,10 +44,16 @@
//! Phase 91 P5b: Escape Pattern Recognizer
//! - escape_pattern_recognizer.rs: P5b (escape sequence handling) pattern detection
//! - Extracted from ast_feature_extractor for improved modularity
//!
//! Phase 93/94: Pattern Policies
//! - policies/: Pattern recognition and routing decision (future expansion)
//! - Currently a placeholder directory for future policy box organization
pub(in crate::mir::builder) mod ast_feature_extractor;
pub(in crate::mir::builder) mod policies; // Phase 93/94: Pattern routing policies (future expansion)
pub(in crate::mir::builder) mod body_local_policy; // Phase 92 P3: promotion vs slot routing
pub(in crate::mir::builder) mod escape_pattern_recognizer; // Phase 91 P5b
pub(in crate::mir::builder) mod p5b_escape_derived_policy; // Phase 94: derived `ch` + conditional counter
pub(in crate::mir::builder) mod common_init;
pub(in crate::mir::builder) mod condition_env_builder;
pub(in crate::mir::builder) mod conversion_pipeline;

View File

@ -0,0 +1,323 @@
//! Phase 94: P5b escape + body-local derived policy (Box)
//!
//! Purpose: detect the minimal "escape handling" shape that requires:
//! - loop counter conditional step (skip escape char)
//! - body-local reassignment (e.g. `ch = substring(...)`) represented as a derived Select
//!
//! This is a *route* decision (not a fallback). In strict mode, if we detect
//! a body-local reassignment that matches the P5b entry shape but cannot be
//! converted to a derived recipe, we fail-fast with a reason tag.
use crate::ast::ASTNode;
use crate::config::env::joinir_dev;
use crate::mir::builder::control_flow::joinir::patterns::escape_pattern_recognizer::EscapeSkipPatternInfo;
use crate::mir::join_ir::lowering::common::body_local_derived_emitter::BodyLocalDerivedRecipe;
use crate::mir::join_ir::lowering::error_tags;
#[derive(Debug)]
pub enum P5bEscapeDerivedDecision {
None,
UseDerived(BodyLocalDerivedRecipe),
Reject(String),
}
/// Detect a P5b derived body-local (`ch`) recipe from a Pattern2 loop body.
///
/// Minimal supported shape (SSOT):
/// - `local ch = <expr>` exists at top level
/// - escape if exists (detected by EscapeSkipPatternInfo)
/// - inside the escape if's then-body, after pre-increment:
/// - optional bounds `if i < n { ch = <override_expr> }`
/// - or direct `ch = <override_expr>`
pub fn classify_p5b_escape_derived(
body: &[ASTNode],
loop_var_name: &str,
) -> P5bEscapeDerivedDecision {
let Some(info) = super::ast_feature_extractor::detect_escape_skip_pattern(body) else {
return P5bEscapeDerivedDecision::None;
};
if info.counter_name != loop_var_name {
// Not the loop counter we lower as the JoinIR loop var; ignore to avoid misrouting.
return P5bEscapeDerivedDecision::None;
}
match build_recipe_from_info(body, &info) {
Ok(Some(recipe)) => P5bEscapeDerivedDecision::UseDerived(recipe),
Ok(None) => {
// Escape pattern exists but there is no body-local reassignment to cover.
P5bEscapeDerivedDecision::None
}
Err(e) => {
if joinir_dev::strict_enabled() {
P5bEscapeDerivedDecision::Reject(error_tags::freeze(&e))
} else {
// Non-strict mode: keep legacy behavior (no derived slot); still loggable via dev.
P5bEscapeDerivedDecision::None
}
}
}
}
fn build_recipe_from_info(
body: &[ASTNode],
info: &EscapeSkipPatternInfo,
) -> Result<Option<BodyLocalDerivedRecipe>, String> {
// 1) Find base init: `local ch = <expr>`
let Some(base_init_expr) = find_local_init_expr(body, "ch") else {
return Err("[phase94/body_local_derived/contract/missing_local_init] Missing `local ch = <expr>`".to_string());
};
// 2) Locate escape if and find override assignment to ch
let escape_if = body.get(info.escape_idx).ok_or_else(|| {
format!(
"[phase94/body_local_derived/contract/escape_idx_oob] escape_idx={} out of bounds (body.len={})",
info.escape_idx,
body.len()
)
})?;
let (escape_cond, then_body) = match escape_if {
ASTNode::If { condition, then_body, else_body: _, .. } => (condition.as_ref().clone(), then_body.as_slice()),
other => {
return Err(format!(
"[phase94/body_local_derived/contract/escape_node_kind] escape_idx points to non-If: {:?}",
other
));
}
};
let override_assignment = find_ch_override_in_escape_then(then_body)?;
let Some((bounds_check, override_expr)) = override_assignment else {
return Ok(None);
};
// EscapeSkipPatternInfo uses "escape_delta" for the then-body increment, and "normal_delta" for the unconditional tail.
// For the common P5b shape:
// - escape iteration total delta = escape_delta + normal_delta
// - normal iteration total delta = normal_delta
let recipe = BodyLocalDerivedRecipe {
name: "ch".to_string(),
base_init_expr,
escape_cond,
loop_counter_name: info.counter_name.clone(),
pre_delta: info.escape_delta,
post_delta: info.normal_delta,
bounds_check,
override_expr,
};
Ok(Some(recipe))
}
fn find_local_init_expr(body: &[ASTNode], name: &str) -> Option<ASTNode> {
for node in body {
if let ASTNode::Local { variables, initial_values, .. } = node {
for (var_name, maybe_expr) in variables.iter().zip(initial_values.iter()) {
if var_name == name {
if let Some(expr) = maybe_expr.as_ref() {
return Some((**expr).clone());
}
}
}
}
}
None
}
/// Find `ch = <expr>` either directly or under an inner bounds `if`.
///
/// Returns:
/// - Ok(Some((bounds_opt, override_expr))) when an override assignment exists
/// - Ok(None) when no override assignment exists (no derived slot needed)
/// - Err when an override exists but violates minimal contract
fn find_ch_override_in_escape_then(
then_body: &[ASTNode],
) -> Result<Option<(Option<ASTNode>, ASTNode)>, String> {
// Direct assignment form: `ch = <expr>`
for stmt in then_body {
if let ASTNode::Assignment { target, value, .. } = stmt {
if is_var_named(target.as_ref(), "ch") {
return Ok(Some((None, value.as_ref().clone())));
}
}
}
// Nested bounds form: `if <cond> { ch = <expr> }`
for stmt in then_body {
if let ASTNode::If { condition, then_body, else_body: None, .. } = stmt {
if then_body.len() != 1 {
continue;
}
if let ASTNode::Assignment { target, value, .. } = &then_body[0] {
if is_var_named(target.as_ref(), "ch") {
return Ok(Some((Some(condition.as_ref().clone()), value.as_ref().clone())));
}
}
}
}
Ok(None)
}
fn is_var_named(node: &ASTNode, name: &str) -> bool {
matches!(node, ASTNode::Variable { name: n, .. } if n == name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{BinaryOperator, Span};
fn var(name: &str) -> ASTNode {
ASTNode::Variable {
name: name.to_string(),
span: Span::unknown(),
}
}
fn str_lit(s: &str) -> ASTNode {
ASTNode::Literal {
value: crate::ast::LiteralValue::String(s.to_string()),
span: Span::unknown(),
}
}
fn int_lit(v: i64) -> ASTNode {
ASTNode::Literal {
value: crate::ast::LiteralValue::Integer(v),
span: Span::unknown(),
}
}
fn binop(op: BinaryOperator, lhs: ASTNode, rhs: ASTNode) -> ASTNode {
ASTNode::BinaryOp {
operator: op,
left: Box::new(lhs),
right: Box::new(rhs),
span: Span::unknown(),
}
}
fn assignment(target: ASTNode, value: ASTNode) -> ASTNode {
ASTNode::Assignment {
target: Box::new(target),
value: Box::new(value),
span: Span::unknown(),
}
}
fn method_call(obj: &str, method: &str, args: Vec<ASTNode>) -> ASTNode {
ASTNode::MethodCall {
object: Box::new(var(obj)),
method: method.to_string(),
arguments: args,
span: Span::unknown(),
}
}
#[test]
fn detects_p5b_shape_and_builds_recipe() {
// Body layout:
// 0: local ch = s.substring(i, i+1)
// 1: if ch == "\"" { break }
// 2: if ch == "\\" { i = i + 1; ch = s.substring(i, i+1) }
// 3: i = i + 1
let body = vec![
ASTNode::Local {
variables: vec!["ch".to_string()],
initial_values: vec![Some(Box::new(method_call(
"s",
"substring",
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
)))],
span: Span::unknown(),
},
ASTNode::If {
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\""))),
then_body: vec![ASTNode::Break { span: Span::unknown() }],
else_body: None,
span: Span::unknown(),
},
ASTNode::If {
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\\"))),
then_body: vec![
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
assignment(
var("ch"),
method_call(
"s",
"substring",
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
),
),
],
else_body: None,
span: Span::unknown(),
},
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
];
match classify_p5b_escape_derived(&body, "i") {
P5bEscapeDerivedDecision::UseDerived(recipe) => {
assert_eq!(recipe.name, "ch");
assert_eq!(recipe.loop_counter_name, "i");
assert_eq!(recipe.pre_delta, 1);
assert_eq!(recipe.post_delta, 1);
match recipe.override_expr {
ASTNode::MethodCall { ref method, .. } => assert_eq!(method, "substring"),
other => panic!("expected override MethodCall, got {:?}", other),
}
}
other => panic!("expected UseDerived recipe, got {:?}", other),
}
}
#[test]
fn strict_rejects_when_local_init_missing() {
// escape pattern exists, but `local ch = ...` is absent -> strict should reject
let body = vec![
ASTNode::If {
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\""))),
then_body: vec![ASTNode::Break { span: Span::unknown() }],
else_body: None,
span: Span::unknown(),
},
ASTNode::If {
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\\"))),
then_body: vec![
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
assignment(
var("ch"),
method_call(
"s",
"substring",
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
),
),
],
else_body: None,
span: Span::unknown(),
},
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
];
let prev = std::env::var("HAKO_JOINIR_STRICT").ok();
std::env::set_var("HAKO_JOINIR_STRICT", "1");
let decision = classify_p5b_escape_derived(&body, "i");
if let Some(v) = prev {
std::env::set_var("HAKO_JOINIR_STRICT", v);
} else {
std::env::remove_var("HAKO_JOINIR_STRICT");
}
match decision {
P5bEscapeDerivedDecision::Reject(reason) => {
assert!(
reason.contains("missing_local_init"),
"unexpected reason: {}",
reason
);
}
other => panic!("expected Reject, got {:?}", other),
}
}
}

View File

@ -7,6 +7,7 @@ use crate::mir::join_ir::lowering::carrier_info::{CarrierInfo, CarrierInit};
use crate::mir::join_ir::lowering::condition_env::{ConditionBinding, ConditionEnv};
use super::body_local_policy::{classify_for_pattern2, BodyLocalPolicyDecision};
use crate::mir::join_ir::lowering::common::body_local_slot::ReadOnlyBodyLocalSlot;
use crate::mir::join_ir::lowering::common::body_local_derived_emitter::BodyLocalDerivedRecipe;
use crate::mir::join_ir::lowering::debug_output_box::DebugOutputBox;
use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace;
use crate::mir::join_ir::lowering::loop_body_local_env::LoopBodyLocalEnv;
@ -16,6 +17,7 @@ use crate::mir::join_ir::lowering::loop_update_analyzer::UpdateExpr;
use crate::mir::loop_pattern_detection::error_messages;
use crate::mir::loop_pattern_detection::function_scope_capture::CapturedEnv;
use crate::mir::ValueId;
use super::p5b_escape_derived_policy::{classify_p5b_escape_derived, P5bEscapeDerivedDecision};
use std::collections::BTreeMap;
struct Pattern2DebugLog {
@ -56,6 +58,8 @@ struct Pattern2Inputs {
break_condition_node: ASTNode,
/// Phase 93 P0: ConditionOnly recipe for derived slot recalculation
condition_only_recipe: Option<crate::mir::join_ir::lowering::common::condition_only_emitter::ConditionOnlyRecipe>,
/// Phase 94: BodyLocalDerived recipe for P5b "ch" reassignment + escape counter.
body_local_derived_recipe: Option<BodyLocalDerivedRecipe>,
}
fn prepare_pattern2_inputs(
@ -254,6 +258,7 @@ fn prepare_pattern2_inputs(
read_only_body_local_slot: None,
break_condition_node,
condition_only_recipe: None, // Phase 93 P0: Will be set by apply_trim_and_normalize
body_local_derived_recipe: None, // Phase 94: Will be set after normalization
})
}
@ -807,6 +812,42 @@ impl MirBuilder {
apply_trim_and_normalize(self, condition, _body, &mut inputs, verbose)?;
let analysis_body = normalized_body.as_deref().unwrap_or(_body);
// Phase 94: Detect P5b escape-derived (`ch` reassignment + escape counter).
// Route is explicit; in strict mode, partial matches that require derived support must fail-fast.
match classify_p5b_escape_derived(analysis_body, &inputs.loop_var_name) {
P5bEscapeDerivedDecision::UseDerived(recipe) => {
log.log(
"phase94",
format!(
"Phase 94: Enabled BodyLocalDerived for '{}' (counter='{}', pre_delta={}, post_delta={})",
recipe.name, recipe.loop_counter_name, recipe.pre_delta, recipe.post_delta
),
);
inputs.body_local_derived_recipe = Some(recipe);
}
P5bEscapeDerivedDecision::Reject(reason) => {
return Err(format!("[cf_loop/pattern2] {}", reason));
}
P5bEscapeDerivedDecision::None => {
// Strict-only guard: if we see a body-local reassignment to `ch`, don't silently miscompile.
let has_ch_reassign = analysis_body.iter().any(|n| match n {
ASTNode::Assignment { target, .. } => matches!(
target.as_ref(),
ASTNode::Variable { name, .. } if name == "ch"
),
_ => false,
});
if crate::config::env::joinir_dev::strict_enabled() && has_ch_reassign {
return Err(format!(
"[cf_loop/pattern2] {}",
crate::mir::join_ir::lowering::error_tags::freeze(
"[phase94/body_local_derived/contract/unhandled_reassign] Body-local reassignment to 'ch' detected but not supported by Phase 94 recipe"
)
));
}
}
}
use crate::mir::join_ir::lowering::loop_update_analyzer::LoopUpdateAnalyzer;
let carrier_updates = LoopUpdateAnalyzer::analyze_carrier_updates(
analysis_body,
@ -894,6 +935,7 @@ impl MirBuilder {
join_value_space,
skeleton,
condition_only_recipe: inputs.condition_only_recipe.as_ref(), // Phase 93 P0
body_local_derived_recipe: inputs.body_local_derived_recipe.as_ref(), // Phase 94
};
let (join_module, fragment_meta) = match lower_loop_with_break_minimal(lowering_inputs) {

View File

@ -0,0 +1,235 @@
# JoinIR Pattern Policies - ルーティング箱の責務
## 概要
このディレクトリには、Pattern認識とルーティングpolicy決定を行う「箱」が格納されています。
## Policy箱の責務
### ルーティング決定
- 入力: LoopSkeleton、break条件、carrier情報
- 出力: 適用可能なPatternPattern2, Pattern3, etc.とLoweringResult
- 判断基準: パターンマッチング条件Trim, ConditionalStep, Escape, etc.
### Lowering Result生成
- Pattern固有の情報ConditionOnlyRecipe, ConditionalStepInfo, etc.)を生成
- CarrierInfo拡張promoted variables, trim_helper, etc.
- ConditionBinding設定
## Policy箱の候補将来の整理対象
### trim_loop_lowering.rs (Phase 180/92/93)
**現在の場所**: `patterns/trim_loop_lowering.rs`
**責務**: Trimパターン認識とConditionOnlyルーティング
**判断基準**:
- LoopBodyLocal変数がTrim条件whitespace check
- break条件がConditionOnly毎イテレーション再計算
**出力**:
- `TrimLoweringResult` - condition, carrier_info, condition_only_recipe
**将来的な整理**:
- Trimパターン判断ロジックをpolicies/へ移動検討
- 現在はpatterns/直下に配置Phase 180統合済み
---
### p5b_escape_derived_policy.rs (Phase 94)
**現在の場所**: `patterns/p5b_escape_derived_policy.rs`
**責務**: P5b escapeパターン認識とBodyLocalDerivedルーティング
**判断基準**:
- body-local変数の再代入例: `ch = s.substring(...)`
- escape skip条件例: `if ch == "\\" { i = i + 2 }`
- loop変数の条件付き更新
**出力**:
- `P5bEscapeDerivedDecision::UseDerived(BodyLocalDerivedRecipe)` - escape recipe
- `P5bEscapeDerivedDecision::Reject(String)` - 検出失敗理由
- `P5bEscapeDerivedDecision::None` - 該当なし
**将来的な整理**:
- policies/へ移動してpolicy箱として統一
- 現在はpatterns/直下に配置
---
### body_local_policy.rs
**現在の場所**: `patterns/body_local_policy.rs`
**責務**: Body-local変数の昇格判断
**判断基準**:
- body-local変数がloop carrier候補か
- 昇格可能なパターンかTrim, Escape, etc.
**将来的な整理**:
- policies/へ移動してpolicy箱として統一
---
## 設計原則
### 単一判断の原則
- 各policy箱は1つのパターン判断のみ
- 複数パターンの判断は別のpolicy箱に委譲
### 非破壊的判断
- 入力を変更しない
- 判断結果をResultで返す`Ok(Some(result))` / `Ok(None)` / `Err(msg)`
### Fail-Fast
- パターンマッチング失敗は即座に`Ok(None)`を返す
- エラーは明示的に`Err(msg)`
- Reject理由は`error_tags::freeze()`でタグ付与
### Decision型の統一
- `Decision` enumNone / Use(...) / Reject(String))を統一パターンとして使用
- 例: `P5bEscapeDerivedDecision`, `TrimDecision`(将来)
## 使用パターン
### Pattern2ルーティング現在のパターン
```rust
// Step 1: P5b escapeパターン判断
match classify_p5b_escape_derived(body, loop_var_name) {
P5bEscapeDerivedDecision::UseDerived(recipe) => {
// P5b escape適用
return lower_with_p5b_escape(recipe, ...);
}
P5bEscapeDerivedDecision::Reject(reason) => {
return Err(reason);
}
P5bEscapeDerivedDecision::None => {
// 次のパターンへ
}
}
// Step 2: Trimパターン判断
if let Some(trim_result) = TrimLoopLowerer::try_lower_trim_pattern(...)? {
// Trimパターン適用
return Ok(trim_result);
}
// Step 3: デフォルトパターン
Ok(default_pattern2_lowering(...))
```
### 将来の統一パターンpolicies/移動後)
```rust
use patterns::policies::{P5bEscapePolicy, TrimPolicy, ConditionalStepPolicy};
// Policy boxの統一インターフェース
trait PatternPolicy {
type Decision;
fn classify(&self, context: &PatternContext) -> Self::Decision;
}
// ルーティングパイプライン
let decision = P5bEscapePolicy.classify(&ctx)
.or_else(|| TrimPolicy.classify(&ctx))
.or_else(|| ConditionalStepPolicy.classify(&ctx))
.unwrap_or(DefaultDecision);
```
## デバッグ
### ログprefixの統一
- `[policy/p5b-escape]` - P5b escape判断
- `[policy/trim]` - Trim判断
- `[policy/conditional-step]` - ConditionalStep判断
### 環境変数
- `HAKO_JOINIR_DEBUG=1` - JoinIR全般のデバッグログ
- `joinir_dev_enabled()` - 既存の制御機構
### デバッグ出力例
```rust
if joinir_dev_enabled() {
eprintln!("[policy/p5b-escape] Detected escape pattern: {:?}", info);
}
```
## 命名規則
### ファイル命名
- `<パターン名>_policy.rs` - パターン判断policy箱
- 例: `trim_policy.rs`, `escape_policy.rs`, `conditional_step_policy.rs`
### 型命名
- `<パターン名>Decision` - 判断結果型
- `<パターン名>Policy` - policy箱の構造体将来
### 列挙型パターン
```rust
pub enum Decision {
None, // 該当なし
Use(Recipe), // 適用可能
Reject(String), // 検出失敗
}
```
## 将来の拡張
### Phase 95以降の候補
- `trim_policy.rs` - Trimパターン判断をpolicies/へ移動
- `escape_policy.rs` - P5b escapeを統一インターフェースに
- `array_loop_policy.rs` - 配列ループパターン判断
- `map_loop_policy.rs` - Mapループパターン判断
- `conditional_step_policy.rs` - ConditionalStep独立箱化
### 段階的な移行計画
#### Phase 1: ディレクトリ準備(今回)
- policies/ディレクトリ作成 ✅
- README.md作成 ✅
- mod.rs作成 ✅
#### Phase 2: 既存policy箱の移動将来
- `p5b_escape_derived_policy.rs``policies/escape_policy.rs`
- `body_local_policy.rs``policies/body_local_policy.rs`
- trim関連ロジック抽出 → `policies/trim_policy.rs`
#### Phase 3: インターフェース統一(将来)
- `PatternPolicy` trait定義
- Decision型の統一
- ルーティングパイプラインの実装
### 拡張時の注意
- README.mdにpolicy箱の責務を追加
- Decision型を統一パターンに従う
- 既存のpolicy箱との一貫性を保つ
- Fail-Fastタグを明記
## 参考資料
### 関連ドキュメント
- [JoinIR アーキテクチャ概要](../../../../development/current/main/joinir-architecture-overview.md)
- [JoinIR 設計マップ](../../../../development/current/main/design/joinir-design-map.md)
- [Pattern Pipeline](../pattern_pipeline.rs) - パターン判断の統合処理
- [Escape Pattern Recognizer](../escape_pattern_recognizer.rs) - escape検出ロジック
### Phase Log
- [Phase 92 Log](../../../../development/current/main/phases/phase-92/) - ConditionalStep
- [Phase 93 Log](../../../../development/current/main/phases/phase-93/) - ConditionOnly
- [Phase 94 Log](../../../../development/current/main/phases/phase-94/) - BodyLocalDerived
## 設計哲学Box Theory
### 箱理論の適用
- **箱にする**: Policy判断を独立した箱に分離
- **境界を作る**: Decisionを統一インターフェースに
- **戻せる**: 段階的移行で既存コードを壊さない
- **見える化**: ログprefixで判断過程を可視化
### Fail-Fast原則の徹底
- フォールバック処理は原則禁止
- Reject理由を明示的に返す
- エラータグで根本原因を追跡可能に
### 単一責任の箱
- 1つのpolicy箱 = 1つのパターン判断
- 複数パターンの組み合わせは上位層で制御
- policy箱同士は独立・疎結合

View File

@ -0,0 +1,29 @@
//! JoinIR Pattern Policies - パターン認識とルーティング決定
//!
//! ## 概要
//! このモジュールには、Pattern認識とルーティングpolicy決定を行う「箱」が格納されています。
//!
//! ## Policy箱の責務
//! - パターン認識: LoopSkeletonから特定のパターンTrim, Escape, etc.)を検出
//! - ルーティング決定: 適用可能なLoweringパターンを決定
//! - Recipe生成: Pattern固有の情報ConditionOnlyRecipe, BodyLocalDerivedRecipe, etc.)を生成
//!
//! ## 設計原則
//! - **単一判断の原則**: 各policy箱は1つのパターン判断のみ
//! - **非破壊的判断**: 入力を変更せず、Decision型で結果を返す
//! - **Fail-Fast**: パターンマッチング失敗は即座にReject/Noneを返す
//!
//! ## 将来の拡張
//! 現在はpolicies/ディレクトリの準備段階です。
//! 既存のpolicy関連ファイルp5b_escape_derived_policy.rs, body_local_policy.rs等
//! patterns/直下に配置されていますが、将来的にこのディレクトリへ移動する予定です。
//!
//! ### 段階的な移行計画
//! - Phase 1: ディレクトリ準備(今回) ✅
//! - Phase 2: 既存policy箱の移動将来
//! - Phase 3: インターフェース統一(将来)
//!
//! 詳細は [README.md](README.md) を参照してください。
// 現在は空モジュール(将来の拡張用)
// 既存のpolicy関連ファイルは親モジュールpatterns/)に配置されています

View File

@ -7,6 +7,7 @@ pub mod conditional_step_emitter; // Phase 92 P1-1: ConditionalStep emission mod
pub mod body_local_slot; // Phase 92 P3: Read-only body-local slot for conditions
pub mod dual_value_rewriter; // Phase 246-EX/247-EX: name-based dual-value rewrites
pub mod condition_only_emitter; // Phase 93 P0: ConditionOnly (Derived Slot) recalculation
pub mod body_local_derived_emitter; // Phase 94: Derived body-local (P5b escape "ch" reassignment)
use crate::mir::loop_form::LoopForm;
use crate::mir::query::{MirQuery, MirQueryBox};

View File

@ -0,0 +1,13 @@
# JoinIR Lowering Common Boxes
このディレクトリは「JoinIR lowerer の縫い目」を担う小箱の入口だよ。責務を混ぜないためのメモ。
- `conditional_step_emitter.rs` — ConditionalStep 専用の更新生成P5b の i+=1/2 など)
- `condition_only_emitter.rs` — ConditionOnly derived slot の再計算Phase 93
- `body_local_slot.rs` — 読み取り専用 body-local を条件式で使うためのガード付き抽出Phase 92
- `body_local_derived_emitter.rs` — 再代入される body-localP5b `ch`)を Select で統合し、loop-var の +1/+2 も同時に出すPhase 94
- `dual_value_rewriter.rs` — name ベースの dual-value 書き換えBodyLocal vs Carrierを一箇所に閉じ込める
Fail-Fast 原則:
- 未対応 shape は error_tags::freeze などで理由付き停止(サイレント回避禁止)。
- フォールバック臭を出さず、ポリシーで「使う/使わない/拒否」を明示する。

View File

@ -0,0 +1,445 @@
//! Phase 94: BodyLocalDerivedEmitter (P5b "complete E2E" for body-local reassignment)
//!
//! Goal: represent a loop body-local that is conditionally overridden (e.g. `ch`)
//! as a pure derived JoinIR value, without PHI-carrying it across iterations.
//!
//! This is intentionally minimal and fail-fast:
//! - Supports a single derived variable recipe (typically `ch`)
//! - Supports the P5b escape shape where:
//! - `ch_top` is computed by a top-level `local ch = ...`
//! - `escape_cond` is computed from `ch_top` (e.g. `ch == "\\"`)
//! - `ch` is conditionally overridden after a pre-increment of the loop counter
//! - loop counter update is `else_delta` always + `pre_delta` when `escape_cond`
//!
//! The extraction/policy lives on the Pattern2 builder side; this module only
//! emits JoinIR given a validated recipe.
use crate::ast::ASTNode;
use crate::mir::join_ir::lowering::condition_env::ConditionEnv;
use crate::mir::join_ir::lowering::condition_lowerer::lower_condition_to_joinir;
use crate::mir::join_ir::lowering::loop_body_local_env::LoopBodyLocalEnv;
use crate::mir::join_ir::lowering::method_call_lowerer::MethodCallLowerer;
use crate::mir::join_ir::{BinOpKind, ConstValue, JoinInst, MirLikeInst};
use crate::mir::{MirType, ValueId};
/// SSOT recipe: "derived body-local" + loop-counter pre/post increment semantics.
#[derive(Debug, Clone)]
pub struct BodyLocalDerivedRecipe {
/// Derived variable name to register into LoopBodyLocalEnv (e.g. "ch").
pub name: String,
/// Base init expression from `local name = <expr>` (diagnostics only; lowering is done elsewhere).
pub base_init_expr: ASTNode,
/// Escape condition evaluated on the *base* value (e.g. `ch == "\\"`).
pub escape_cond: ASTNode,
/// Loop counter variable name (typically the loop var, e.g. "i").
pub loop_counter_name: String,
/// Pre-increment applied inside escape branch before override expr (e.g. `i = i + 1`).
pub pre_delta: i64,
/// Post-increment applied at the loop tail (e.g. the unconditional `i = i + 1`).
pub post_delta: i64,
/// Optional bounds check after the pre-increment (e.g. `i < n` in the escape branch).
/// This is evaluated with `loop_counter_name` bound to `i_pre` (= i + pre_delta).
pub bounds_check: Option<ASTNode>,
/// Override expression (e.g. `s.substring(i, i + 1)` in the escape branch),
/// evaluated with `loop_counter_name` bound to `i_pre`.
pub override_expr: ASTNode,
}
#[derive(Debug, Clone, Copy)]
pub struct BodyLocalDerivedEmission {
/// ValueId of the escape condition (truthy) evaluated on the base body-local value.
pub escape_cond_id: ValueId,
/// ValueId of the loop counter next value (includes conditional pre-delta + post-delta).
pub loop_counter_next: ValueId,
}
/// Generation box: emits JoinIR for the derived slot and the conditional loop-counter next.
pub struct BodyLocalDerivedEmitter;
impl BodyLocalDerivedEmitter {
pub fn emit(
recipe: &BodyLocalDerivedRecipe,
alloc_value: &mut dyn FnMut() -> ValueId,
env: &ConditionEnv,
body_local_env: &mut LoopBodyLocalEnv,
instructions: &mut Vec<JoinInst>,
) -> Result<BodyLocalDerivedEmission, String> {
let base_value = body_local_env.get(&recipe.name).ok_or_else(|| {
format!(
"[phase94/body_local_derived/contract/missing_base_value] Missing base ValueId for body-local '{}' in LoopBodyLocalEnv",
recipe.name
)
})?;
if recipe.pre_delta < 0 || recipe.post_delta < 0 {
return Err(format!(
"[phase94/body_local_derived/contract/negative_delta] Invalid deltas: pre_delta={}, post_delta={} (must be >= 0)",
recipe.pre_delta, recipe.post_delta
));
}
if recipe.post_delta == 0 {
return Err(format!(
"[phase94/body_local_derived/contract/post_delta_zero] post_delta=0 is not supported (would stall loop counter '{}')",
recipe.loop_counter_name
));
}
// ------------------------------------------------------------
// 1) escape_cond evaluated on base `name` (ch_top)
// ------------------------------------------------------------
let (escape_cond_id, escape_cond_insts) = lower_condition_to_joinir(
&recipe.escape_cond,
alloc_value,
env,
Some(body_local_env),
)?;
instructions.extend(escape_cond_insts);
// ------------------------------------------------------------
// 2) i_pre = i + pre_delta (used for bounds + override expr)
// ------------------------------------------------------------
let counter_cur = env.get(&recipe.loop_counter_name).ok_or_else(|| {
format!(
"[phase94/body_local_derived/contract/missing_loop_counter] ConditionEnv missing loop counter '{}'",
recipe.loop_counter_name
)
})?;
let counter_pre = if recipe.pre_delta == 0 {
counter_cur
} else {
let pre_const = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::Const {
dst: pre_const,
value: ConstValue::Integer(recipe.pre_delta),
}));
let pre_sum = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: pre_sum,
op: BinOpKind::Add,
lhs: counter_cur,
rhs: pre_const,
}));
pre_sum
};
// ------------------------------------------------------------
// 3) override_guard = escape_cond && bounds_check(i_pre) (if present)
// ------------------------------------------------------------
let override_guard = if let Some(bounds_ast) = &recipe.bounds_check {
let mut env_pre = env.clone();
env_pre.insert(recipe.loop_counter_name.clone(), counter_pre);
let (bounds_ok, bounds_insts) =
lower_condition_to_joinir(bounds_ast, alloc_value, &env_pre, Some(body_local_env))?;
instructions.extend(bounds_insts);
let guard = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: guard,
op: BinOpKind::And,
lhs: escape_cond_id,
rhs: bounds_ok,
}));
guard
} else {
escape_cond_id
};
// ------------------------------------------------------------
// 4) override_val (evaluated with loop_counter_name := i_pre)
// ------------------------------------------------------------
let mut env_pre = env.clone();
env_pre.insert(recipe.loop_counter_name.clone(), counter_pre);
let override_val = Self::lower_override_expr(
&recipe.override_expr,
alloc_value,
&env_pre,
body_local_env,
instructions,
)?;
// ------------------------------------------------------------
// 5) derived = Select(override_guard, override_val, base_value)
// ------------------------------------------------------------
let derived = alloc_value();
instructions.push(JoinInst::Select {
dst: derived,
cond: override_guard,
then_val: override_val,
else_val: base_value,
type_hint: Some(MirType::String),
});
body_local_env.insert(recipe.name.clone(), derived);
// ------------------------------------------------------------
// 6) loop_counter_next = Select(escape_cond, i + (pre+post), i + post)
// ------------------------------------------------------------
let then_delta = recipe.pre_delta + recipe.post_delta;
let else_delta = recipe.post_delta;
if then_delta == else_delta {
return Err(format!(
"[phase94/body_local_derived/contract/equal_total_deltas] then_delta == else_delta == {} for loop counter '{}' (pre_delta={}, post_delta={})",
then_delta, recipe.loop_counter_name, recipe.pre_delta, recipe.post_delta
));
}
let then_const = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::Const {
dst: then_const,
value: ConstValue::Integer(then_delta),
}));
let then_sum = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: then_sum,
op: BinOpKind::Add,
lhs: counter_cur,
rhs: then_const,
}));
let else_const = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::Const {
dst: else_const,
value: ConstValue::Integer(else_delta),
}));
let else_sum = alloc_value();
instructions.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: else_sum,
op: BinOpKind::Add,
lhs: counter_cur,
rhs: else_const,
}));
let counter_next = alloc_value();
instructions.push(JoinInst::Select {
dst: counter_next,
cond: escape_cond_id,
then_val: then_sum,
else_val: else_sum,
type_hint: Some(MirType::Integer),
});
Ok(BodyLocalDerivedEmission {
escape_cond_id,
loop_counter_next: counter_next,
})
}
fn lower_override_expr(
expr: &ASTNode,
alloc_value: &mut dyn FnMut() -> ValueId,
env: &ConditionEnv,
body_local_env: &LoopBodyLocalEnv,
instructions: &mut Vec<JoinInst>,
) -> Result<ValueId, String> {
match expr {
ASTNode::MethodCall {
object,
method,
arguments,
..
} => {
let recv_name = match object.as_ref() {
ASTNode::Variable { name, .. } => name,
_ => {
return Err(format!(
"[phase94/body_local_derived/contract/override_receiver] Override receiver must be a variable: {:?}",
object
));
}
};
let recv_val = body_local_env
.get(recv_name)
.or_else(|| env.get(recv_name))
.ok_or_else(|| {
format!(
"[phase94/body_local_derived/contract/override_receiver_missing] Receiver '{}' not found in envs",
recv_name
)
})?;
MethodCallLowerer::lower_for_init(
recv_val,
method,
arguments,
alloc_value,
env,
body_local_env,
instructions,
)
}
_ => Err(format!(
"[phase94/body_local_derived/contract/override_expr_kind] Override expr must be MethodCall (pure): {:?}",
expr
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{BinaryOperator, LiteralValue, Span};
use crate::mir::join_ir::lowering::condition_env::ConditionEnv;
fn var(name: &str) -> ASTNode {
ASTNode::Variable {
name: name.to_string(),
span: Span::unknown(),
}
}
fn str_lit(s: &str) -> ASTNode {
ASTNode::Literal {
value: LiteralValue::String(s.to_string()),
span: Span::unknown(),
}
}
fn int_lit(i: i64) -> ASTNode {
ASTNode::Literal {
value: LiteralValue::Integer(i),
span: Span::unknown(),
}
}
fn binop(op: BinaryOperator, left: ASTNode, right: ASTNode) -> ASTNode {
ASTNode::BinaryOp {
operator: op,
left: Box::new(left),
right: Box::new(right),
span: Span::unknown(),
}
}
fn method_call(obj: &str, method: &str, args: Vec<ASTNode>) -> ASTNode {
ASTNode::MethodCall {
object: Box::new(var(obj)),
method: method.to_string(),
arguments: args,
span: Span::unknown(),
}
}
#[test]
fn derived_emits_select_and_updates_env() {
// Pattern:
// base: ch_top already computed and in body_local_env
// escape_cond: ch == "\\"
// bounds: i < n (evaluated with i_pre)
// override_expr: s.substring(i, i + 1) (evaluated with i_pre)
let recipe = BodyLocalDerivedRecipe {
name: "ch".to_string(),
base_init_expr: method_call(
"s",
"substring",
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
),
escape_cond: binop(BinaryOperator::Equal, var("ch"), str_lit("\\")),
loop_counter_name: "i".to_string(),
pre_delta: 1,
post_delta: 1,
bounds_check: Some(binop(BinaryOperator::Less, var("i"), var("n"))),
override_expr: method_call(
"s",
"substring",
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
),
};
let mut env = ConditionEnv::new();
env.insert("i".to_string(), ValueId(10));
env.insert("n".to_string(), ValueId(11));
env.insert("s".to_string(), ValueId(12));
let mut body_env = LoopBodyLocalEnv::new();
body_env.insert("ch".to_string(), ValueId(20));
let mut next = 100u32;
let mut alloc_value = || {
let id = ValueId(next);
next += 1;
id
};
let mut insts = Vec::new();
let out = BodyLocalDerivedEmitter::emit(&recipe, &mut alloc_value, &env, &mut body_env, &mut insts)
.expect("emit should succeed");
// Env must now point `ch` to the derived value (not the base 20).
let ch_now = body_env.get("ch").expect("ch should exist");
assert_ne!(ch_now, ValueId(20));
assert!(insts.iter().any(|i| matches!(i, JoinInst::Select { .. })));
assert_ne!(out.loop_counter_next, ValueId(10));
}
#[test]
fn fails_fast_on_equal_total_deltas() {
let recipe = BodyLocalDerivedRecipe {
name: "ch".to_string(),
base_init_expr: str_lit("x"),
escape_cond: binop(BinaryOperator::Equal, var("ch"), str_lit("\\")),
loop_counter_name: "i".to_string(),
pre_delta: 0,
post_delta: 1,
bounds_check: None,
override_expr: method_call("s", "substring", vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))]),
};
let mut env = ConditionEnv::new();
env.insert("i".to_string(), ValueId(10));
env.insert("s".to_string(), ValueId(12));
let mut body_env = LoopBodyLocalEnv::new();
body_env.insert("ch".to_string(), ValueId(20));
let mut next = 200u32;
let mut alloc_value = || {
let id = ValueId(next);
next += 1;
id
};
let mut insts = Vec::new();
let err = BodyLocalDerivedEmitter::emit(&recipe, &mut alloc_value, &env, &mut body_env, &mut insts)
.unwrap_err();
assert!(err.contains("equal_total_deltas"));
}
#[test]
fn fails_fast_on_unsupported_override_expr_kind() {
let recipe = BodyLocalDerivedRecipe {
name: "ch".to_string(),
base_init_expr: str_lit("x"),
escape_cond: binop(BinaryOperator::Equal, var("ch"), str_lit("\\")),
loop_counter_name: "i".to_string(),
pre_delta: 1,
post_delta: 1,
bounds_check: None,
// Literal is not allowed (must be MethodCall)
override_expr: str_lit("!"),
};
let mut env = ConditionEnv::new();
env.insert("i".to_string(), ValueId(1));
env.insert("s".to_string(), ValueId(2));
let mut body_env = LoopBodyLocalEnv::new();
body_env.insert("ch".to_string(), ValueId(3));
let mut next = 10u32;
let mut alloc_value = || {
let id = ValueId(next);
next += 1;
id
};
let mut insts = Vec::new();
let err = BodyLocalDerivedEmitter::emit(&recipe, &mut alloc_value, &env, &mut body_env, &mut insts)
.unwrap_err();
assert!(
err.contains("override_expr_kind"),
"expected override_expr_kind contract violation, got: {err}"
);
}
}

View File

@ -67,6 +67,7 @@ use crate::mir::join_ir::lowering::carrier_update_emitter::{
};
// Phase 92 P2-1: Import ConditionalStep emitter from dedicated module
use crate::mir::join_ir::lowering::common::conditional_step_emitter::emit_conditional_step_update;
use crate::mir::join_ir::lowering::common::body_local_derived_emitter::BodyLocalDerivedRecipe;
use crate::mir::join_ir::lowering::condition_to_joinir::ConditionEnv;
use crate::mir::loop_canonicalizer::UpdateKind;
use crate::mir::join_ir::lowering::join_value_space::JoinValueSpace;
@ -74,7 +75,7 @@ use crate::mir::join_ir::lowering::loop_body_local_env::LoopBodyLocalEnv;
use crate::mir::join_ir::lowering::loop_scope_shape::LoopScopeShape;
use crate::mir::join_ir::lowering::loop_update_analyzer::UpdateExpr;
use crate::mir::join_ir::lowering::step_schedule::{
build_pattern2_schedule, Pattern2ScheduleContext, Pattern2StepKind,
build_pattern2_schedule_from_decision, decide_pattern2_schedule, Pattern2StepKind,
};
use crate::mir::join_ir::lowering::update_env::UpdateEnv;
use crate::mir::loop_canonicalizer::LoopSkeleton;
@ -107,6 +108,8 @@ pub(crate) struct LoopWithBreakLoweringInputs<'a> {
pub skeleton: Option<&'a LoopSkeleton>,
/// Phase 93 P0: ConditionOnly recipe for derived slot recalculation
pub condition_only_recipe: Option<&'a crate::mir::join_ir::lowering::common::condition_only_emitter::ConditionOnlyRecipe>,
/// Phase 94: BodyLocalDerived recipe (P5b escape `ch` reassignment + conditional counter).
pub body_local_derived_recipe: Option<&'a BodyLocalDerivedRecipe>,
}
/// Lower Pattern 2 (Loop with Conditional Break) to JoinIR
@ -184,6 +187,7 @@ pub(crate) fn lower_loop_with_break_minimal(
join_value_space,
skeleton,
condition_only_recipe,
body_local_derived_recipe,
} = inputs;
let mut body_local_env = body_local_env;
@ -377,12 +381,13 @@ pub(crate) fn lower_loop_with_break_minimal(
// Decide evaluation order (header/body-init/break/updates/tail) up-front.
// Phase 93 P0: Pass condition_only_recipe existence to schedule context.
// When recipe exists, body-init must happen before break check.
let schedule_ctx = Pattern2ScheduleContext::from_env(
let schedule_decision = decide_pattern2_schedule(
body_local_env.as_ref().map(|env| &**env),
carrier_info,
condition_only_recipe.is_some(),
body_local_derived_recipe.is_some(),
);
let schedule = build_pattern2_schedule(&schedule_ctx);
let schedule = build_pattern2_schedule_from_decision(&schedule_decision);
// Collect fragments per step; append them according to the schedule below.
let mut header_block: Vec<JoinInst> = Vec::new();
@ -390,6 +395,7 @@ pub(crate) fn lower_loop_with_break_minimal(
let mut break_block: Vec<JoinInst> = Vec::new();
let mut carrier_update_block: Vec<JoinInst> = Vec::new();
let mut tail_block: Vec<JoinInst> = Vec::new();
let mut loop_var_next_override: Option<ValueId> = None; // Phase 94: conditional loop-var step (P5b)
// ------------------------------------------------------------------
// Natural Exit Condition Check (Phase 169: from AST)
@ -523,6 +529,28 @@ pub(crate) fn lower_loop_with_break_minimal(
cond: Some(break_cond_value), // Phase 170-B: Use lowered condition
});
// ------------------------------------------------------------------
// Phase 94: P5b escape derived body-local + conditional loop-var update
// ------------------------------------------------------------------
if let (Some(recipe), Some(ref mut body_env)) = (body_local_derived_recipe, body_local_env.as_mut())
{
use crate::mir::join_ir::lowering::common::body_local_derived_emitter::BodyLocalDerivedEmitter;
let emission = BodyLocalDerivedEmitter::emit(
recipe,
&mut alloc_value,
env,
body_env,
&mut carrier_update_block,
)?;
loop_var_next_override = Some(emission.loop_counter_next);
dev_log.log_if_enabled(|| {
format!(
"[phase94/body_local_derived] enabled: name='{}', loop_counter='{}', loop_counter_next={:?}",
recipe.name, recipe.loop_counter_name, emission.loop_counter_next
)
});
}
// ------------------------------------------------------------------
// Loop Body: Compute updated values for all carriers
// ------------------------------------------------------------------
@ -684,20 +712,25 @@ pub(crate) fn lower_loop_with_break_minimal(
// Phase 176-3: Multi-carrier support - tail call includes all updated carriers
// Call(loop_step, [i_next, carrier1_next, carrier2_next, ...]) // tail recursion
// Note: We need to emit i_next = i + 1 first for the loop variable
let const_1 = alloc_value();
tail_block.push(JoinInst::Compute(MirLikeInst::Const {
dst: const_1,
value: ConstValue::Integer(1),
}));
// Phase 94: i_next may be overridden (escape skip: +2 vs +1).
let i_next = if let Some(i_next) = loop_var_next_override {
i_next
} else {
let const_1 = alloc_value();
tail_block.push(JoinInst::Compute(MirLikeInst::Const {
dst: const_1,
value: ConstValue::Integer(1),
}));
let i_next = alloc_value();
tail_block.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: i_next,
op: BinOpKind::Add,
lhs: i_param,
rhs: const_1,
}));
let i_next = alloc_value();
tail_block.push(JoinInst::Compute(MirLikeInst::BinOp {
dst: i_next,
op: BinOpKind::Add,
lhs: i_param,
rhs: const_1,
}));
i_next
};
let mut tail_call_args = vec![i_next];
tail_call_args.extend(updated_carrier_values.iter().copied());

View File

@ -147,6 +147,7 @@ fn test_pattern2_header_condition_via_exprlowerer() {
join_value_space: &mut join_value_space,
skeleton: None, // Phase 92 P0-3: skeleton=None for backward compatibility
condition_only_recipe: None, // Phase 93 P0: None for normal loops
body_local_derived_recipe: None, // Phase 94: None for normal loops
});
assert!(result.is_ok(), "ExprLowerer header path should succeed");

View File

@ -39,6 +39,60 @@ use crate::mir::ValueId;
use crate::runtime::core_box_ids::CoreMethodId;
use super::condition_env::ConditionEnv;
use super::loop_body_local_env::LoopBodyLocalEnv;
use super::debug_output_box::DebugOutputBox;
/// Box: resolves method call arguments with cascading lookup (body-local → condition).
struct CascadingArgResolver<'a> {
cond_env: &'a ConditionEnv,
body_local_env: &'a LoopBodyLocalEnv,
debug: DebugOutputBox,
}
impl<'a> CascadingArgResolver<'a> {
fn new(cond_env: &'a ConditionEnv, body_local_env: &'a LoopBodyLocalEnv) -> Self {
Self {
cond_env,
body_local_env,
debug: DebugOutputBox::new_dev("method_call_lowerer"),
}
}
fn resolve(
&self,
expr: &ASTNode,
alloc_value: &mut dyn FnMut() -> ValueId,
instructions: &mut Vec<JoinInst>,
) -> Result<ValueId, String> {
match expr {
// Variables - check body_local_env first, then cond_env
ASTNode::Variable { name, .. } => {
if let Some(vid) = self.body_local_env.get(name) {
self.debug
.log_if_enabled(|| format!("Arg '{}' found in LoopBodyLocalEnv → {:?}", name, vid));
Ok(vid)
} else if let Some(vid) = self.cond_env.get(name) {
self.debug
.log_if_enabled(|| format!("Arg '{}' found in ConditionEnv → {:?}", name, vid));
Ok(vid)
} else {
Err(format!(
"Variable '{}' not found in LoopBodyLocalEnv or ConditionEnv",
name
))
}
}
// Non-variables delegate to value expression lowering (body-local not needed)
_ => super::condition_lowerer::lower_value_expression(
expr,
alloc_value,
self.cond_env,
None, // body-local not used for generic expressions
instructions,
),
}
}
}
/// Phase 224-B: MethodCall Lowerer Box
///
@ -167,18 +221,15 @@ impl MethodCallLowerer {
/// - Arguments can reference previously defined body-local variables
/// - Checks `body_local_env` first, then `cond_env` for variable resolution
/// - Example: `local digit_pos = digits.indexOf(ch)` where `ch` is body-local
pub fn lower_for_init<F>(
pub fn lower_for_init(
recv_val: ValueId,
method_name: &str,
args: &[ASTNode],
alloc_value: &mut F,
alloc_value: &mut dyn FnMut() -> ValueId,
cond_env: &ConditionEnv,
body_local_env: &super::loop_body_local_env::LoopBodyLocalEnv,
body_local_env: &LoopBodyLocalEnv,
instructions: &mut Vec<JoinInst>,
) -> Result<ValueId, String>
where
F: FnMut() -> ValueId,
{
) -> Result<ValueId, String> {
// Resolve method name to CoreMethodId
let method_id = CoreMethodId::iter()
.find(|m| m.name() == method_name)
@ -211,15 +262,10 @@ impl MethodCallLowerer {
// Phase 226: Lower arguments with cascading LoopBodyLocal support
// Check body_local_env first, then cond_env
let resolver = CascadingArgResolver::new(cond_env, body_local_env);
let mut lowered_args = Vec::new();
for arg_ast in args {
let arg_val = Self::lower_arg_with_cascading(
arg_ast,
alloc_value,
cond_env,
body_local_env,
instructions,
)?;
let arg_val = resolver.resolve(arg_ast, alloc_value, instructions)?;
lowered_args.push(arg_val);
}
@ -241,81 +287,12 @@ impl MethodCallLowerer {
Ok(dst)
}
/// Phase 226: Lower an argument expression with cascading LoopBodyLocal support
///
/// This function extends `condition_lowerer::lower_value_expression` to support
/// cascading LoopBodyLocal variables. It checks both environments:
/// 1. LoopBodyLocalEnv first (for previously defined body-local variables)
/// 2. ConditionEnv as fallback (for loop condition variables)
///
/// # Example
///
/// ```nyash
/// local ch = s.substring(p, p+1) // ch stored in body_local_env
/// local digit_pos = digits.indexOf(ch) // ch resolved from body_local_env
/// ```
///
/// # Arguments
///
/// * `expr` - Argument AST node
/// * `alloc_value` - ValueId allocator
/// * `cond_env` - Condition environment (fallback)
/// * `body_local_env` - LoopBodyLocal environment (priority)
/// * `instructions` - Instruction buffer
///
/// # Returns
///
/// * `Ok(ValueId)` - Lowered argument value
/// * `Err(String)` - If variable not found in either environment
fn lower_arg_with_cascading<F>(
expr: &ASTNode,
alloc_value: &mut F,
cond_env: &ConditionEnv,
body_local_env: &super::loop_body_local_env::LoopBodyLocalEnv,
instructions: &mut Vec<JoinInst>,
) -> Result<ValueId, String>
where
F: FnMut() -> ValueId,
{
match expr {
// Variables - check body_local_env first, then cond_env
ASTNode::Variable { name, .. } => {
if let Some(vid) = body_local_env.get(name) {
eprintln!(
"[method_call_lowerer] Arg '{}' found in LoopBodyLocalEnv → {:?}",
name, vid
);
Ok(vid)
} else if let Some(vid) = cond_env.get(name) {
eprintln!(
"[method_call_lowerer] Arg '{}' found in ConditionEnv → {:?}",
name, vid
);
Ok(vid)
} else {
Err(format!(
"Variable '{}' not found in LoopBodyLocalEnv or ConditionEnv",
name
))
}
}
// For non-variables (literals, binops, etc.), delegate to condition_lowerer
// These don't need cascading lookup since they don't reference variables directly
_ => super::condition_lowerer::lower_value_expression(
expr,
alloc_value,
cond_env,
None, // Phase 92 P2-2: No body-local for method call expressions
instructions,
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Span;
use crate::mir::join_ir::lowering::loop_body_local_env::LoopBodyLocalEnv;
use crate::mir::join_ir::JoinInst;
@ -577,9 +554,53 @@ mod tests {
assert_eq!(args[1], ValueId(11)); // ch argument
}
_ => panic!("Expected BoxCall instruction"),
}
}
#[test]
fn test_cascading_resolves_body_local_first() {
// receiver: s, method: indexOf(ch) where ch is body-local
let mut body_env = LoopBodyLocalEnv::new();
body_env.insert("ch".to_string(), ValueId(2));
let mut cond_env = ConditionEnv::new();
cond_env.insert("s".to_string(), ValueId(1));
let recv_val = ValueId(1);
let mut next = 100u32;
let mut alloc_value = || {
let id = ValueId(next);
next += 1;
id
};
let mut instructions = Vec::new();
let result = MethodCallLowerer::lower_for_init(
recv_val,
"indexOf",
&[ASTNode::Variable {
name: "ch".to_string(),
span: Span::unknown(),
}],
&mut alloc_value,
&cond_env,
&body_env,
&mut instructions,
)
.expect("lower_for_init should succeed");
// Ensure BoxCall args include receiver + body-local resolved value (ValueId(2))
let boxcall = instructions
.iter()
.find_map(|inst| match inst {
JoinInst::Compute(MirLikeInst::BoxCall { args, .. }) => Some(args.clone()),
_ => None,
})
.expect("BoxCall not emitted");
assert_eq!(boxcall, vec![ValueId(1), ValueId(2)]);
assert!(result.0 >= 100);
}
}
#[test]
fn test_lower_substring_with_args() {
// Phase 226 Test: s.substring(i, j) with 2 arguments (cascading support)

View File

@ -71,9 +71,7 @@ impl Pattern2StepSchedule {
}
}
/// Schedule decision result with reasoning
///
/// Phase 93 Refactoring: Unified schedule decision with explicit reasons
/// Schedule decision result with reasoning (SSOT)
#[derive(Debug, Clone)]
pub(crate) struct ScheduleDecision {
/// Whether body-init should come before break check
@ -90,6 +88,7 @@ pub(crate) struct ScheduleDebugContext {
pub has_body_local_init: bool,
pub has_loop_local_carrier: bool,
pub has_condition_only_recipe: bool,
pub has_body_local_derived_recipe: bool,
}
/// Decide Pattern2 schedule based on loop characteristics
@ -116,6 +115,7 @@ pub(crate) fn decide_pattern2_schedule(
body_local_env: Option<&LoopBodyLocalEnv>,
carrier_info: &CarrierInfo,
has_condition_only_recipe: bool,
has_body_local_derived_recipe: bool,
) -> ScheduleDecision {
let has_body_local_init = body_local_env.map(|env| !env.is_empty()).unwrap_or(false);
let has_loop_local_carrier = carrier_info
@ -123,10 +123,15 @@ pub(crate) fn decide_pattern2_schedule(
.iter()
.any(|c| matches!(c.init, CarrierInit::LoopLocalZero));
let body_init_first = has_condition_only_recipe || has_body_local_init || has_loop_local_carrier;
let body_init_first = has_condition_only_recipe
|| has_body_local_derived_recipe
|| has_body_local_init
|| has_loop_local_carrier;
let reason = if has_condition_only_recipe {
"ConditionOnly requires body-init before break"
} else if has_body_local_derived_recipe {
"BodyLocalDerived requires body-init before break"
} else if has_body_local_init {
"body-local variables require init before break"
} else if has_loop_local_carrier {
@ -142,51 +147,13 @@ pub(crate) fn decide_pattern2_schedule(
has_body_local_init,
has_loop_local_carrier,
has_condition_only_recipe,
has_body_local_derived_recipe,
},
}
}
/// Minimal context for deciding the step order.
///
/// Phase 93 Refactoring: Kept for backward compatibility, delegates to `decide_pattern2_schedule`
#[derive(Debug, Clone, Copy)]
pub(crate) struct Pattern2ScheduleContext {
pub(crate) has_body_local_init: bool,
pub(crate) has_loop_local_carrier: bool,
}
impl Pattern2ScheduleContext {
/// Build schedule context from environment.
///
/// # Phase 93 Refactoring: Backward compatibility wrapper
///
/// Delegates to `decide_pattern2_schedule()` for actual decision making.
/// Note: When `has_condition_only_recipe` is true, we set `has_body_local_init` to true
/// to ensure the correct schedule is generated.
pub(crate) fn from_env(
body_local_env: Option<&LoopBodyLocalEnv>,
carrier_info: &CarrierInfo,
has_condition_only_recipe: bool,
) -> Self {
let decision = decide_pattern2_schedule(body_local_env, carrier_info, has_condition_only_recipe);
Self {
// Phase 93 Refactoring: Include condition_only_recipe in has_body_local_init
// to maintain backward compatibility with requires_body_init_before_break()
has_body_local_init: decision.debug_ctx.has_body_local_init
|| decision.debug_ctx.has_condition_only_recipe,
has_loop_local_carrier: decision.debug_ctx.has_loop_local_carrier,
}
}
fn requires_body_init_before_break(&self) -> bool {
self.has_body_local_init || self.has_loop_local_carrier
}
}
/// Build a schedule for Pattern 2 lowering.
///
/// Phase 93 Refactoring: Now accepts `ScheduleDecision` for explicit reasoning
///
/// - Default P2: header → break → body-init → updates → tail
/// - Body-local break dependency (DigitPos/_atoi style):
/// header → body-init → break → updates → tail
@ -221,42 +188,6 @@ pub(crate) fn build_pattern2_schedule_from_decision(
schedule
}
/// Build a schedule for Pattern 2 lowering (legacy wrapper).
///
/// Phase 93 Refactoring: Kept for backward compatibility
///
/// - Default P2: header → break → body-init → updates → tail
/// - Body-local break dependency (DigitPos/_atoi style):
/// header → body-init → break → updates → tail
pub(crate) fn build_pattern2_schedule(ctx: &Pattern2ScheduleContext) -> Pattern2StepSchedule {
let schedule = if ctx.requires_body_init_before_break() {
Pattern2StepSchedule {
steps: vec![
Pattern2StepKind::HeaderCond,
Pattern2StepKind::BodyInit,
Pattern2StepKind::BreakCheck,
Pattern2StepKind::Updates,
Pattern2StepKind::Tail,
],
reason: "body-local break dependency",
}
} else {
Pattern2StepSchedule {
steps: vec![
Pattern2StepKind::HeaderCond,
Pattern2StepKind::BreakCheck,
Pattern2StepKind::BodyInit,
Pattern2StepKind::Updates,
Pattern2StepKind::Tail,
],
reason: "default",
}
};
log_schedule(ctx, &schedule);
schedule
}
fn log_schedule_from_decision(decision: &ScheduleDecision, schedule: &Pattern2StepSchedule) {
if !(env::joinir_dev_enabled() || joinir_test_debug_enabled()) {
return;
@ -270,32 +201,13 @@ fn log_schedule_from_decision(decision: &ScheduleDecision, schedule: &Pattern2St
.join(" -> ");
eprintln!(
"[phase93/schedule] steps={} reason={} ctx={{body_local_init={}, loop_local_carrier={}, condition_only={}}}",
"[phase93/schedule] steps={} reason={} ctx={{body_local_init={}, loop_local_carrier={}, condition_only={}, body_local_derived={}}}",
steps_desc,
schedule.reason(),
decision.debug_ctx.has_body_local_init,
decision.debug_ctx.has_loop_local_carrier,
decision.debug_ctx.has_condition_only_recipe
);
}
fn log_schedule(ctx: &Pattern2ScheduleContext, schedule: &Pattern2StepSchedule) {
if !(env::joinir_dev_enabled() || joinir_test_debug_enabled()) {
return;
}
let steps_desc = schedule
.steps()
.iter()
.map(Pattern2StepKind::as_str)
.collect::<Vec<_>>()
.join(" -> ");
eprintln!(
"[joinir/p2-sched] steps={steps_desc} reason={} ctx={{body_local_init={}, loop_local_carrier={}}}",
schedule.reason(),
ctx.has_body_local_init,
ctx.has_loop_local_carrier
decision.debug_ctx.has_condition_only_recipe,
decision.debug_ctx.has_body_local_derived_recipe
);
}
@ -351,8 +263,8 @@ mod tests {
#[test]
fn default_schedule_break_before_body_init() {
let ctx = Pattern2ScheduleContext::from_env(None, &carrier_info(vec![]), false);
let schedule = build_pattern2_schedule(&ctx);
let decision = decide_pattern2_schedule(None, &carrier_info(vec![]), false, false);
let schedule = build_pattern2_schedule_from_decision(&decision);
assert_eq!(
schedule.steps(),
&[
@ -363,7 +275,7 @@ mod tests {
Pattern2StepKind::Tail
]
);
assert_eq!(schedule.reason(), "default");
assert_eq!(schedule.reason(), "default schedule");
}
#[test]
@ -371,9 +283,13 @@ mod tests {
let mut body_env = LoopBodyLocalEnv::new();
body_env.insert("tmp".to_string(), ValueId(5));
let ctx =
Pattern2ScheduleContext::from_env(Some(&body_env), &carrier_info(vec![carrier(false)]), false);
let schedule = build_pattern2_schedule(&ctx);
let decision = decide_pattern2_schedule(
Some(&body_env),
&carrier_info(vec![carrier(false)]),
false,
false,
);
let schedule = build_pattern2_schedule_from_decision(&decision);
assert_eq!(
schedule.steps(),
&[
@ -384,13 +300,18 @@ mod tests {
Pattern2StepKind::Tail
]
);
assert_eq!(schedule.reason(), "body-local break dependency");
assert_eq!(schedule.reason(), "body-local variables require init before break");
}
#[test]
fn loop_local_carrier_triggers_body_first() {
let ctx = Pattern2ScheduleContext::from_env(None, &carrier_info(vec![carrier(true)]), false);
let schedule = build_pattern2_schedule(&ctx);
let decision = decide_pattern2_schedule(
None,
&carrier_info(vec![carrier(true)]),
false,
false,
);
let schedule = build_pattern2_schedule_from_decision(&decision);
assert_eq!(
schedule.steps(),
&[
@ -401,15 +322,15 @@ mod tests {
Pattern2StepKind::Tail
]
);
assert_eq!(schedule.reason(), "body-local break dependency");
assert_eq!(schedule.reason(), "loop-local carrier requires init before break");
}
/// Phase 93 P0: ConditionOnly recipe triggers body-init before break
#[test]
fn condition_only_recipe_triggers_body_first() {
// Empty body_local_env but has condition_only_recipe
let ctx = Pattern2ScheduleContext::from_env(None, &carrier_info(vec![]), true);
let schedule = build_pattern2_schedule(&ctx);
let decision = decide_pattern2_schedule(None, &carrier_info(vec![]), true, false);
let schedule = build_pattern2_schedule_from_decision(&decision);
assert_eq!(
schedule.steps(),
&[
@ -420,8 +341,7 @@ mod tests {
Pattern2StepKind::Tail
]
);
// Phase 93 Refactoring: Reason is now preserved from backward compat wrapper
assert_eq!(schedule.reason(), "body-local break dependency");
assert_eq!(schedule.reason(), "ConditionOnly requires body-init before break");
}
#[test]

View File

@ -136,6 +136,7 @@ pub fn build_pattern2_minimal_structured() -> JoinModule {
join_value_space: &mut join_value_space,
skeleton: None, // Phase 92 P0-3: skeleton=None for backward compatibility
condition_only_recipe: None, // Phase 93 P0: None for normal loops
body_local_derived_recipe: None, // Phase 94: None for fixture
},
)
.expect("pattern2 minimal lowering should succeed");

View File

@ -0,0 +1,63 @@
#!/bin/bash
# Phase 94: P5b escape handling complete E2E (VM)
#
# Verifies:
# - Pattern2Break JoinIR lowering supports:
# - body-local `ch` conditional override (derived Select)
# - loop counter skip on escape (i += 2 when ch == "\\")
# - Fixture prints: hello" world
#
# Notes:
# - We keep this in `integration` (not `quick`) to avoid adding more output-heavy cases
# to the fastest profile.
source "$(dirname "$0")/../../../lib/test_runner.sh"
export SMOKES_USE_PYVM=0
require_env || exit 2
PASS_COUNT=0
FAIL_COUNT=0
RUN_TIMEOUT_SECS=${RUN_TIMEOUT_SECS:-10}
INPUT="$NYASH_ROOT/tools/selfhost/test_pattern5b_escape_minimal.hako"
echo "[INFO] Phase 94: P5b escape E2E (VM) - $INPUT"
set +e
OUTPUT=$(timeout "$RUN_TIMEOUT_SECS" env \
NYASH_DISABLE_PLUGINS=1 \
HAKO_JOINIR_STRICT=1 \
"$NYASH_BIN" --backend vm "$INPUT" 2>&1)
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -eq 124 ]; then
echo "[FAIL] hakorune timed out (>${RUN_TIMEOUT_SECS}s)"
FAIL_COUNT=$((FAIL_COUNT + 1))
elif [ "$EXIT_CODE" -eq 0 ]; then
if echo "$OUTPUT" | grep -q '^hello" world$'; then
echo "[PASS] Output verified: hello\" world"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "[FAIL] Unexpected output (expected line: hello\" world)"
echo "[INFO] output (tail):"
echo "$OUTPUT" | tail -n 50 || true
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
else
echo "[FAIL] hakorune failed with exit code $EXIT_CODE"
echo "[INFO] output (tail):"
echo "$OUTPUT" | tail -n 50 || true
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
echo "[INFO] PASS: $PASS_COUNT, FAIL: $FAIL_COUNT"
if [ "$FAIL_COUNT" -eq 0 ]; then
test_pass "phase94_p5b_escape_e2e: All tests passed"
exit 0
else
test_fail "phase94_p5b_escape_e2e: $FAIL_COUNT test(s) failed"
exit 1
fi