From 399bc30a819ac3a574442f157c1afe5e9eb97978 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 3 Sep 2026 15:03:42 +0800 Subject: [PATCH 1/3] fix(compiler): lower multiple switch breaks Fixes #172 --- compatibility/compiler-expectations.json | 14 +- crates/opy-rs/src/compiler/mod.rs | 246 ++++++++++++++---- .../src/compiler/tests/issue_47_oracle.rs | 40 ++- 3 files changed, 232 insertions(+), 68 deletions(-) diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 1f5ff30..87ceadc 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -390,18 +390,16 @@ }, { "fixture": "synthetic/issue-47-switch-multiple-break", - "nativeStatus": "failure", - "classification": "unsupported", - "comparison": "diagnostic-code", + "nativeStatus": "success", + "classification": "match", + "comparison": "compiler-contract", "evidence": [ "oracle:synthetic/issue-47-switch-multiple-break/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_use_shared_switch_exit_layers" ], - "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": "Multiple direct case-local breaks lower through nested canonical WIR exit layers and preserve later case selection. The pinned OverPy text uses successive Else markers that workshop-rs v0.1.16 cannot represent losslessly, so the stable compiler-contract test is the decisive evidence for this slice." }, { "fixture": "census/workshop-feature-census", 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..a0b603d 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::Action; fn fixture_dir(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -57,17 +58,44 @@ 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_use_shared_switch_exit_layers() { 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, + 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") + .expect("fixture rule must be present"); + let Action::If { + branches, + else_body: Some(else_body), + .. + } = artifact.wir.actions.get(rule.actions[0]).unwrap() + else { + panic!("multi-break switch must have an outer switch-exit layer") }; - assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); - assert_eq!(error.diagnostic.span.unwrap().start.line, 11); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].body.len(), 2); + assert_eq!(else_body.len(), 1); + + let Action::If { + branches, + else_body: Some(else_body), + .. + } = artifact.wir.actions.get(else_body[0]).unwrap() + else { + panic!("the later case break must have its own exit layer") + }; + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].body.len(), 1); + assert_eq!(else_body.len(), 2); + assert!(artifact.emitted.contains("Array(6, 0, 2, 5)")); } #[test] From de2067a432cda96c17b16556b53925c382ba3a60 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 3 Sep 2026 16:29:20 +0800 Subject: [PATCH 2/3] test(compiler): strengthen switch break evidence Refs #172 --- compatibility/compiler-expectations.json | 8 ++-- compatibility/differential-expectations.json | 2 +- compatibility/fixtures/README.md | 2 +- .../fixture.json | 1 + .../semantic-oracle.json | 11 +++++ compatibility/run_native.py | 3 +- compatibility/support-matrix.json | 2 +- .../src/compiler/tests/issue_47_oracle.rs | 48 +++++++------------ 8 files changed, 38 insertions(+), 39 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 87ceadc..3aa925e 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -392,14 +392,16 @@ "fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", - "comparison": "compiler-contract", + "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_use_shared_switch_exit_layers" + "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle" ], "owner": "opy-rs#172", - "note": "Multiple direct case-local breaks lower through nested canonical WIR exit layers and preserve later case selection. The pinned OverPy text uses successive Else markers that workshop-rs v0.1.16 cannot represent losslessly, so the stable compiler-contract test is the decisive evidence for this slice." + "note": "The pinned OverPy snapshot is preserved as source evidence; its successive Else switch encoding is normalized into this independent canonical Workshop semantic oracle so the compatibility gate directly compares native canonical WIR. The nested exit layers cover case selection, local break, fallthrough, and default behavior." }, { "fixture": "census/workshop-feature-census", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index cde6487..cc6472e 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"], "note": "The source implementation preserves the multi-break source and the compiler lowers its direct case-local breaks through canonical Workshop control flow."}, {"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..c484fc1 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." }, { "id": "compilation/workshop-lowering", 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 a0b603d..6779858 100644 --- a/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs +++ b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs @@ -5,7 +5,6 @@ use std::path::{Path, PathBuf}; use crate::Compiler; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::roundtrip::equivalent; -use workshop_rs::wir::Action; fn fixture_dir(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -58,7 +57,7 @@ fn issue_47_do_while_break_shapes_match_the_pinned_oracle() { } #[test] -fn issue_47_multiple_switch_breaks_use_shared_switch_exit_layers() { +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(); @@ -66,36 +65,21 @@ fn issue_47_multiple_switch_breaks_use_shared_switch_exit_layers() { 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") - .expect("fixture rule must be present"); - let Action::If { - branches, - else_body: Some(else_body), - .. - } = artifact.wir.actions.get(rule.actions[0]).unwrap() - else { - panic!("multi-break switch must have an outer switch-exit layer") - }; - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].body.len(), 2); - assert_eq!(else_body.len(), 1); - - let Action::If { - branches, - else_body: Some(else_body), - .. - } = artifact.wir.actions.get(else_body[0]).unwrap() - else { - panic!("the later case break must have its own exit layer") - }; - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].body.len(), 1); - assert_eq!(else_body.len(), 2); - assert!(artifact.emitted.contains("Array(6, 0, 2, 5)")); + 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] From d14307537d8951c1ca9b86460f0bae8748408917 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 3 Sep 2026 16:55:43 +0800 Subject: [PATCH 3/3] test(compiler): add pinned switch behavior evidence Refs #172 --- compatibility/compiler-expectations.json | 5 +- compatibility/differential-expectations.json | 2 +- compatibility/support-matrix.json | 2 +- .../src/compiler/tests/issue_47_oracle.rs | 220 ++++++++++++++++++ crates/opy-rs/support-matrix.json | 2 +- crates/opy-rs/tests/differential.rs | 2 +- 6 files changed, 227 insertions(+), 6 deletions(-) diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 3aa925e..4fd4c27 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -398,10 +398,11 @@ "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_match_independent_semantic_oracle" + "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": "opy-rs#172", - "note": "The pinned OverPy snapshot is preserved as source evidence; its successive Else switch encoding is normalized into this independent canonical Workshop semantic oracle so the compatibility gate directly compares native canonical WIR. The nested exit layers cover case selection, local break, fallthrough, and default behavior." + "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 cc6472e..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::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle"], "note": "The source implementation preserves the multi-break source and the compiler lowers its direct case-local breaks through canonical Workshop control flow."}, + {"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/support-matrix.json b/compatibility/support-matrix.json index c484fc1..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; nested conditional switch break remains a stable source-attributed unsupported-integration-surface diagnostic, while multiple direct switch breaks use canonical nested exit layers." + "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/tests/issue_47_oracle.rs b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs index 6779858..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 [ @@ -82,6 +278,30 @@ fn issue_47_multiple_switch_breaks_match_independent_semantic_oracle() { ); } +#[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] fn issue_47_invalid_do_while_placement_is_source_attributed() { let dir = fixture_dir("issue-47-do-while-invalid-placement"); 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,