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:
@ -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)を検討。
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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();
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user