feat(joinir): Phase 70-B - Multihop Relay Passthrough Support (dev-only)
Phase 70-B relaxes runtime guard from "always error" to "unsupported only". Adds structural detection (is_supported_multihop_pattern) to distinguish: - Simple passthrough (no self-updates): ACCEPT - Complex patterns (self-conflict, etc): REJECT with [ownership/relay:runtime_unsupported] Changes: - plan_validator.rs: +is_supported_multihop_pattern() method + unit tests - normalized_joinir_min.rs: +2 integration tests for passthrough/rejection - phase70-relay-runtime-guard.md: Phase 70 series status table added Tests: normalized_dev 52/52 PASS, lib 950/950 PASS Design: Structural detection only (no by-name branching) 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@ -1872,3 +1872,309 @@ fn test_phase70a_multihop_relay_runtime_unsupported_tag() {
|
||||
// will be hit when P3 lowering encounters this plan at runtime.
|
||||
eprintln!("[phase70a/test] Runtime would fail with [ownership/relay:runtime_unsupported] tag");
|
||||
}
|
||||
|
||||
/// Phase 70-B: Test that simple passthrough multihop relay is accepted.
|
||||
///
|
||||
/// This test verifies the structural detection logic in OwnershipPlanValidator
|
||||
/// correctly identifies supported multihop patterns (pure passthrough, no self-updates).
|
||||
#[test]
|
||||
#[cfg(feature = "normalized_dev")]
|
||||
fn test_phase70b_multihop_relay_simple_passthrough_succeeds() {
|
||||
use nyash_rust::mir::join_ir::ownership::{
|
||||
OwnershipPlan, OwnershipPlanValidator, RelayVar, ScopeId, ScopeOwnedVar,
|
||||
};
|
||||
|
||||
// Build a plan for innermost loop (L3) with multihop relay
|
||||
// L3 writes to 'counter' owned by L1, relayed through L2
|
||||
let mut plan_l3 = OwnershipPlan::new(ScopeId(3)); // Inner loop scope
|
||||
plan_l3.owned_vars.push(ScopeOwnedVar {
|
||||
name: "i".to_string(), // loop variable
|
||||
is_written: true,
|
||||
is_condition_only: true,
|
||||
});
|
||||
plan_l3.relay_writes.push(RelayVar {
|
||||
name: "counter".to_string(),
|
||||
owner_scope: ScopeId(1), // L1 owns counter
|
||||
relay_path: vec![ScopeId(3), ScopeId(2)], // 2 hops: L3 → L2 → L1
|
||||
});
|
||||
// No owned_vars for 'counter' - pure passthrough from L3's perspective
|
||||
|
||||
// Phase 70-B: This should be accepted (passthrough pattern)
|
||||
let result = OwnershipPlanValidator::validate_relay_support(&plan_l3);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Phase 70-B: Simple passthrough multihop should be accepted, got: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
eprintln!("[phase70b/test] Simple passthrough multihop relay accepted (3-layer loop)");
|
||||
}
|
||||
|
||||
/// Phase 70-B: Test that unsupported multihop patterns are still rejected.
|
||||
///
|
||||
/// This test verifies that complex patterns (e.g., self-conflict where a scope
|
||||
/// both owns and relays the same variable) are still rejected with the standard tag.
|
||||
#[test]
|
||||
#[cfg(feature = "normalized_dev")]
|
||||
fn test_phase70b_multihop_relay_self_conflict_rejected() {
|
||||
use nyash_rust::mir::join_ir::ownership::{
|
||||
OwnershipPlan, OwnershipPlanValidator, RelayVar, ScopeId, ScopeOwnedVar,
|
||||
};
|
||||
|
||||
// Build a plan where L3 both owns and relays 'counter' (conflict)
|
||||
// L3 → L2 → L1 (multihop with self-conflict at L3)
|
||||
let mut plan_l3 = OwnershipPlan::new(ScopeId(3)); // Inner loop scope
|
||||
plan_l3.owned_vars.push(ScopeOwnedVar {
|
||||
name: "counter".to_string(), // L3 owns counter
|
||||
is_written: true,
|
||||
is_condition_only: false,
|
||||
});
|
||||
plan_l3.relay_writes.push(RelayVar {
|
||||
name: "counter".to_string(), // L3 also relays counter (conflict)
|
||||
owner_scope: ScopeId(1),
|
||||
relay_path: vec![ScopeId(3), ScopeId(2)], // L3 → L2 → L1 (multihop)
|
||||
});
|
||||
|
||||
// Phase 70-B: This should be rejected (self-conflict)
|
||||
let result = OwnershipPlanValidator::validate_relay_support(&plan_l3);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Phase 70-B: Self-conflict multihop should be rejected"
|
||||
);
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("[ownership/relay:runtime_unsupported]"),
|
||||
"Error should contain standard tag: {}",
|
||||
err
|
||||
);
|
||||
|
||||
eprintln!("[phase70b/test] Self-conflict multihop relay correctly rejected");
|
||||
}
|
||||
|
||||
/// Phase 70-C: Merge Relay (multiple inner loops → same owner)
|
||||
///
|
||||
/// This test verifies that OwnershipAnalyzer correctly detects the "merge relay"
|
||||
/// pattern where multiple inner loops update the same owner variable.
|
||||
///
|
||||
/// Structure:
|
||||
/// ```text
|
||||
/// loop L1 {
|
||||
/// local total = 0 // owned by L1
|
||||
/// loop L2_A {
|
||||
/// total++ // L2_A → L1 relay
|
||||
/// }
|
||||
/// loop L2_B {
|
||||
/// total += 10 // L2_B → L1 relay
|
||||
/// }
|
||||
/// }
|
||||
/// // L1 exit: merge both L2_A and L2_B's updates to 'total'
|
||||
/// ```
|
||||
#[test]
|
||||
#[cfg(feature = "normalized_dev")]
|
||||
fn test_phase70c_merge_relay_multiple_inner_loops_detected() {
|
||||
use nyash_rust::ast::{ASTNode, BinaryOperator, LiteralValue, Span};
|
||||
use nyash_rust::mir::join_ir::ownership::AstOwnershipAnalyzer;
|
||||
|
||||
// Build AST: loop L1 { local total=0; loop L2_A { total++ } loop L2_B { total+=10 } }
|
||||
fn var(name: &str) -> ASTNode {
|
||||
ASTNode::Variable {
|
||||
name: name.to_string(),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
fn lit_i(i: i64) -> ASTNode {
|
||||
ASTNode::Literal {
|
||||
value: LiteralValue::Integer(i),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
fn lit_true() -> ASTNode {
|
||||
ASTNode::Literal {
|
||||
value: LiteralValue::Bool(true),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
let ast = ASTNode::FunctionDeclaration {
|
||||
name: "test_merge_relay".to_string(),
|
||||
params: vec![],
|
||||
body: vec![
|
||||
// L1: outer loop
|
||||
ASTNode::Loop {
|
||||
condition: Box::new(lit_true()),
|
||||
body: vec![
|
||||
// local total = 0 (L1 owns)
|
||||
ASTNode::Local {
|
||||
variables: vec!["total".to_string()],
|
||||
initial_values: vec![Some(Box::new(lit_i(0)))],
|
||||
span: Span::unknown(),
|
||||
},
|
||||
// L2_A: first inner loop (total++)
|
||||
ASTNode::Loop {
|
||||
condition: Box::new(lit_true()),
|
||||
body: vec![
|
||||
ASTNode::Assignment {
|
||||
target: Box::new(var("total")),
|
||||
value: Box::new(ASTNode::BinaryOp {
|
||||
operator: BinaryOperator::Add,
|
||||
left: Box::new(var("total")),
|
||||
right: Box::new(lit_i(1)),
|
||||
span: Span::unknown(),
|
||||
}),
|
||||
span: Span::unknown(),
|
||||
},
|
||||
ASTNode::Break {
|
||||
span: Span::unknown(),
|
||||
},
|
||||
],
|
||||
span: Span::unknown(),
|
||||
},
|
||||
// L2_B: second inner loop (total += 10)
|
||||
ASTNode::Loop {
|
||||
condition: Box::new(lit_true()),
|
||||
body: vec![
|
||||
ASTNode::Assignment {
|
||||
target: Box::new(var("total")),
|
||||
value: Box::new(ASTNode::BinaryOp {
|
||||
operator: BinaryOperator::Add,
|
||||
left: Box::new(var("total")),
|
||||
right: Box::new(lit_i(10)),
|
||||
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).expect("analysis should succeed");
|
||||
|
||||
// Find L1 (owner of 'total')
|
||||
let l1_plan = plans
|
||||
.iter()
|
||||
.find(|p| p.owned_vars.iter().any(|v| v.name == "total"))
|
||||
.expect("expected L1 plan with owned total");
|
||||
let l1_scope_id = l1_plan.scope_id;
|
||||
|
||||
// Find L2_A and L2_B (both relay 'total' to L1)
|
||||
let relay_plans: Vec<_> = plans
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.relay_writes.iter().any(|r| r.name == "total")
|
||||
&& p.scope_id != l1_scope_id
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify: two inner loops relay to the same owner
|
||||
assert_eq!(
|
||||
relay_plans.len(),
|
||||
2,
|
||||
"Phase 70-C: Expected 2 inner loops relaying 'total' to L1, got {}",
|
||||
relay_plans.len()
|
||||
);
|
||||
|
||||
for (idx, plan) in relay_plans.iter().enumerate() {
|
||||
let relay = plan
|
||||
.relay_writes
|
||||
.iter()
|
||||
.find(|r| r.name == "total")
|
||||
.expect("expected 'total' in relay_writes");
|
||||
|
||||
// Verify: both relay to the same owner (L1)
|
||||
assert_eq!(
|
||||
relay.owner_scope, l1_scope_id,
|
||||
"Phase 70-C: relay {} should have owner_scope = L1",
|
||||
idx
|
||||
);
|
||||
|
||||
// Verify: single-hop relay (L2 → L1)
|
||||
assert_eq!(
|
||||
relay.relay_path.len(),
|
||||
1,
|
||||
"Phase 70-C: relay {} should have single-hop path",
|
||||
idx
|
||||
);
|
||||
|
||||
// Verify: relay_path[0] is this scope (L2_A or L2_B)
|
||||
assert_eq!(
|
||||
relay.relay_path[0], plan.scope_id,
|
||||
"Phase 70-C: relay {} relay_path[0] must be this scope",
|
||||
idx
|
||||
);
|
||||
}
|
||||
|
||||
// Verify: L1 owned_vars contains 'total' and is_written=true
|
||||
let total_var = l1_plan
|
||||
.owned_vars
|
||||
.iter()
|
||||
.find(|v| v.name == "total")
|
||||
.expect("expected 'total' in L1 owned_vars");
|
||||
assert!(
|
||||
total_var.is_written,
|
||||
"Phase 70-C: L1's 'total' should be is_written=true"
|
||||
);
|
||||
|
||||
eprintln!("[phase70c/test] Merge relay pattern detected: 2 inner loops → same owner variable");
|
||||
}
|
||||
|
||||
/// Phase 70-C: Merge Relay validation acceptance
|
||||
///
|
||||
/// This test verifies that OwnershipPlanValidator ACCEPTS merge relay patterns
|
||||
/// (multiple inner loops → same owner) because they are PERMITTED with owner merge.
|
||||
#[test]
|
||||
#[cfg(feature = "normalized_dev")]
|
||||
fn test_phase70c_merge_relay_same_owner_accepted() {
|
||||
use nyash_rust::mir::join_ir::ownership::{
|
||||
OwnershipPlan, OwnershipPlanValidator, RelayVar, ScopeId,
|
||||
};
|
||||
|
||||
// Build two plans for L2_A and L2_B, both relaying to L1
|
||||
// L2_A plan: relay 'total' to L1
|
||||
let mut plan_l2a = OwnershipPlan::new(ScopeId(2));
|
||||
plan_l2a.relay_writes.push(RelayVar {
|
||||
name: "total".to_string(),
|
||||
owner_scope: ScopeId(1), // L1 owns total
|
||||
relay_path: vec![ScopeId(2)], // Single hop: L2_A → L1
|
||||
});
|
||||
|
||||
// L2_B plan: relay 'total' to L1
|
||||
let mut plan_l2b = OwnershipPlan::new(ScopeId(3));
|
||||
plan_l2b.relay_writes.push(RelayVar {
|
||||
name: "total".to_string(),
|
||||
owner_scope: ScopeId(1), // L1 owns total (same owner)
|
||||
relay_path: vec![ScopeId(3)], // Single hop: L2_B → L1
|
||||
});
|
||||
|
||||
// Phase 70-C: Both should be accepted (single-hop relay to same owner)
|
||||
let result_a = OwnershipPlanValidator::validate_relay_support(&plan_l2a);
|
||||
assert!(
|
||||
result_a.is_ok(),
|
||||
"Phase 70-C: L2_A relay to L1 should be accepted, got: {:?}",
|
||||
result_a
|
||||
);
|
||||
|
||||
let result_b = OwnershipPlanValidator::validate_relay_support(&plan_l2b);
|
||||
assert!(
|
||||
result_b.is_ok(),
|
||||
"Phase 70-C: L2_B relay to L1 should be accepted, got: {:?}",
|
||||
result_b
|
||||
);
|
||||
|
||||
eprintln!("[phase70c/test] Merge relay validation accepted: multiple inner loops → same owner");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user