feat(joinir): Phase 66 - Ownership-Relay Multihop Implementation (dev-only)

Phase 65設計に基づき、relay_path.len() > 1 を analysis/plan 層で受理するよう実装。

Key changes:
- plan_to_lowering.rs: relay_path.len() > 1 制限撤去 + 構造的 Fail-Fast 維持
  - relay_path.is_empty() → Err(loop relay は最低 1 hop)
  - relay_path[0] != plan.scope_id → Err(この scope が最初の hop)
  - relay.owner_scope == plan.scope_id → Err(relay と owned は排他)
  - owned_vars ∩ relay_writes ≠ ∅ → Err(同名は不変条件違反)

- ast_analyzer.rs: 3階層 multihop テスト追加
  - multihop_relay_detected_for_3_layer_nested_loops

Unit tests (plan_to_lowering):
- test_relay_multi_hop_accepted_in_with_relay
- test_relay_path_empty_rejected_in_with_relay
- test_relay_path_not_starting_at_plan_scope_rejected
- test_relay_owner_same_as_plan_scope_rejected
- test_owned_and_relay_same_name_rejected

Tests: normalized_dev 49/49 PASS, lib 947/947 PASS
Design: Analysis-only, no behavior change in production lowering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nyash-codex
2025-12-12 23:33:16 +09:00
parent ba68fb2bad
commit 5c75506dcc
4 changed files with 285 additions and 14 deletions

View File

@ -281,7 +281,15 @@
- 禁止事項の明文化by-name分岐排除dev-only name guard対象外 - 禁止事項の明文化by-name分岐排除dev-only name guard対象外
- 代表ケース3階層multihop AST例Merge relay JSON fixture例 - 代表ケース3階層multihop AST例Merge relay JSON fixture例
- 詳細: [phase65-ownership-relay-multihop-design.md](docs/development/current/main/phase65-ownership-relay-multihop-design.md) - 詳細: [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 ビルドで検証しているので、 - すでに PHI/ValueId 契約は debug ビルドで検証しているので、
必要なら SSADFA や軽い最適化Loop invariant / Strength reductionを検討。 必要なら SSADFA や軽い最適化Loop invariant / Strength reductionを検討。

View File

@ -545,3 +545,39 @@ OwnershipPlan {
| **relay_path** | 内→外の順でrelayを経由するスコープIDのリストwriter自身とownerは含まない | | **relay_path** | 内→外の順でrelayを経由するスコープIDのリストwriter自身とownerは含まない |
| **Exit PHI** | Owner loopの出口でrelay変数をmergeするPHI命令 | | **Exit PHI** | Owner loopの出口でrelay変数をmergeするPHI命令 |
| **Fail-Fast** | 不正なパターンを検出して即座にErrを返す設計方針 | | **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()` → Errloop relay は最低 1 hop
- [x] `relay_path[0] != plan.scope_id` → Errこの scope が最初の hop
- [x] `relay.owner_scope == plan.scope_id` → Errrelay と 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

View File

@ -934,4 +934,123 @@ mod tests {
assert_eq!(relay.relay_path.len(), 1); assert_eq!(relay.relay_path.len(), 1);
assert_eq!(relay.relay_path[0], loop_plan.scope_id); 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"
);
}
} }

View File

@ -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: /// Rules:
/// - carriers = owned_vars where is_written && name != loop_var /// - carriers = owned_vars where is_written && name != loop_var
/// + relay_writes where 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")] #[cfg(feature = "normalized_dev")]
pub fn plan_to_p2_inputs_with_relay( pub fn plan_to_p2_inputs_with_relay(
plan: &OwnershipPlan, plan: &OwnershipPlan,
loop_var: &str, loop_var: &str,
) -> Result<P2LoweringInputs, String> { ) -> Result<P2LoweringInputs, String> {
// 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(); let mut carriers = Vec::new();
for var in &plan.owned_vars { for var in &plan.owned_vars {
@ -126,14 +135,40 @@ pub fn plan_to_p2_inputs_with_relay(
if relay.name == loop_var { if relay.name == loop_var {
continue; continue;
} }
if relay.relay_path.len() > 1 {
// Phase 66 Fail-Fast: owned_vars と relay_writes で同名は不正(不変条件違反)
if owned_names.contains(&relay.name) {
return Err(format!( return Err(format!(
"Phase 60 limitation: only single-hop relay supported for P2. Var='{}' relay_path_len={}", "Invariant violation: '{}' appears in both owned_vars and relay_writes",
relay.name, relay.name
relay.relay_path.len()
)); ));
} }
// 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 { carriers.push(CarrierVar {
name: relay.name.clone(), name: relay.name.clone(),
role: CarrierRole::LoopState, role: CarrierRole::LoopState,
@ -307,6 +342,7 @@ mod tests {
#[test] #[test]
#[cfg(feature = "normalized_dev")] #[cfg(feature = "normalized_dev")]
fn test_relay_single_hop_accepted_in_with_relay() { 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)); let mut plan = OwnershipPlan::new(ScopeId(1));
plan.owned_vars.push(ScopeOwnedVar { plan.owned_vars.push(ScopeOwnedVar {
name: "i".to_string(), name: "i".to_string(),
@ -316,7 +352,7 @@ mod tests {
plan.relay_writes.push(RelayVar { plan.relay_writes.push(RelayVar {
name: "sum".to_string(), name: "sum".to_string(),
owner_scope: ScopeId(0), 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"); let inputs = plan_to_p2_inputs_with_relay(&plan, "i").expect("with_relay should accept");
@ -327,19 +363,90 @@ mod tests {
#[test] #[test]
#[cfg(feature = "normalized_dev")] #[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)); let mut plan = OwnershipPlan::new(ScopeId(1));
plan.relay_writes.push(RelayVar { plan.relay_writes.push(RelayVar {
name: "outer_var".to_string(), name: "outer_var".to_string(),
owner_scope: ScopeId(0), 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"); let result = plan_to_p2_inputs_with_relay(&plan, "i");
assert!(result.is_err()); assert!(result.is_err());
assert!(result assert!(result.unwrap_err().contains("empty relay_path"));
.unwrap_err() }
.contains("only single-hop relay supported"));
#[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] #[test]
@ -528,6 +635,7 @@ mod tests {
#[test] #[test]
#[cfg(feature = "normalized_dev")] #[cfg(feature = "normalized_dev")]
fn test_p3_with_relay_accepts_single_hop() { 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)); let mut plan = OwnershipPlan::new(ScopeId(1));
plan.owned_vars.push(ScopeOwnedVar { plan.owned_vars.push(ScopeOwnedVar {
name: "i".to_string(), name: "i".to_string(),
@ -537,7 +645,7 @@ mod tests {
plan.relay_writes.push(RelayVar { plan.relay_writes.push(RelayVar {
name: "sum".to_string(), name: "sum".to_string(),
owner_scope: ScopeId(0), 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"); let inputs = plan_to_p3_inputs_with_relay(&plan, "i").expect("P3 with_relay should accept");