diff --git a/CURRENT_TASK.md b/CURRENT_TASK.md index 41ec0b05..797ab04a 100644 --- a/CURRENT_TASK.md +++ b/CURRENT_TASK.md @@ -281,7 +281,15 @@ - ✅ 禁止事項の明文化(by-name分岐排除、dev-only name guard対象外) - ✅ 代表ケース(3階層multihop AST例、Merge relay JSON fixture例) - 詳細: [phase65-ownership-relay-multihop-design.md](docs/development/current/main/phase65-ownership-relay-multihop-design.md) -24. JoinIR Verify / 最適化まわり +24. **Phase 66-OWNERSHIP-RELAY-MULTIHOP-IMPL(完了✅ 2025-12-12)**: Multihop relay 実装(analysis/plan層) + - ✅ `plan_to_lowering.rs` の relay_path.len() > 1 制限撤去 + - ✅ 構造的 Fail-Fast ガード実装(empty path, scope mismatch, owner=scope, name conflict) + - ✅ ユニットテスト追加(5件: multihop accepted, empty rejected, path mismatch, owner same, name conflict) + - ✅ `ast_analyzer.rs` に 3階層 multihop テスト追加 + - ✅ テスト結果: normalized_dev 49/49, lib 947/947 PASS + - 次フェーズ: Phase 67(本番lowering側のmultihop実行対応 or merge relay) + - 詳細: [phase65-ownership-relay-multihop-design.md](docs/development/current/main/phase65-ownership-relay-multihop-design.md) +25. JoinIR Verify / 最適化まわり - すでに PHI/ValueId 契約は debug ビルドで検証しているので、 必要なら SSA‑DFA や軽い最適化(Loop invariant / Strength reduction)を検討。 diff --git a/docs/development/current/main/phase65-ownership-relay-multihop-design.md b/docs/development/current/main/phase65-ownership-relay-multihop-design.md index 4070f7f4..f19be890 100644 --- a/docs/development/current/main/phase65-ownership-relay-multihop-design.md +++ b/docs/development/current/main/phase65-ownership-relay-multihop-design.md @@ -545,3 +545,39 @@ OwnershipPlan { | **relay_path** | 内→外の順でrelayを経由するスコープIDのリスト(writer自身とownerは含まない) | | **Exit PHI** | Owner loopの出口でrelay変数をmergeするPHI命令 | | **Fail-Fast** | 不正なパターンを検出して即座にErrを返す設計方針 | + +--- + +## Phase 66 Implementation Status + +**Phase 66 Implementation (2025-12-12)**: ✅ **COMPLETED** + +Phase 66では `plan_to_p2_inputs_with_relay` の multihop 受理ロジックを実装完了。 + +### 実装済みチェック + +- [x] `plan_to_lowering.rs` の relay_path.len() > 1 制限撤去 +- [x] 構造的 Fail-Fast ガード実装: + - [x] `relay_path.is_empty()` → Err(loop relay は最低 1 hop) + - [x] `relay_path[0] != plan.scope_id` → Err(この scope が最初の hop) + - [x] `relay.owner_scope == plan.scope_id` → Err(relay と owned は排他) + - [x] `owned_vars ∩ relay_writes ≠ ∅` → Err(同名は不変条件違反) +- [x] ユニットテスト追加: + - [x] `test_relay_multi_hop_accepted_in_with_relay` (multihop 受理) + - [x] `test_relay_path_empty_rejected_in_with_relay` + - [x] `test_relay_path_not_starting_at_plan_scope_rejected` + - [x] `test_relay_owner_same_as_plan_scope_rejected` + - [x] `test_owned_and_relay_same_name_rejected` +- [x] `ast_analyzer.rs` に 3階層 multihop テスト追加: + - [x] `multihop_relay_detected_for_3_layer_nested_loops` + +### 検証結果 + +- normalized_dev: 49/49 PASS +- lib tests: 947/947 PASS +- Zero regressions + +### 次フェーズ(Phase 67) + +- 本番 lowering への multihop 完全対応(boundary/exit PHI のmerge実装) +- Merge relay テスト追加(複数 inner loop → 共通 owner) diff --git a/src/mir/join_ir/ownership/ast_analyzer.rs b/src/mir/join_ir/ownership/ast_analyzer.rs index ed7c3fae..cd09515b 100644 --- a/src/mir/join_ir/ownership/ast_analyzer.rs +++ b/src/mir/join_ir/ownership/ast_analyzer.rs @@ -934,4 +934,123 @@ mod tests { assert_eq!(relay.relay_path.len(), 1); assert_eq!(relay.relay_path[0], loop_plan.scope_id); } + + /// Phase 66: Test multihop relay (3-layer nested loops) + /// + /// Structure: + /// ```text + /// loop(L1) { + /// local sum = 0; // L1 owns sum + /// loop(L2) { + /// loop(L3) { + /// sum = sum + 1; // L3 writes to sum (owned by L1) + /// } + /// } + /// } + /// ``` + /// + /// Expected: L3's relay_writes has `sum` with relay_path.len() == 2 (L3 → L2 → owner L1) + #[test] + fn multihop_relay_detected_for_3_layer_nested_loops() { + let ast = ASTNode::FunctionDeclaration { + name: "main".to_string(), + params: vec![], + body: vec![ + // L1: outermost loop (owns sum) + ASTNode::Loop { + condition: Box::new(lit_true()), + body: vec![ + // local sum = 0 (L1 owns) + ASTNode::Local { + variables: vec!["sum".to_string()], + initial_values: vec![Some(Box::new(lit_i(0)))], + span: Span::unknown(), + }, + // L2: middle loop + ASTNode::Loop { + condition: Box::new(lit_true()), + body: vec![ + // L3: innermost loop + ASTNode::Loop { + condition: Box::new(lit_true()), + body: vec![ + // sum = sum + 1 (L3 writes to L1-owned) + ASTNode::Assignment { + target: Box::new(var("sum")), + value: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Add, + left: Box::new(var("sum")), + right: Box::new(lit_i(1)), + span: Span::unknown(), + }), + span: Span::unknown(), + }, + ASTNode::Break { span: Span::unknown() }, + ], + span: Span::unknown(), + }, + ASTNode::Break { span: Span::unknown() }, + ], + span: Span::unknown(), + }, + ASTNode::Break { span: Span::unknown() }, + ], + span: Span::unknown(), + }, + ], + is_static: false, + is_override: false, + span: Span::unknown(), + }; + + let mut analyzer = AstOwnershipAnalyzer::new(); + let plans = analyzer.analyze_ast(&ast).unwrap(); + + // Find L1 (owns sum) + let l1_plan = plans + .iter() + .find(|p| p.owned_vars.iter().any(|v| v.name == "sum")) + .expect("expected L1 plan with owned sum"); + let l1_scope_id = l1_plan.scope_id; + + // Find L3 (writes to sum via relay) - must be the innermost loop (largest scope_id among relays) + // Note: Both L2 and L3 have relay_writes to sum, but L3 has longer relay_path + let relay_plans: Vec<_> = plans + .iter() + .filter(|p| p.relay_writes.iter().any(|r| r.name == "sum") && p.scope_id != l1_scope_id) + .collect(); + + // Get the innermost one (highest scope_id = L3) + let l3_plan = relay_plans + .iter() + .max_by_key(|p| p.scope_id.0) + .expect("expected L3 plan with relay write to sum"); + + let relay = l3_plan + .relay_writes + .iter() + .find(|r| r.name == "sum") + .expect("expected sum in relay_writes"); + + // Verify: relay_path has 2 hops (L3 → L2) + // Note: relay_path does NOT include owner (L1), only intermediate loops + assert_eq!( + relay.relay_path.len(), + 2, + "multihop relay should have 2 hops: L3 (this scope) → L2. Got {:?}", + relay.relay_path + ); + + // Verify: relay_path[0] == L3's scope_id (Phase 66 invariant) + assert_eq!( + relay.relay_path[0], l3_plan.scope_id, + "relay_path[0] must be this scope (L3)" + ); + + // Verify: owner is L1 + assert_eq!( + relay.owner_scope, l1_scope_id, + "owner_scope must be L1" + ); + } } diff --git a/src/mir/join_ir/ownership/plan_to_lowering.rs b/src/mir/join_ir/ownership/plan_to_lowering.rs index 7e8ac3a8..58fda7ef 100644 --- a/src/mir/join_ir/ownership/plan_to_lowering.rs +++ b/src/mir/join_ir/ownership/plan_to_lowering.rs @@ -89,17 +89,26 @@ pub fn plan_to_p2_inputs( }) } -/// Convert OwnershipPlan to P2 lowering inputs, allowing relay_writes (dev-only Phase 60). +/// Convert OwnershipPlan to P2 lowering inputs, allowing relay_writes (dev-only Phase 60+). +/// +/// Phase 66: Multihop relay support with structural Fail-Fast guards. /// /// Rules: /// - carriers = owned_vars where is_written && name != loop_var /// + relay_writes where name != loop_var -/// - relay_path.len() > 1 is rejected (single-hop only) +/// - relay_path.len() >= 1 required (loop relay always has at least 1 hop) +/// - relay_path[0] == plan.scope_id (this scope is the first hop) +/// - relay.owner_scope != plan.scope_id (relay is exclusive with owned) +/// - owned_vars and relay_writes cannot share names (invariant) #[cfg(feature = "normalized_dev")] pub fn plan_to_p2_inputs_with_relay( plan: &OwnershipPlan, loop_var: &str, ) -> Result { + // Phase 66: Pre-validation - check for owned/relay name conflicts + let owned_names: std::collections::BTreeSet<_> = + plan.owned_vars.iter().map(|v| &v.name).collect(); + let mut carriers = Vec::new(); for var in &plan.owned_vars { @@ -126,14 +135,40 @@ pub fn plan_to_p2_inputs_with_relay( if relay.name == loop_var { continue; } - if relay.relay_path.len() > 1 { + + // Phase 66 Fail-Fast: owned_vars と relay_writes で同名は不正(不変条件違反) + if owned_names.contains(&relay.name) { return Err(format!( - "Phase 60 limitation: only single-hop relay supported for P2. Var='{}' relay_path_len={}", - relay.name, - relay.relay_path.len() + "Invariant violation: '{}' appears in both owned_vars and relay_writes", + relay.name )); } + // Phase 66 Fail-Fast: relay_path.is_empty() は不正(loop relay は最低 1 hop) + if relay.relay_path.is_empty() { + return Err(format!( + "Invariant violation: relay '{}' has empty relay_path (loop relay requires at least 1 hop)", + relay.name + )); + } + + // Phase 66 Fail-Fast: relay_path[0] != plan.scope_id は不正(この plan の scope が最初の hop であるべき) + if relay.relay_path[0] != plan.scope_id { + return Err(format!( + "Invariant violation: relay '{}' relay_path[0]={:?} != plan.scope_id={:?} (this scope must be first hop)", + relay.name, relay.relay_path[0], plan.scope_id + )); + } + + // Phase 66 Fail-Fast: relay.owner_scope == plan.scope_id は不正(relay は owned と排他) + if relay.owner_scope == plan.scope_id { + return Err(format!( + "Invariant violation: relay '{}' owner_scope={:?} == plan.scope_id (relay cannot be owned by same scope)", + relay.name, relay.owner_scope + )); + } + + // Phase 66: Multihop accepted (relay_path.len() > 1 is OK now) carriers.push(CarrierVar { name: relay.name.clone(), role: CarrierRole::LoopState, @@ -307,6 +342,7 @@ mod tests { #[test] #[cfg(feature = "normalized_dev")] fn test_relay_single_hop_accepted_in_with_relay() { + // Phase 66: relay_path[0] must be plan.scope_id let mut plan = OwnershipPlan::new(ScopeId(1)); plan.owned_vars.push(ScopeOwnedVar { name: "i".to_string(), @@ -316,7 +352,7 @@ mod tests { plan.relay_writes.push(RelayVar { name: "sum".to_string(), owner_scope: ScopeId(0), - relay_path: vec![ScopeId(42)], + relay_path: vec![ScopeId(1)], // Phase 66: must start with plan.scope_id }); let inputs = plan_to_p2_inputs_with_relay(&plan, "i").expect("with_relay should accept"); @@ -327,19 +363,90 @@ mod tests { #[test] #[cfg(feature = "normalized_dev")] - fn test_relay_multi_hop_rejected_in_with_relay() { + fn test_relay_multi_hop_accepted_in_with_relay() { + // Phase 66: Multihop is now accepted! + // plan.scope_id = ScopeId(1), relay_path = [ScopeId(1), ScopeId(2)] + // This represents: L3 (this scope) → L2 → L1 (owner) + let mut plan = OwnershipPlan::new(ScopeId(1)); + plan.relay_writes.push(RelayVar { + name: "counter".to_string(), + owner_scope: ScopeId(0), // L1 owns + relay_path: vec![ScopeId(1), ScopeId(2)], // L3 → L2 → L1 + }); + + let inputs = plan_to_p2_inputs_with_relay(&plan, "i").expect("Phase 66: multihop should be accepted"); + assert_eq!(inputs.carriers.len(), 1); + assert_eq!(inputs.carriers[0].name, "counter"); + assert_eq!(inputs.carriers[0].role, CarrierRole::LoopState); + } + + #[test] + #[cfg(feature = "normalized_dev")] + fn test_relay_path_empty_rejected_in_with_relay() { + // Phase 66: empty relay_path is invalid (loop relay requires at least 1 hop) let mut plan = OwnershipPlan::new(ScopeId(1)); plan.relay_writes.push(RelayVar { name: "outer_var".to_string(), owner_scope: ScopeId(0), - relay_path: vec![ScopeId(1), ScopeId(2)], + relay_path: vec![], // Invalid: empty }); let result = plan_to_p2_inputs_with_relay(&plan, "i"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("only single-hop relay supported")); + assert!(result.unwrap_err().contains("empty relay_path")); + } + + #[test] + #[cfg(feature = "normalized_dev")] + fn test_relay_path_not_starting_at_plan_scope_rejected() { + // Phase 66: relay_path[0] must be plan.scope_id + let mut plan = OwnershipPlan::new(ScopeId(1)); + plan.relay_writes.push(RelayVar { + name: "outer_var".to_string(), + owner_scope: ScopeId(0), + relay_path: vec![ScopeId(42)], // Invalid: doesn't start with ScopeId(1) + }); + + let result = plan_to_p2_inputs_with_relay(&plan, "i"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay_path[0]")); + } + + #[test] + #[cfg(feature = "normalized_dev")] + fn test_relay_owner_same_as_plan_scope_rejected() { + // Phase 66: relay.owner_scope != plan.scope_id + let mut plan = OwnershipPlan::new(ScopeId(1)); + plan.relay_writes.push(RelayVar { + name: "var".to_string(), + owner_scope: ScopeId(1), // Invalid: same as plan.scope_id + relay_path: vec![ScopeId(1)], + }); + + let result = plan_to_p2_inputs_with_relay(&plan, "i"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay cannot be owned by same scope")); + } + + #[test] + #[cfg(feature = "normalized_dev")] + fn test_owned_and_relay_same_name_rejected() { + // Phase 66: owned_vars and relay_writes cannot share names + let mut plan = OwnershipPlan::new(ScopeId(1)); + plan.owned_vars.push(ScopeOwnedVar { + name: "sum".to_string(), + is_written: true, + is_condition_only: false, + }); + plan.relay_writes.push(RelayVar { + name: "sum".to_string(), // Invalid: same name as owned + owner_scope: ScopeId(0), + relay_path: vec![ScopeId(1)], + }); + + let result = plan_to_p2_inputs_with_relay(&plan, "i"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("appears in both owned_vars and relay_writes")); } #[test] @@ -528,6 +635,7 @@ mod tests { #[test] #[cfg(feature = "normalized_dev")] fn test_p3_with_relay_accepts_single_hop() { + // Phase 66: relay_path must start with plan.scope_id and be non-empty let mut plan = OwnershipPlan::new(ScopeId(1)); plan.owned_vars.push(ScopeOwnedVar { name: "i".to_string(), @@ -537,7 +645,7 @@ mod tests { plan.relay_writes.push(RelayVar { name: "sum".to_string(), owner_scope: ScopeId(0), - relay_path: vec![], + relay_path: vec![ScopeId(1)], // Phase 66: must be non-empty and start with plan.scope_id }); let inputs = plan_to_p3_inputs_with_relay(&plan, "i").expect("P3 with_relay should accept");