diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 1f5ff30..4fd4c27 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -390,18 +390,19 @@ }, { "fixture": "synthetic/issue-47-switch-multiple-break", - "nativeStatus": "failure", - "classification": "unsupported", - "comparison": "diagnostic-code", + "nativeStatus": "success", + "classification": "match", + "comparison": "semantic-wir", + "semanticEquivalent": true, "evidence": [ "oracle:synthetic/issue-47-switch-multiple-break/oracle.json", + "oracle:synthetic/issue-47-switch-multiple-break/semantic-oracle.json", "provenance:synthetic/issue-47-switch-multiple-break/fixture.json", - "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_are_not_silently_dropped" + "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle", + "test:opy-rs::compiler::integration_tests::issue_47_oracle::pinned_overpy_switch_action_trace" ], - "owner": "workshop-rs#123", - "note": "The released workshop-rs v0.1.16 contract provides native control-flow calls, but OPY still cannot lower later-reachable multiple switch breaks without changing the source semantics, so the compiler rejects the shape explicitly.", - "failureClass": "integration", - "diagnosticCode": "unsupported-integration-surface" + "owner": "opy-rs#172", + "note": "The pinned OverPy snapshot is preserved as source evidence; its successive Else switch encoding is normalized into the canonical Workshop semantic oracle used by the compatibility gate. An independent action-trace property check parses the pinned output and executes the native WIR dispatch for hit, miss, break, and fallthrough cases, so an incorrect lowering can disagree even when the semantic oracle shape is edited." }, { "fixture": "census/workshop-feature-census", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index cde6487..1de844b 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -66,7 +66,7 @@ {"fixture": "synthetic/issue-47-unsupported", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-unsupported/oracle.json", "test:opy-rs::compiler-issue-47-nested-negative"], "note": "Negative #47 probe: the source implementation and pinned oracle accept the nested conditional switch-break HIR, while the compiler rejects the form because OPY has no lossless lowering to the canonical workshop-rs v0.1.16 control-flow contract."}, {"fixture": "synthetic/issue-47-switch-order", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-order/oracle.json", "implementation-invariant:issue-47-authored-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, {"fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-structured-target/oracle.json", "implementation-invariant:issue-47-structured-switch-source-order"], "note": "The #47 structured switch probe preserves nested if/while actions and authored case/default target order in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, - {"fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-multiple-break/oracle.json", "test:opy-rs::compiler-issue-47-multiple-switch-break"], "note": "The source implementation preserves the multi-break source and the pinned oracle accepts it; the compiler rejects the later-reachable multi-target shape explicitly because OPY still has no lossless lowering to the canonical workshop-rs v0.1.16 control-flow contract."}, + {"fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-multiple-break/oracle.json", "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle", "test:opy-rs::compiler::integration_tests::issue_47_oracle::pinned_overpy_switch_action_trace"], "note": "The source implementation preserves the multi-break source; the compiler lowers its direct case-local breaks through canonical Workshop control flow, and the pinned OverPy action trace independently checks case selection, break, default, and fallthrough behavior."}, {"fixture": "synthetic/issue-47-do-while-shapes", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-do-while-shapes/oracle.json", "test:opy-rs::compiler-issue-47-do-while-break-shapes"], "note": "Direct, conditional, and nested do-while break shapes resolve and match the pinned Workshop through direct native-WIR comparison with the parsed oracle."}, {"fixture": "synthetic/issue-47-do-while-invalid-placement", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-do-while-invalid-placement/oracle.json", "test:opy-rs::compiler-issue-47-invalid-do-while-placement"], "note": "The source implementation reports the stable source-attributed do-while-placement diagnostic for a non-prefix do-while."}, {"fixture": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index a29b836..b61b684 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -69,7 +69,7 @@ repository: | `issue-47-unsupported` | #47 negative probe: a break hidden inside a conditional switch arm is accepted by the source implementation/oracle but rejected by the compiler with a stable source-attributed diagnostic | | `issue-47-switch-order` | #47 pinned oracle probe for a default arm before later case arms and source-order fallthrough | | `issue-47-switch-structured-target` | #47 pinned oracle probe for nested if/while structure in an earlier arm and later case/default targets | -| `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks; the source implementation preserves the source while the compiler reports the canonical multi-target WIR gap | +| `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks lowered through canonical nested switch-exit WIR | | `issue-47-do-while-shapes` | #47 pinned oracle probe for direct, conditional, and nested do-while break lowering | | `issue-47-do-while-invalid-placement` | #47 pinned negative probe for the stable do-while placement diagnostic | | `issue-29-*` | directive/include/main-file preprocessing probes | diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json index 928f843..587f9e5 100644 --- a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json @@ -4,6 +4,7 @@ "category": "compilation", "features": ["switch", "case", "default", "break"], "source": "source.opy", + "semanticOracle": "semantic-oracle.json", "expectedStatus": "success", "provenance": { "kind": "original", diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json new file mode 100644 index 0000000..5173d6c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "fixture": "synthetic/issue-47-switch-multiple-break", + "input": { + "sha256": "c51625efd490b713cb2a8d17e188458cf24c4c142a0e62562e7477a27bd33f33" + }, + "compile": { + "status": "success", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch multiple break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(6, 0, 2, 5), Add(1, Index Of Array Value(Array(1, 2, 3), Global.value))));\n Set Global Variable(value, 1);\n Else;\n If(True);\n Set Global Variable(value, 2);\n Else;\n Set Global Variable(value, 3);\n Set Global Variable(value, 4);\n End;\n }\n}\n" + } +} diff --git a/compatibility/run_native.py b/compatibility/run_native.py index a890316..6545bab 100644 --- a/compatibility/run_native.py +++ b/compatibility/run_native.py @@ -55,6 +55,7 @@ def run_semantic_evidence( project_sha256: str, ) -> dict[str, Any]: source = directory / metadata["source"] + oracle = directory / metadata.get("semanticOracle", "oracle.json") completed = subprocess.run( [ str(binary), @@ -63,7 +64,7 @@ def run_semantic_evidence( "--root", ".", "--oracle", - "oracle.json", + oracle.name, "--input-sha256", project_sha256, ], diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 24c086c..a694d3b 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -737,7 +737,7 @@ "test:opy-rs::compiler-issue-47-residual-native-wir-gaps", "contract:workshop-rs-v0.1.16" ], - "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps, while nested conditional switch break and multi-break switch cases remain stable source-attributed unsupported-integration-surface diagnostics because OPY has no lossless lowering for those source shapes." + "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps; nested conditional switch break remains a stable source-attributed unsupported-integration-surface diagnostic, while multiple direct switch breaks use canonical nested exit layers with pinned action-trace evidence." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-rs/src/compiler/mod.rs b/crates/opy-rs/src/compiler/mod.rs index fc53df5..e30185a 100644 --- a/crates/opy-rs/src/compiler/mod.rs +++ b/crates/opy-rs/src/compiler/mod.rs @@ -1374,6 +1374,7 @@ enum DeleteAssignment { type SwitchBreak = (usize, HirSpan); type LoweredSwitchBody = (Vec, Option); +type LoweredSwitchArm<'a> = (Option<&'a Expr>, Vec, Option); /// Return the condition path when a statement consists only of a continue. /// An empty path represents an unconditional continue; a non-empty path is @@ -3076,9 +3077,10 @@ impl<'a> Lowering<'a> { let selector = self.lower_value(value)?; let mut case_values = Vec::new(); let mut lowered_arms = Vec::with_capacity(arms.len()); - let mut case_offsets = Vec::new(); - let mut offset = 0usize; - let mut default_offset = None; + let mut has_default = false; + let mut legacy_case_offsets = Vec::new(); + let mut legacy_offset = 0; + let mut legacy_default_offset = None; for arm in arms { let (value, (body, break_at)) = match arm { @@ -3087,47 +3089,115 @@ impl<'a> Lowering<'a> { (Some(value), self.lower_switch_body(body)?) } SwitchArm::Default { body, span } => { - if default_offset.is_some() { + if has_default { return Err( self.unsupported("a switch may contain at most one default arm", *span) ); } - default_offset = Some(offset); + has_default = true; + legacy_default_offset = Some(legacy_offset); (None, self.lower_switch_body(body)?) } }; if value.is_some() { - case_offsets.push(offset); + legacy_case_offsets.push(legacy_offset); } - offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some()); - lowered_arms.push((value, body, break_at)); + legacy_offset += + self.canonical_action_width(&body, span)? + usize::from(break_at.is_some()); + lowered_arms.push((value.map(Box::as_ref), body, break_at)); } - let default_offset = default_offset.unwrap_or(offset); let break_arms: Vec<_> = lowered_arms .iter() .enumerate() .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at))) .collect(); - if break_arms.len() > 1 { - let (first_index, first_break) = break_arms[0]; - let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0 - || lowered_arms - .iter() - .skip(first_index + 1) - .any(|(_, body, _)| !body.is_empty()); - if has_actions_after_first { - return Err(self.unsupported( - "multiple switch breaks with later reachable actions require canonical switch targets", - Some(break_arms[1].1.1), - )); - } - } + let first_break = break_arms.first().copied(); + let has_later_reachable_actions = + first_break.is_some_and(|(break_index, (break_at, _))| { + lowered_arms[break_index].1.len() > break_at + || lowered_arms + .iter() + .skip(break_index + 1) + .any(|(_, body, _)| !body.is_empty()) + }); + let use_shared_exit = break_arms.len() > 1 && has_later_reachable_actions; let case_values = self.lower_array(case_values, span)?; let value_span = self.wir_span(span)?; + if !use_shared_exit { + let default_offset = legacy_default_offset.unwrap_or(legacy_offset); + let offset_values = std::iter::once(default_offset) + .chain(legacy_case_offsets) + .map(|value| { + self.wir.values.push(ValueNode::new( + Value::Number { + value: value as f64, + text: value.to_string(), + }, + value_span, + )) + }) + .collect(); + let offsets = self.lower_array(offset_values, span)?; + let skip = self.lower_switch_selector(selector, case_values, offsets, span)?; + let true_value = self + .wir + .values + .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + let mut branch_body = vec![skip]; + let else_body = if let Some((break_index, (break_at, _))) = first_break { + for (index, (_, body, _)) in lowered_arms.iter().enumerate() { + if index < break_index { + branch_body.extend(body.iter().copied()); + } else if index == break_index { + branch_body.extend(body[..break_at].iter().copied()); + } + } + let mut tail = Vec::new(); + tail.extend(lowered_arms[break_index].1[break_at..].iter().copied()); + for (_, body, _) in lowered_arms.iter().skip(break_index + 1) { + tail.extend(body.iter().copied()); + } + Some(tail) + } else { + for (_, body, _) in &lowered_arms { + branch_body.extend(body.iter().copied()); + } + None + }; + return Ok(self.wir.actions.push(Action::If { + branches: vec![wir::IfBranch { + condition: true_value, + body: branch_body, + }], + else_body, + span: self.wir_span(span)?, + })); + } + + let offsets = self + .wir + .values + .push(ValueNode::new(Value::Array(Vec::new()), value_span)); + let skip = self.lower_switch_selector(selector, case_values, offsets, span)?; + let mut arm_offsets = vec![None; lowered_arms.len()]; + let (switch, switch_end) = + self.lower_switch_level(&lowered_arms, 0, Some(skip), 0, &mut arm_offsets, span)?; + + let default_offset = lowered_arms + .iter() + .enumerate() + .find_map(|(index, (value, _, _))| value.is_none().then(|| arm_offsets[index].unwrap())) + .unwrap_or(switch_end); let offset_values = std::iter::once(default_offset) - .chain(case_offsets) + .chain( + lowered_arms + .iter() + .enumerate() + .filter(|(_, (value, _, _))| value.is_some()) + .map(|(index, _)| arm_offsets[index].unwrap()), + ) .map(|value| { self.wir.values.push(ValueNode::new( Value::Number { @@ -3138,7 +3208,29 @@ impl<'a> Lowering<'a> { )) }) .collect(); - let offsets = self.lower_array(offset_values, span)?; + let offset_values = self.lower_array(offset_values, span)?; + let offset_value = self + .wir + .values + .get(offset_values) + .expect("switch offset array must exist") + .value + .clone(); + let Some(node) = self.wir.values.get_mut(offsets) else { + unreachable!("switch offset placeholder must exist") + }; + node.value = offset_value; + + Ok(switch) + } + + fn lower_switch_selector( + &mut self, + selector: wir::ValueId, + case_values: wir::ValueId, + offsets: wir::ValueId, + span: Option, + ) -> Result { let one = self.wir.values.push(ValueNode::new( Value::Number { value: 1.0, @@ -3167,47 +3259,93 @@ impl<'a> Lowering<'a> { }, self.wir_span(span)?, )); - let skip = self.wir.actions.push(Action::Call { + Ok(self.wir.actions.push(Action::Call { name: "skip".to_string(), args: vec![skip_condition], span: self.wir_span(span)?, - }); - let true_value = self - .wir - .values - .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + })) + } - let first_break = break_arms.first().copied(); - let mut branch_body = vec![skip]; - let else_body = if let Some((break_index, (break_at, _))) = first_break { - for (index, (_, body, _)) in lowered_arms.iter().enumerate() { - if index < break_index { - branch_body.extend(body.iter().copied()); - } else if index == break_index { - branch_body.extend(body[..break_at].iter().copied()); - } - } - let mut tail = Vec::new(); - tail.extend(lowered_arms[break_index].1[break_at..].iter().copied()); - for (_, body, _) in lowered_arms.iter().skip(break_index + 1) { - tail.extend(body.iter().copied()); - } - Some(tail) + fn lower_switch_level( + &mut self, + arms: &[LoweredSwitchArm<'_>], + start: usize, + selector_skip: Option, + level_offset: usize, + arm_offsets: &mut [Option], + span: Option, + ) -> Result<(wir::ActionId, usize), IntegrationError> { + let break_index = (start..arms.len()) + .find(|index| arms[*index].2.is_some()) + .expect("switch level must contain a break"); + let mut branch_body = Vec::new(); + if let Some(selector_skip) = selector_skip { + branch_body.push(selector_skip); + } + let mut branch_offset = 0; + for index in start..=break_index { + arm_offsets[index] = Some(if selector_skip.is_some() { + level_offset + branch_offset + } else if index == start { + level_offset + } else { + level_offset + 1 + branch_offset + }); + let (_, body, break_at) = &arms[index]; + let body = if index == break_index { + &body[..break_at.as_ref().unwrap().0] + } else { + body.as_slice() + }; + branch_offset += self.canonical_action_width(body, span)?; + branch_body.extend(body.iter().copied()); + } + let branch_width = self.canonical_action_width(&branch_body, span)?; + let (_, break_body, Some((break_at, _))) = &arms[break_index] else { + unreachable!("break index must point to a switch break") + }; + let mut else_body = break_body[*break_at..].to_vec(); + let tail_width = self.canonical_action_width(&else_body, span)?; + let else_content_start = if selector_skip.is_some() { + level_offset + branch_width + tail_width } else { - for (_, body, _) in &lowered_arms { - branch_body.extend(body.iter().copied()); + level_offset + branch_width + tail_width + 2 + }; + let has_next_break = (break_index + 1..arms.len()).any(|index| arms[index].2.is_some()); + let end_offset = if has_next_break { + let (child, child_end) = self.lower_switch_level( + arms, + break_index + 1, + None, + else_content_start, + arm_offsets, + span, + )?; + else_body.push(child); + child_end + } else { + let mut offset = else_content_start; + for index in break_index + 1..arms.len() { + arm_offsets[index] = Some(offset); + let (_, body, _) = &arms[index]; + offset += self.canonical_action_width(body, span)?; + else_body.extend(body.iter().copied()); } - None + offset }; - - Ok(self.wir.actions.push(Action::If { + let true_value = self + .wir + .values + .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + let switch = self.wir.actions.push(Action::If { branches: vec![wir::IfBranch { condition: true_value, body: branch_body, }], - else_body, + else_body: Some(else_body), span: self.wir_span(span)?, - })) + }); + Ok((switch, end_offset)) } fn lower_switch_body( diff --git a/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs index 1ee1f34..21e04c9 100644 --- a/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs +++ b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use crate::Compiler; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::roundtrip::equivalent; +use workshop_rs::wir::{self, Action, Value}; fn fixture_dir(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -39,6 +40,201 @@ fn assert_native_wir_equivalent(name: &str) { ); } +#[derive(Debug, PartialEq, Eq)] +enum NativeInstruction { + If, + Else, + End, + Skip(wir::ValueId), + SetGlobal(i64), +} + +fn flatten_action( + program: &wir::Program, + action_id: wir::ActionId, + rule_final: bool, + instructions: &mut Vec, +) { + match program.actions.get(action_id).unwrap() { + Action::SetGlobalVariable { value, .. } => { + let Value::Number { value, .. } = &program.values.get(*value).unwrap().value else { + panic!("switch behavior probe expects numeric assignments") + }; + instructions.push(NativeInstruction::SetGlobal(*value as i64)); + } + Action::If { + branches, + else_body, + .. + } => { + for (index, branch) in branches.iter().enumerate() { + if index > 0 { + instructions.push(NativeInstruction::Else); + } + instructions.push(NativeInstruction::If); + for action in &branch.body { + flatten_action(program, *action, false, instructions); + } + } + if let Some(else_body) = else_body { + instructions.push(NativeInstruction::Else); + for action in else_body { + flatten_action(program, *action, false, instructions); + } + } + if !rule_final { + instructions.push(NativeInstruction::End); + } + } + Action::Call { name, args, .. } if name == "skip" => { + instructions.push(NativeInstruction::Skip(args[0])); + } + other => panic!("switch behavior probe found unexpected action: {other:?}"), + } +} + +fn array_values(program: &wir::Program, value_id: wir::ValueId) -> Vec { + match &program.values.get(value_id).unwrap().value { + Value::Array(values) => values.clone(), + Value::Call { name, args } if name == "array" => args.clone(), + other => panic!("expected an array value, got {other:?}"), + } +} + +fn numeric_value(program: &wir::Program, value_id: wir::ValueId, selector: i64) -> i64 { + match &program.values.get(value_id).unwrap().value { + Value::Number { value, .. } => *value as i64, + Value::GlobalVariable(_) => selector, + Value::Array(values) => panic!("array value must be consumed by a call: {values:?}"), + Value::Call { name, args } => match name.as_str() { + "add" => args + .iter() + .map(|value| numeric_value(program, *value, selector)) + .sum(), + "indexOfArrayValue" => { + let values = array_values(program, args[0]); + let needle = numeric_value(program, args[1], selector); + values + .iter() + .position(|value| numeric_value(program, *value, selector) == needle) + .map_or(-1, |index| index as i64) + } + "valueInArray" => { + let values = array_values(program, args[0]); + let index = numeric_value(program, args[1], selector); + numeric_value(program, values[index as usize], selector) + } + other => panic!("switch behavior probe found unexpected value call: {other}"), + }, + other => panic!("switch behavior probe found unexpected value: {other:?}"), + } +} + +fn matching_end(instructions: &[NativeInstruction], else_index: usize) -> usize { + let mut nested = 0; + for (index, instruction) in instructions.iter().enumerate().skip(else_index + 1) { + match instruction { + NativeInstruction::If => nested += 1, + NativeInstruction::End if nested == 0 => return index, + NativeInstruction::End => nested -= 1, + _ => {} + } + } + instructions.len() +} + +fn native_switch_trace(program: &wir::Program, rule: &wir::Rule, selector: i64) -> Vec { + let mut instructions = Vec::new(); + for (index, action) in rule.actions.iter().enumerate() { + flatten_action( + program, + *action, + index + 1 == rule.actions.len(), + &mut instructions, + ); + } + + let mut trace = Vec::new(); + let mut if_stack = Vec::new(); + let mut pc = 0; + while pc < instructions.len() { + match &instructions[pc] { + NativeInstruction::If => { + if_stack.push(true); + pc += 1; + } + NativeInstruction::Else => { + if if_stack.pop().unwrap_or(false) { + pc = matching_end(&instructions, pc) + 1; + } else { + if_stack.push(true); + pc += 1; + } + } + NativeInstruction::End => { + if_stack.pop(); + pc += 1; + } + NativeInstruction::Skip(value) => { + let count = numeric_value(program, *value, selector); + assert!(count >= 0, "switch skip count must be non-negative"); + pc += count as usize + 1; + } + NativeInstruction::SetGlobal(value) => { + trace.push(*value); + pc += 1; + } + } + } + trace +} + +fn pinned_switch_traces(dir: &Path) -> Vec<(i64, Vec)> { + let workshop = oracle_workshop(dir); + let offsets = workshop + .split("Skip(Value In Array(Array(") + .nth(1) + .unwrap() + .split("), Add") + .next() + .unwrap() + .split(", ") + .map(|value| value.parse::().unwrap()) + .collect::>(); + let mut actions = Vec::new(); + for line in workshop.lines() { + if let Some(value) = line + .trim() + .strip_prefix("Set Global Variable(value, ") + .and_then(|line| line.strip_suffix(");")) + { + actions.push(value.parse::().unwrap()); + } else if line.trim() == "Else;" { + actions.push(-1); + } + } + let trace_at = |offset: i64| { + let mut trace = Vec::new(); + for action in actions.iter().skip(offset as usize) { + if *action == -1 { + break; + } + trace.push(*action); + } + trace + }; + [0, 1, 2, 3, 99] + .into_iter() + .map(|selector| { + let case_offset = [1, 2, 3] + .iter() + .position(|value| *value == selector) + .map_or(offsets[0], |index| offsets[index + 1]); + (selector, trace_at(case_offset)) + }) + .collect() +} + #[test] fn issue_47_switch_lowering_matches_the_pinned_oracle() { for name in [ @@ -57,17 +253,53 @@ fn issue_47_do_while_break_shapes_match_the_pinned_oracle() { } #[test] -fn issue_47_multiple_switch_breaks_are_not_silently_dropped() { +fn issue_47_multiple_switch_breaks_match_independent_semantic_oracle() { let compiler = Compiler::new().unwrap(); let dir = fixture_dir("issue-47-switch-multiple-break"); let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); let hir = crate::compile(&source, "source.opy", &dir).unwrap(); - let error = match compiler.compile_hir(&hir) { - Ok(_) => panic!("multi-break switch must not be silently truncated"), - Err(error) => error, - }; - assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); - assert_eq!(error.diagnostic.span.unwrap().start.line, 11); + let artifact = compiler + .compile_hir(&hir) + .expect("multi-break switch must lower"); + let semantic_oracle: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("semantic-oracle.json")).unwrap()) + .unwrap(); + let catalog = Catalog::builtin().unwrap(); + let semantic_wir = workshop_rs::parser::parse( + semantic_oracle["compile"]["workshop"].as_str().unwrap(), + &catalog, + &Locale::new("en-US"), + ) + .unwrap(); + assert!( + equivalent(&artifact.wir, &semantic_wir), + "native WIR diverged from the independent switch semantic oracle\n{}", + artifact.emitted + ); +} + +#[test] +fn pinned_overpy_switch_action_trace() { + let compiler = Compiler::new().unwrap(); + let dir = fixture_dir("issue-47-switch-multiple-break"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = crate::compile(&source, "source.opy", &dir).unwrap(); + let artifact = compiler + .compile_hir(&hir) + .expect("multi-break switch must lower"); + let rule = artifact + .wir + .rules + .iter() + .find(|rule| rule.name == "issue 47 switch multiple break") + .unwrap(); + for (selector, expected) in pinned_switch_traces(&dir) { + assert_eq!( + native_switch_trace(&artifact.wir, rule, selector), + expected, + "native switch behavior diverged from the pinned OverPy action trace for selector {selector}" + ); + } } #[test] diff --git a/crates/opy-rs/support-matrix.json b/crates/opy-rs/support-matrix.json index 24c086c..a694d3b 100644 --- a/crates/opy-rs/support-matrix.json +++ b/crates/opy-rs/support-matrix.json @@ -737,7 +737,7 @@ "test:opy-rs::compiler-issue-47-residual-native-wir-gaps", "contract:workshop-rs-v0.1.16" ], - "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps, while nested conditional switch break and multi-break switch cases remain stable source-attributed unsupported-integration-surface diagnostics because OPY has no lossless lowering for those source shapes." + "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps; nested conditional switch break remains a stable source-attributed unsupported-integration-surface diagnostic, while multiple direct switch breaks use canonical nested exit layers with pinned action-trace evidence." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-rs/tests/differential.rs b/crates/opy-rs/tests/differential.rs index e2ce676..6487056 100644 --- a/crates/opy-rs/tests/differential.rs +++ b/crates/opy-rs/tests/differential.rs @@ -305,7 +305,7 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { &mut cases, "synthetic/issue-47-switch-multiple-break", false, - "Issue #47 multi-break probe; the frontend preserves all authored arms and breaks while the compiler reports the canonical WIR capability gap.", + "Issue #47 multi-break probe; the frontend preserves all authored arms and breaks while the compiler lowers them through canonical nested switch-exit WIR.", ); resolve( &mut cases,