test(joinir): Phase 104 read_digits loop(true) parity

This commit is contained in:
nyash-codex
2025-12-17 18:29:27 +09:00
parent ce501113a7
commit 950560a3d9
20 changed files with 954 additions and 30 deletions

View File

@ -2,7 +2,7 @@
// Goal: one branch returns early (no join), other returns later. // Goal: one branch returns early (no join), other returns later.
// Expect: two numeric lines "7" then "2". // Expect: two numeric lines "7" then "2".
box Main { static box Main {
g(flag) { g(flag) {
if flag == 0 { if flag == 0 {
return 7 return 7
@ -12,9 +12,8 @@ box Main {
} }
main() { main() {
print(me.g(0)) print(g(0))
print(me.g(1)) print(g(1))
return "OK" return "OK"
} }
} }

View File

@ -2,7 +2,7 @@
// Goal: require merge (PHI-equivalent) in an if-only program (no loops), // Goal: require merge (PHI-equivalent) in an if-only program (no loops),
// and include one nested if to ensure nested merge stability. // and include one nested if to ensure nested merge stability.
box Main { static box Main {
main() { main() {
local x = 0 local x = 0
@ -20,4 +20,3 @@ box Main {
return "OK" return "OK"
} }
} }

View File

@ -0,0 +1,35 @@
// Phase 104: read_digits_from loop(true) + break-only minimal fixture
// Expect numeric output lines: 2 then 1
static box Main {
read_digits_min(s, pos) {
local i = pos
local out = ""
loop(true) {
local ch = s.substring(i, i + 1)
// end-of-string guard
if ch == "" { break }
// digit check (match real-app shape; keep on one line to avoid ASI ambiguity)
if ch == "0" || ch == "1" || ch == "2" || ch == "3" || ch == "4" || ch == "5" || ch == "6" || ch == "7" || ch == "8" || ch == "9" {
out = out + ch
i = i + 1
} else {
break
}
}
return out
}
main() {
local a = read_digits_min("12x", 0)
local b = read_digits_min("9", 0)
print(a.length())
print(b.length())
return "OK"
}
}

View File

@ -61,8 +61,10 @@ JoinIR の箱構造と責務、ループ/if の lowering パターンを把握
- `docs/development/current/main/phases/phase-102/README.md` - `docs/development/current/main/phases/phase-102/README.md`
14. Phase 103: if-only regression baselineVM + LLVM EXE / plan 14. Phase 103: if-only regression baselineVM + LLVM EXE / plan
- `docs/development/current/main/phases/phase-103/README.md` - `docs/development/current/main/phases/phase-103/README.md`
15. Phase 104: loop(true) break-only digitsVM + LLVM EXE
- `docs/development/current/main/phases/phase-104/README.md`
6. MIR BuilderContext 分割の入口) 6. MIR BuilderContext 分割の入口)
- `src/mir/builder/README.md` - `src/mir/builder/README.md`
7. Scope/BindingIdshadowing・束縛同一性の段階移行 7. Scope/BindingIdshadowing・束縛同一性の段階移行
- `docs/development/current/main/phase73-scope-manager-design.md` - `docs/development/current/main/phase73-scope-manager-design.md`
- `docs/development/current/main/PHASE_74_SUMMARY.md` - `docs/development/current/main/PHASE_74_SUMMARY.md`

View File

@ -1,5 +1,12 @@
# Self Current Task — Now (main) # Self Current Task — Now (main)
## 2025-12-17Phase 104 完了 ✅
**Phase 104: loop(true) + break-only digitsread_digits 系)**
- read_digits_from 形の `loop(true)` を Pattern2 で受理loop var 抽出 + break cond 正規化)
- fixture: `apps/tests/phase104_read_digits_loop_true_min.hako`expected: `2`, `1`
- smoke: `tools/smokes/v2/profiles/integration/apps/phase104_read_digits_vm.sh` / `tools/smokes/v2/profiles/integration/apps/phase104_read_digits_llvm_exe.sh`
## 2025-12-17Phase 103 P0 完了 ✅ ## 2025-12-17Phase 103 P0 完了 ✅
**Phase 103: if-only regression baseline** **Phase 103: if-only regression baseline**

View File

@ -0,0 +1,9 @@
# Phase 104: loop(true) + break-only digitsread_digits 系)
目的: `loop(true)` の break-only ループread_digits_from 形)を Pattern2 経路で VM/LLVM EXE parity 固定する。
Fixture: `apps/tests/phase104_read_digits_loop_true_min.hako`expected: `2`, `1`
Smokes: `tools/smokes/v2/profiles/integration/apps/phase104_read_digits_vm.sh` / `tools/smokes/v2/profiles/integration/apps/phase104_read_digits_llvm_exe.sh`
DONE:
- loop(true) counter 抽出: `LoopTrueCounterExtractorBox`
- break 条件break when true正規化 + digit set 固定: `ReadDigitsBreakConditionBox`

View File

@ -33,6 +33,8 @@ mod control_flow; // thin wrappers to centralize control-flow entrypoints
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility) // Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use control_flow::{detect_skip_whitespace_pattern, SkipWhitespaceInfo}; pub(crate) use control_flow::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use control_flow::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer // Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use control_flow::{detect_continue_pattern, ContinuePatternInfo}; pub(crate) use control_flow::{detect_continue_pattern, ContinuePatternInfo};
// Phase 143-P0: Re-export parse_number pattern detection for loop_canonicalizer // Phase 143-P0: Re-export parse_number pattern detection for loop_canonicalizer

View File

@ -19,6 +19,9 @@ pub(in crate::mir::builder) mod trace;
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility) // Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use patterns::{detect_skip_whitespace_pattern, SkipWhitespaceInfo}; pub(crate) use patterns::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use patterns::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer // Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use patterns::{detect_continue_pattern, ContinuePatternInfo}; pub(crate) use patterns::{detect_continue_pattern, ContinuePatternInfo};

View File

@ -1043,6 +1043,115 @@ pub fn detect_skip_whitespace_pattern(body: &[ASTNode]) -> Option<SkipWhitespace
} }
} }
// ============================================================================
// Phase 104: loop(true) + break-only digits (read_digits_from)
// ============================================================================
/// loop(true) + break-only digits pattern information
#[derive(Debug, Clone, PartialEq)]
pub struct ReadDigitsLoopTrueInfo {
/// Counter variable name (e.g., "i")
pub carrier_name: String,
/// Constant step increment (currently only supports +1)
pub delta: i64,
/// Body statements before the digit-check if (may include `ch = substring(...)`, `if ch==\"\" { break }`, etc.)
pub body_stmts: Vec<ASTNode>,
}
/// Detect read_digits_from-like pattern in loop body (loop(true) expected at callsite)
///
/// Recognized minimal shape (JsonCursorBox/MiniJsonLoader):
/// ```text
/// loop(true) {
/// local ch = s.substring(i, i+1)
/// if ch == "" { break }
/// if is_digit(ch) { out = out + ch; i = i + 1 } else { break }
/// }
/// ```
///
/// Contract (Phase 104 minimal):
/// - Last statement is `if ... { ... } else { break }`
/// - Then branch contains an update `i = i + 1`
/// - Then branch may contain other updates (e.g., `out = out + ch`)
pub fn detect_read_digits_loop_true_pattern(body: &[ASTNode]) -> Option<ReadDigitsLoopTrueInfo> {
if body.is_empty() {
return None;
}
// Last statement must be if-else with break
let last_stmt = &body[body.len() - 1];
let (then_body, else_body) = match last_stmt {
ASTNode::If {
then_body,
else_body: Some(else_body),
..
} => (then_body, else_body),
_ => return None,
};
// Else branch must be single break
if else_body.len() != 1 || !matches!(else_body[0], ASTNode::Break { .. }) {
return None;
}
// Then branch must include `i = i + 1` (allow other statements too)
let mut carrier_name: Option<String> = None;
let mut delta: Option<i64> = None;
for stmt in then_body {
let (name, d) = match stmt {
ASTNode::Assignment { target, value, .. } => {
let target_name = match target.as_ref() {
ASTNode::Variable { name, .. } => name.clone(),
_ => continue,
};
match value.as_ref() {
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} => {
let left_name = match left.as_ref() {
ASTNode::Variable { name, .. } => name,
_ => continue,
};
if left_name != &target_name {
continue;
}
let const_val = match right.as_ref() {
ASTNode::Literal {
value: LiteralValue::Integer(n),
..
} => *n,
_ => continue,
};
(target_name, const_val)
}
_ => continue,
}
}
_ => continue,
};
// Phase 104 minimal: only accept +1 step
if d == 1 {
carrier_name = Some(name);
delta = Some(1);
break;
}
}
let carrier_name = carrier_name?;
let delta = delta?;
let body_stmts = body[..body.len() - 1].to_vec();
Some(ReadDigitsLoopTrueInfo {
carrier_name,
delta,
body_stmts,
})
}
// ============================================================================ // ============================================================================
// Phase 91 P5b (Escape Sequence Handling) Pattern // Phase 91 P5b (Escape Sequence Handling) Pattern
// ============================================================================ // ============================================================================

View File

@ -0,0 +1,211 @@
//! LoopTrueCounterExtractorBox - loop(true) からの loop counter 抽出Pattern2専用
//!
//! Phase 104: read_digits_from 形loop(true) + break-onlyを Pattern2 で扱うため、
//! `condition` 側に loop var が無いケースで body から loop counter例: iを一意に確定する。
//!
//! SSOT/Fail-Fast:
//! - 目的は「曖昧な loop(true) を通さない」こと。
//! - 1変数・+1 だけを許可し、取りこぼしは理由付き Err にする。
use crate::ast::{ASTNode, BinaryOperator, LiteralValue};
use crate::mir::ValueId;
use std::collections::BTreeMap;
pub(crate) struct LoopTrueCounterExtractorBox;
impl LoopTrueCounterExtractorBox {
pub(crate) fn is_loop_true(condition: &ASTNode) -> bool {
matches!(
condition,
ASTNode::Literal {
value: LiteralValue::Bool(true),
..
}
)
}
/// Extract a unique loop counter variable from loop(true) body.
///
/// Current supported shape (Phase 104 minimal):
/// - There exists an assignment `i = i + 1` somewhere in the body (including nested if).
/// - There exists a substring read using that counter: `s.substring(i, i + 1)` (same `i`).
///
/// Fail-Fast (returns Err) when:
/// - No counter candidate found
/// - Multiple different candidates found
/// - Candidate not found in `variable_map` (loop-outer var required)
/// - Substring pattern not found (guards against accidental matches)
pub(crate) fn extract_loop_counter_from_body(
body: &[ASTNode],
variable_map: &BTreeMap<String, ValueId>,
) -> Result<(String, ValueId), String> {
let mut candidates: Vec<String> = Vec::new();
fn walk(node: &ASTNode, out: &mut Vec<String>) {
match node {
ASTNode::Assignment { target, value, .. } => {
if let (Some(name), true) = (
extract_var_name(target.as_ref()),
is_self_plus_const_one(value.as_ref(), target.as_ref()),
) {
out.push(name);
}
}
ASTNode::If {
condition: _,
then_body,
else_body,
..
} => {
for s in then_body {
walk(s, out);
}
if let Some(eb) = else_body {
for s in eb {
walk(s, out);
}
}
}
ASTNode::Loop { body, .. } => {
for s in body {
walk(s, out);
}
}
_ => {}
}
}
fn extract_var_name(n: &ASTNode) -> Option<String> {
match n {
ASTNode::Variable { name, .. } => Some(name.clone()),
_ => None,
}
}
fn is_self_plus_const_one(value: &ASTNode, target: &ASTNode) -> bool {
let target_name = match extract_var_name(target) {
Some(n) => n,
None => return false,
};
match value {
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} => {
let left_is_var = matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == &target_name);
let right_is_one = matches!(
right.as_ref(),
ASTNode::Literal {
value: LiteralValue::Integer(1),
..
}
);
left_is_var && right_is_one
}
_ => false,
}
}
for stmt in body {
walk(stmt, &mut candidates);
}
candidates.sort();
candidates.dedup();
let loop_var_name = match candidates.len() {
0 => {
return Err(
"[phase104/loop-true-counter] Cannot find unique counter update `i = i + 1` in loop(true) body"
.to_string(),
);
}
1 => candidates[0].clone(),
_ => {
return Err(format!(
"[phase104/loop-true-counter] Multiple counter candidates found in loop(true) body: {:?}",
candidates
));
}
};
let host_id = variable_map.get(&loop_var_name).copied().ok_or_else(|| {
format!(
"[phase104/loop-true-counter] Counter '{}' not found in variable_map (loop-outer var required)",
loop_var_name
)
})?;
if !has_substring_read(body, &loop_var_name) {
return Err(format!(
"[phase104/loop-true-counter] Counter '{}' found, but missing substring pattern `s.substring({}, {} + 1)`",
loop_var_name, loop_var_name, loop_var_name
));
}
Ok((loop_var_name, host_id))
}
}
fn has_substring_read(body: &[ASTNode], counter: &str) -> bool {
fn walk(node: &ASTNode, counter: &str) -> bool {
match node {
ASTNode::Assignment { value, .. } => walk(value.as_ref(), counter),
ASTNode::Local {
initial_values, ..
} => initial_values
.iter()
.filter_map(|v| v.as_ref())
.any(|v| walk(v.as_ref(), counter)),
ASTNode::MethodCall {
object: _,
method,
arguments,
..
} => {
if method == "substring" && arguments.len() == 2 {
let a0 = &arguments[0];
let a1 = &arguments[1];
let a0_ok = matches!(a0, ASTNode::Variable { name, .. } if name == counter);
let a1_ok = matches!(
a1,
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == counter)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(1), .. })
);
if a0_ok && a1_ok {
return true;
}
}
// Search recursively in args
arguments.iter().any(|a| walk(a, counter))
}
ASTNode::BinaryOp { left, right, .. } => walk(left.as_ref(), counter) || walk(right.as_ref(), counter),
ASTNode::If {
condition,
then_body,
else_body,
..
} => {
walk(condition.as_ref(), counter)
|| then_body.iter().any(|s| walk(s, counter))
|| else_body
.as_ref()
.map(|eb| eb.iter().any(|s| walk(s, counter)))
.unwrap_or(false)
}
ASTNode::Loop { body, condition, .. } => {
walk(condition.as_ref(), counter) || body.iter().any(|s| walk(s, counter))
}
_ => false,
}
}
body.iter().any(|s| walk(s, counter))
}

View File

@ -54,6 +54,8 @@ pub(in crate::mir::builder) mod policies; // Phase 93/94: Pattern routing polici
pub(in crate::mir::builder) mod body_local_policy; // Phase 92 P3: promotion vs slot routing pub(in crate::mir::builder) mod body_local_policy; // Phase 92 P3: promotion vs slot routing
pub(in crate::mir::builder) mod escape_pattern_recognizer; // Phase 91 P5b pub(in crate::mir::builder) mod escape_pattern_recognizer; // Phase 91 P5b
pub(in crate::mir::builder) mod common_init; pub(in crate::mir::builder) mod common_init;
pub(in crate::mir::builder) mod loop_true_counter_extractor; // Phase 104: loop(true) counter extraction for Pattern2
pub(in crate::mir::builder) mod read_digits_break_condition_box; // Phase 104: break cond normalization for read_digits(loop(true))
pub(in crate::mir::builder) mod condition_env_builder; pub(in crate::mir::builder) mod condition_env_builder;
pub(in crate::mir::builder) mod conversion_pipeline; pub(in crate::mir::builder) mod conversion_pipeline;
pub(in crate::mir::builder) mod exit_binding; pub(in crate::mir::builder) mod exit_binding;
@ -79,6 +81,9 @@ pub(in crate::mir::builder) use router::{route_loop_pattern, LoopPatternContext}
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility) // Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use ast_feature_extractor::{detect_skip_whitespace_pattern, SkipWhitespaceInfo}; pub(crate) use ast_feature_extractor::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use ast_feature_extractor::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer // Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use ast_feature_extractor::{detect_continue_pattern, ContinuePatternInfo}; pub(crate) use ast_feature_extractor::{detect_continue_pattern, ContinuePatternInfo};

View File

@ -496,16 +496,67 @@ fn prepare_pattern2_inputs(
} }
} }
// Break condition extraction // Break condition extraction (SSOT + Phase 104 extension)
// //
// Use the analyzer SSOT to produce "break when <cond> is true" as an owned AST node: // SSOT (BreakConditionAnalyzer):
// - `if cond { break }` -> `cond` // - `if cond { break }` -> `cond`
// - `if cond { ... } else { break }` -> `!cond` // - `if cond { ... } else { break }` -> `!cond`
use crate::mir::loop_pattern_detection::break_condition_analyzer::BreakConditionAnalyzer; //
let break_condition_node = BreakConditionAnalyzer::extract_break_condition_node(body) // Phase 104 (read_digits loop(true)):
.map_err(|_| "[cf_loop/pattern2] Failed to extract break condition from loop body".to_string())?; // - multiple breaks exist; normalize as:
// `break_when_true := (ch == \"\") || !(is_digit(ch))`
let (break_condition_node, phase104_recipe_and_allowed) = {
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use crate::ast::{BinaryOperator, Span, UnaryOperator};
use crate::mir::join_ir::lowering::common::condition_only_emitter::{BreakSemantics, ConditionOnlyRecipe};
use crate::mir::loop_pattern_detection::break_condition_analyzer::BreakConditionAnalyzer;
Ok(Pattern2Inputs { if LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some()
{
let (ch_var, eos_cond, digit_literals) =
super::read_digits_break_condition_box::ReadDigitsBreakConditionBox::extract_eos_and_digit_set(body)
.map_err(|e| format!("[cf_loop/pattern2] {}", e))?;
// ConditionOnly derived slot: "is ch a digit?" (computed each iteration from body-local `ch`)
let is_digit_name = "__phase104_is_digit".to_string();
let recipe = ConditionOnlyRecipe {
name: is_digit_name.clone(),
original_var: ch_var.clone(),
whitespace_chars: digit_literals,
break_semantics: BreakSemantics::WhenMatch,
};
let break_on_not_digit = ASTNode::UnaryOp {
operator: UnaryOperator::Not,
operand: Box::new(ASTNode::Variable {
name: is_digit_name.clone(),
span: Span::unknown(),
}),
span: Span::unknown(),
};
let break_when_true = ASTNode::BinaryOp {
operator: BinaryOperator::Or,
left: Box::new(eos_cond),
right: Box::new(break_on_not_digit),
span: Span::unknown(),
};
(
break_when_true,
Some((ch_var, is_digit_name, recipe)),
)
} else {
(
BreakConditionAnalyzer::extract_break_condition_node(body)
.map_err(|_| "[cf_loop/pattern2] Failed to extract break condition from loop body".to_string())?,
None,
)
}
};
let mut inputs = Pattern2Inputs {
loop_var_name, loop_var_name,
loop_var_id, loop_var_id,
carrier_info, carrier_info,
@ -520,7 +571,20 @@ fn prepare_pattern2_inputs(
break_condition_node, break_condition_node,
condition_only_recipe: None, // Phase 93 P0: Will be set by apply_trim_and_normalize condition_only_recipe: None, // Phase 93 P0: Will be set by apply_trim_and_normalize
body_local_derived_recipe: None, // Phase 94: Will be set after normalization body_local_derived_recipe: None, // Phase 94: Will be set after normalization
}) };
// Phase 104: read_digits(loop(true)) wires derived is-digit slot + allow-list in one place.
if let Some((ch_var, is_digit_name, recipe)) = phase104_recipe_and_allowed {
use crate::mir::join_ir::lowering::common::body_local_slot::ReadOnlyBodyLocalSlotBox;
inputs.condition_only_recipe = Some(recipe);
inputs.allowed_body_locals_for_conditions = vec![ch_var.clone(), is_digit_name];
inputs.read_only_body_local_slot = Some(ReadOnlyBodyLocalSlotBox::extract_single(
&[ch_var],
body,
)?);
}
Ok(inputs)
} }
fn promote_and_prepare_carriers( fn promote_and_prepare_carriers(
@ -533,6 +597,7 @@ fn promote_and_prepare_carriers(
) -> Result<(), String> { ) -> Result<(), String> {
use crate::mir::join_ir::lowering::digitpos_condition_normalizer::DigitPosConditionNormalizer; use crate::mir::join_ir::lowering::digitpos_condition_normalizer::DigitPosConditionNormalizer;
use crate::mir::loop_pattern_detection::loop_condition_scope::LoopConditionScopeBox; use crate::mir::loop_pattern_detection::loop_condition_scope::LoopConditionScopeBox;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
let cond_scope = LoopConditionScopeBox::analyze( let cond_scope = LoopConditionScopeBox::analyze(
&inputs.loop_var_name, &inputs.loop_var_name,
@ -550,6 +615,11 @@ fn promote_and_prepare_carriers(
.collect(); .collect();
if cond_scope.has_loop_body_local() { if cond_scope.has_loop_body_local() {
// Phase 104: read_digits(loop(true)) pre-wires slot/allow-list/recipe in prepare_pattern2_inputs.
// Do not run promotion heuristics here.
if !(LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some())
{
match classify_for_pattern2( match classify_for_pattern2(
builder, builder,
&inputs.loop_var_name, &inputs.loop_var_name,
@ -682,6 +752,7 @@ fn promote_and_prepare_carriers(
} }
PolicyDecision::None => {} PolicyDecision::None => {}
} }
}
} }
log.log( log.log(
@ -798,8 +869,13 @@ fn apply_trim_and_normalize(
let log = Pattern2DebugLog::new(verbose); let log = Pattern2DebugLog::new(verbose);
let mut alloc_join_value = || inputs.join_value_space.alloc_param(); let mut alloc_join_value = || inputs.join_value_space.alloc_param();
let effective_break_condition = if let Some(trim_result) = // Phase 104: read_digits(loop(true)) uses a digit-guard OR-chain which resembles
super::trim_loop_lowering::TrimLoopLowerer::try_lower_trim_like_loop( // "trim-like" patterns; do not route through TrimLoopLowerer.
let is_phase104_read_digits = super::loop_true_counter_extractor::LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some();
let effective_break_condition = if !is_phase104_read_digits {
if let Some(trim_result) = super::trim_loop_lowering::TrimLoopLowerer::try_lower_trim_like_loop(
builder, builder,
&inputs.scope, &inputs.scope,
condition, condition,
@ -834,6 +910,9 @@ fn apply_trim_and_normalize(
), ),
); );
trim_result.condition trim_result.condition
} else {
inputs.break_condition_node.clone()
}
} else { } else {
inputs.break_condition_node.clone() inputs.break_condition_node.clone()
}; };
@ -926,7 +1005,9 @@ fn collect_body_local_variables(
/// - ConditionalStep updates are supported (if skeleton provides them) /// - ConditionalStep updates are supported (if skeleton provides them)
pub(crate) fn can_lower(builder: &MirBuilder, ctx: &super::router::LoopPatternContext) -> bool { pub(crate) fn can_lower(builder: &MirBuilder, ctx: &super::router::LoopPatternContext) -> bool {
use super::common_init::CommonPatternInitializer; use super::common_init::CommonPatternInitializer;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use crate::mir::loop_pattern_detection::LoopPatternKind; use crate::mir::loop_pattern_detection::LoopPatternKind;
let trace_enabled = trace::trace().is_joinir_enabled() || crate::config::env::joinir_dev_enabled();
// Basic pattern check // Basic pattern check
if ctx.pattern_kind != LoopPatternKind::Pattern2Break { if ctx.pattern_kind != LoopPatternKind::Pattern2Break {
@ -971,17 +1052,44 @@ pub(crate) fn can_lower(builder: &MirBuilder, ctx: &super::router::LoopPatternCo
} }
// Phase 188/Refactor: Use common carrier update validation // Phase 188/Refactor: Use common carrier update validation
// Extracts loop variable for dummy carrier creation (not used but required by API) // Phase 104: Support loop(true) by extracting the counter from the body.
let loop_var_name = match builder.extract_loop_variable_from_condition(ctx.condition) { let loop_var_name = if LoopTrueCounterExtractorBox::is_loop_true(ctx.condition) {
Ok(name) => name, match LoopTrueCounterExtractorBox::extract_loop_counter_from_body(
Err(_) => return false, ctx.body,
&builder.variable_ctx.variable_map,
) {
Ok((name, _host_id)) => name,
Err(e) => {
if trace_enabled {
trace::trace().debug("pattern2/can_lower", &format!("reject loop(true): {}", e));
}
return false;
}
}
} else {
match builder.extract_loop_variable_from_condition(ctx.condition) {
Ok(name) => name,
Err(e) => {
if trace_enabled {
trace::trace().debug("pattern2/can_lower", &format!("reject loop(cond): {}", e));
}
return false;
}
}
}; };
CommonPatternInitializer::check_carrier_updates_allowed( let ok = CommonPatternInitializer::check_carrier_updates_allowed(
ctx.body, ctx.body,
&loop_var_name, &loop_var_name,
&builder.variable_ctx.variable_map, &builder.variable_ctx.variable_map,
) );
if !ok && trace_enabled {
trace::trace().debug(
"pattern2/can_lower",
"reject: carrier updates contain complex RHS (UpdateRhs::Other)",
);
}
ok
} }
/// Phase 194: Lowering function for Pattern 2 /// Phase 194: Lowering function for Pattern 2

View File

@ -45,6 +45,7 @@ use crate::mir::ValueId;
use std::collections::{BTreeMap, BTreeSet}; // Phase 222.5-D: HashMap → BTreeMap for determinism use std::collections::{BTreeMap, BTreeSet}; // Phase 222.5-D: HashMap → BTreeMap for determinism
use super::common_init::CommonPatternInitializer; use super::common_init::CommonPatternInitializer;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use super::loop_scope_shape_builder::LoopScopeShapeBuilder; use super::loop_scope_shape_builder::LoopScopeShapeBuilder;
/// Phase 179-B: Unified Pattern Pipeline Context /// Phase 179-B: Unified Pattern Pipeline Context
@ -260,12 +261,26 @@ pub(crate) fn build_pattern_context(
variant: PatternVariant, variant: PatternVariant,
) -> Result<PatternPipelineContext, String> { ) -> Result<PatternPipelineContext, String> {
// Step 1: Common initialization (all patterns) // Step 1: Common initialization (all patterns)
let (loop_var_name, loop_var_id, carrier_info) = CommonPatternInitializer::initialize_pattern( //
builder, // Phase 104: Pattern2 now supports `loop(true)` by extracting the counter from the body.
condition, // This path must be strict and conservative to avoid "accidental" routing.
&builder.variable_ctx.variable_map, let (loop_var_name, loop_var_id, carrier_info) = if variant == PatternVariant::Pattern2
None, // No exclusions for now (Pattern 2/4 will filter carriers later) && LoopTrueCounterExtractorBox::is_loop_true(condition)
)?; {
let (name, host_id) = LoopTrueCounterExtractorBox::extract_loop_counter_from_body(
body,
&builder.variable_ctx.variable_map,
)?;
let carrier_info = CarrierInfo::from_variable_map(name.clone(), &builder.variable_ctx.variable_map)?;
(name, host_id, carrier_info)
} else {
CommonPatternInitializer::initialize_pattern(
builder,
condition,
&builder.variable_ctx.variable_map,
None, // No exclusions for now (Pattern 2/4 will filter carriers later)
)?
};
// Step 2: Build LoopScopeShape // Step 2: Build LoopScopeShape
let loop_scope = match variant { let loop_scope = match variant {

View File

@ -0,0 +1,179 @@
//! ReadDigitsBreakConditionBox (Phase 104)
//!
//! Responsibility (analysis only):
//! - For `loop(true)` read_digits_from shape, extract:
//! - the EOS break condition (`if ch == "" { break }`)
//! - the digit literal set used in the final `if <is_digit> { ... } else { break }`
use crate::ast::{ASTNode, BinaryOperator, LiteralValue};
pub(crate) struct ReadDigitsBreakConditionBox;
impl ReadDigitsBreakConditionBox {
pub(crate) fn extract_eos_and_digit_set(
body: &[ASTNode],
) -> Result<(String, ASTNode, Vec<String>), String> {
if body.is_empty() {
return Err("[phase104/read-digits] empty loop body".to_string());
}
let (digit_cond, has_else_break) = match &body[body.len() - 1] {
ASTNode::If {
condition,
else_body: Some(else_body),
..
} => (
condition.as_ref().clone(),
else_body.len() == 1 && matches!(else_body[0], ASTNode::Break { .. }),
),
_ => return Err("[phase104/read-digits] last statement is not if-else".to_string()),
};
if !has_else_break {
return Err("[phase104/read-digits] last if does not have `else { break }`".to_string());
}
let (ch_var, eos_cond) = find_eos_break_condition(body).ok_or_else(|| {
"[phase104/read-digits] missing `if ch == \"\" { break }` guard".to_string()
})?;
let mut digit_literals: Vec<String> = Vec::new();
let mut digit_var_name: Option<String> = None;
collect_eq_string_literals(&digit_cond, &mut digit_literals, &mut digit_var_name)?;
let digit_var_name = digit_var_name.ok_or_else(|| {
"[phase104/read-digits] digit condition does not reference a variable".to_string()
})?;
if digit_var_name != ch_var {
return Err(format!(
"[phase104/read-digits] digit condition var '{}' != eos var '{}'",
digit_var_name, ch_var
));
}
digit_literals.sort();
digit_literals.dedup();
if digit_literals.is_empty() {
return Err("[phase104/read-digits] digit condition has no string literals".to_string());
}
// Phase 104 minimal: require the canonical digit set.
let expected: Vec<String> = (0..=9).map(|d| d.to_string()).collect();
if digit_literals != expected {
return Err(format!(
"[phase104/read-digits] digit condition literal set mismatch: got={:?}, expected={:?}",
digit_literals, expected
));
}
Ok((ch_var, eos_cond, digit_literals))
}
}
fn find_eos_break_condition(body: &[ASTNode]) -> Option<(String, ASTNode)> {
for stmt in body {
let (cond, then_body, else_body) = match stmt {
ASTNode::If {
condition,
then_body,
else_body,
..
} => (condition, then_body, else_body),
_ => continue,
};
if else_body.is_some() {
continue;
}
if then_body.len() != 1 || !matches!(then_body[0], ASTNode::Break { .. }) {
continue;
}
if let Some(var_name) = ch_eq_empty_var(cond.as_ref()) {
return Some((var_name, cond.as_ref().clone()));
}
}
None
}
fn ch_eq_empty_var(cond: &ASTNode) -> Option<String> {
match cond {
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
} => {
let name = match left.as_ref() {
ASTNode::Variable { name, .. } => name.clone(),
_ => return None,
};
match right.as_ref() {
ASTNode::Literal {
value: LiteralValue::String(s),
..
} if s.is_empty() => Some(name),
_ => None,
}
}
_ => None,
}
}
fn collect_eq_string_literals(
cond: &ASTNode,
out: &mut Vec<String>,
var_name: &mut Option<String>,
) -> Result<(), String> {
match cond {
ASTNode::BinaryOp {
operator: BinaryOperator::Or,
left,
right,
..
} => {
collect_eq_string_literals(left.as_ref(), out, var_name)?;
collect_eq_string_literals(right.as_ref(), out, var_name)?;
Ok(())
}
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
} => {
let (name, lit) = match (left.as_ref(), right.as_ref()) {
(ASTNode::Variable { name, .. }, ASTNode::Literal { value: LiteralValue::String(s), .. }) => {
(name.clone(), s.clone())
}
(ASTNode::Literal { value: LiteralValue::String(s), .. }, ASTNode::Variable { name, .. }) => {
(name.clone(), s.clone())
}
_ => {
return Err("[phase104/read-digits] digit condition must be OR of `ch == \"d\"`".to_string());
}
};
if lit.len() != 1 || !lit.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"[phase104/read-digits] non-digit literal in digit condition: {:?}",
lit
));
}
match var_name.as_deref() {
None => *var_name = Some(name),
Some(prev) if prev == name => {}
Some(prev) => {
return Err(format!(
"[phase104/read-digits] mixed variable names in digit condition: '{}' vs '{}'",
prev, name
));
}
}
out.push(lit);
Ok(())
}
_ => Err("[phase104/read-digits] digit condition must be OR-chain of equality checks".to_string()),
}
}

View File

@ -57,6 +57,9 @@ pub(in crate::mir::builder) mod utils;
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility) // Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use joinir::{detect_skip_whitespace_pattern, SkipWhitespaceInfo}; pub(crate) use joinir::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use joinir::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer // Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use joinir::{detect_continue_pattern, ContinuePatternInfo}; pub(crate) use joinir::{detect_continue_pattern, ContinuePatternInfo};

View File

@ -9,7 +9,8 @@ use crate::mir::loop_pattern_detection::LoopPatternKind;
use super::capability_guard::{CapabilityTag, RoutingDecision}; use super::capability_guard::{CapabilityTag, RoutingDecision};
use super::pattern_recognizer::{ use super::pattern_recognizer::{
try_extract_continue_pattern, try_extract_escape_skip_pattern, try_extract_parse_number_pattern, try_extract_continue_pattern, try_extract_escape_skip_pattern, try_extract_parse_number_pattern,
try_extract_parse_string_pattern, try_extract_skip_whitespace_pattern, try_extract_parse_string_pattern, try_extract_read_digits_loop_true_pattern,
try_extract_skip_whitespace_pattern,
}; };
use super::skeleton_types::{ use super::skeleton_types::{
CarrierRole, CarrierSlot, ExitContract, LoopSkeleton, SkeletonStep, UpdateKind, CarrierRole, CarrierSlot, ExitContract, LoopSkeleton, SkeletonStep, UpdateKind,
@ -203,6 +204,53 @@ pub fn canonicalize_loop_expr(
return Ok((skeleton, decision)); return Ok((skeleton, decision));
} }
// Phase 104: loop(true) + break-only digits (read_digits_from)
//
// Shape (JsonCursorBox.read_digits_from / MiniJsonLoader.read_digits_from):
// - loop(true)
// - last statement is `if is_digit { ... i = i + 1 } else { break }`
// - may have `if ch == "" { break }` and substring read before it
if matches!(
condition,
ASTNode::Literal {
value: crate::ast::LiteralValue::Bool(true),
..
}
) {
if let Some((carrier_name, delta, body_stmts)) = try_extract_read_digits_loop_true_pattern(body) {
let mut skeleton = LoopSkeleton::new(span);
skeleton.steps.push(SkeletonStep::HeaderCond {
expr: Box::new(condition.clone()),
});
if !body_stmts.is_empty() {
skeleton.steps.push(SkeletonStep::Body { stmts: body_stmts });
}
skeleton.steps.push(SkeletonStep::Update {
carrier_name: carrier_name.clone(),
update_kind: UpdateKind::ConstStep { delta },
});
skeleton.carriers.push(CarrierSlot {
name: carrier_name,
role: CarrierRole::Counter,
update_kind: UpdateKind::ConstStep { delta },
});
skeleton.exits = ExitContract {
has_break: true,
has_continue: false,
has_return: false,
break_has_value: false,
};
let decision = RoutingDecision::success(LoopPatternKind::Pattern2Break);
return Ok((skeleton, decision));
}
}
// Phase 143-P0: Try to extract parse_number pattern (break in THEN clause) // Phase 143-P0: Try to extract parse_number pattern (break in THEN clause)
if let Some((carrier_name, delta, body_stmts, rest_stmts)) = if let Some((carrier_name, delta, body_stmts, rest_stmts)) =
try_extract_parse_number_pattern(body) try_extract_parse_number_pattern(body)
@ -378,7 +426,7 @@ pub fn canonicalize_loop_expr(
LoopSkeleton::new(span), LoopSkeleton::new(span),
RoutingDecision::fail_fast( RoutingDecision::fail_fast(
vec![CapabilityTag::ConstStep], vec![CapabilityTag::ConstStep],
"Phase 143-P2: Loop does not match skip_whitespace, parse_number, continue, parse_string, or parse_array pattern" "Phase 143-P2: Loop does not match read_digits(loop(true)), skip_whitespace, parse_number, continue, parse_string, or parse_array pattern"
.to_string(), .to_string(),
), ),
)) ))

View File

@ -8,6 +8,7 @@ use crate::mir::detect_continue_pattern;
use crate::mir::detect_parse_number_pattern as ast_detect_parse_number; use crate::mir::detect_parse_number_pattern as ast_detect_parse_number;
use crate::mir::detect_parse_string_pattern as ast_detect_parse_string; use crate::mir::detect_parse_string_pattern as ast_detect_parse_string;
use crate::mir::detect_skip_whitespace_pattern as ast_detect; use crate::mir::detect_skip_whitespace_pattern as ast_detect;
use crate::mir::detect_read_digits_loop_true_pattern as ast_detect_read_digits;
use crate::mir::detect_escape_skip_pattern as ast_detect_escape; use crate::mir::detect_escape_skip_pattern as ast_detect_escape;
// ============================================================================ // ============================================================================
@ -40,6 +41,19 @@ pub fn try_extract_skip_whitespace_pattern(
ast_detect(body).map(|info| (info.carrier_name, info.delta, info.body_stmts)) ast_detect(body).map(|info| (info.carrier_name, info.delta, info.body_stmts))
} }
// ============================================================================
// Phase 104: Read Digits loop(true) Pattern
// ============================================================================
/// Try to extract read_digits_from-like pattern from loop(true) body.
///
/// Returns (carrier_name, delta, body_stmts) if pattern matches.
pub fn try_extract_read_digits_loop_true_pattern(
body: &[ASTNode],
) -> Option<(String, i64, Vec<ASTNode>)> {
ast_detect_read_digits(body).map(|info| (info.carrier_name, info.delta, info.body_stmts))
}
// ============================================================================ // ============================================================================
// Parse Number Pattern (Phase 143-P0) // Parse Number Pattern (Phase 143-P0)
// ============================================================================ // ============================================================================

View File

@ -58,6 +58,8 @@ pub use builder::MirBuilder;
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT // Phase 140-P4-A: Re-export for loop_canonicalizer SSOT
pub(crate) use builder::{detect_skip_whitespace_pattern, SkipWhitespaceInfo}; pub(crate) use builder::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use builder::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer // Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use builder::{detect_continue_pattern, ContinuePatternInfo}; pub(crate) use builder::{detect_continue_pattern, ContinuePatternInfo};
// Phase 143-P0: Re-export parse_number pattern detection for loop_canonicalizer // Phase 143-P0: Re-export parse_number pattern detection for loop_canonicalizer

View File

@ -0,0 +1,120 @@
#!/bin/bash
# Phase 104: read_digits loop(true) + break-only (LLVM EXE parity)
source "$(dirname "$0")/../../../lib/test_runner.sh"
export SMOKES_USE_PYVM=0
require_env || exit 2
# LLVM availability checks (graceful SKIP)
if ! command -v llvm-config-18 &> /dev/null; then
test_skip "llvm-config-18 not found"; exit 0
fi
if ! "$NYASH_BIN" --help 2>&1 | grep -q "llvm"; then
test_skip "hakorune --backend llvm not available"; exit 0
fi
if ! python3 -c "import llvmlite" 2>/dev/null; then
test_skip "Python llvmlite not found"; exit 0
fi
# Phase 97/98/100 SSOT: plugin dlopen check → build only if needed → dlopen recheck.
FILEBOX_SO="$NYASH_ROOT/plugins/nyash-filebox-plugin/libnyash_filebox_plugin.so"
MAPBOX_SO="$NYASH_ROOT/plugins/nyash-map-plugin/libnyash_map_plugin.so"
STRINGBOX_SO="$NYASH_ROOT/plugins/nyash-string-plugin/libnyash_string_plugin.so"
CONSOLEBOX_SO="$NYASH_ROOT/plugins/nyash-console-plugin/libnyash_console_plugin.so"
INTEGERBOX_SO="$NYASH_ROOT/plugins/nyash-integer-plugin/libnyash_integer_plugin.so"
check_plugins() {
python3 - "$FILEBOX_SO" "$MAPBOX_SO" "$STRINGBOX_SO" "$CONSOLEBOX_SO" "$INTEGERBOX_SO" <<'PY'
import ctypes
import os
import sys
names = ["FileBox", "MapBox", "StringBox", "ConsoleBox", "IntegerBox"]
paths = sys.argv[1:]
failures = []
for name, path in zip(names, paths):
if not os.path.isfile(path):
failures.append(f"[plugin/missing] {name}: {path}")
continue
try:
ctypes.CDLL(path)
except Exception as e: # noqa: BLE001
failures.append(f"[plugin/dlopen] {name}: {path} ({e})")
if failures:
print("\n".join(failures))
sys.exit(1)
print("OK")
PY
}
echo "[INFO] Checking plugin artifacts (FileBox/MapBox/StringBox/ConsoleBox/IntegerBox)"
if ! CHECK_OUTPUT=$(check_plugins 2>&1); then
echo "$CHECK_OUTPUT"
echo "[INFO] Missing/broken plugin detected, running build-all (core plugins)"
BUILD_LOG="/tmp/phase104_read_digits_plugin_build.log"
if ! bash "$NYASH_ROOT/tools/plugins/build-all.sh" \
nyash-filebox-plugin nyash-map-plugin nyash-string-plugin nyash-console-plugin nyash-integer-plugin \
>"$BUILD_LOG" 2>&1; then
echo "[FAIL] tools/plugins/build-all.sh failed for core plugins"
tail -n 80 "$BUILD_LOG"
exit 1
fi
if ! CHECK_OUTPUT=$(check_plugins 2>&1); then
echo "$CHECK_OUTPUT"
echo "[FAIL] Plugin artifacts still missing or unloadable after build-all"
tail -n 80 "$BUILD_LOG"
exit 1
fi
fi
mkdir -p "$NYASH_ROOT/tmp"
INPUT_HAKO="$NYASH_ROOT/apps/tests/phase104_read_digits_loop_true_min.hako"
OUTPUT_EXE="$NYASH_ROOT/tmp/phase104_read_digits_llvm_exe"
echo "[INFO] Building: $INPUT_HAKO$OUTPUT_EXE"
BUILD_LOG="/tmp/phase104_read_digits_build.log"
if ! env NYASH_DISABLE_PLUGINS=0 "$NYASH_ROOT/tools/build_llvm.sh" "$INPUT_HAKO" -o "$OUTPUT_EXE" 2>&1 | tee "$BUILD_LOG"; then
echo "[FAIL] build_llvm.sh failed"
tail -n 80 "$BUILD_LOG"
exit 1
fi
if [ ! -x "$OUTPUT_EXE" ]; then
echo "[FAIL] Executable not created or not executable: $OUTPUT_EXE"
ls -la "$OUTPUT_EXE" 2>/dev/null || echo "File does not exist"
exit 1
fi
echo "[INFO] Executing: $OUTPUT_EXE"
set +e
OUTPUT=$(timeout "${RUN_TIMEOUT_SECS:-10}" env NYASH_DISABLE_PLUGINS=0 "$OUTPUT_EXE" 2>&1)
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -ne 0 ]; then
echo "[FAIL] Execution failed with exit code $EXIT_CODE"
echo "$OUTPUT" | tail -n 80
exit 1
fi
EXPECTED=$'2\n1'
CLEAN=$(printf "%s\n" "$OUTPUT" | grep -v '^\[' | grep -E '^-?[0-9]+$' | head -n 2 | paste -sd '\n' - | tr -d '\r')
echo "[INFO] CLEAN output:"
echo "$CLEAN"
if [ "$CLEAN" = "$EXPECTED" ]; then
test_pass "phase104_read_digits_llvm_exe: output matches expected (2, 1)"
else
echo "[FAIL] Output mismatch"
echo "[INFO] Raw output (tail):"
echo "$OUTPUT" | tail -n 80
echo "[INFO] Expected:"
printf "%s\n" "$EXPECTED"
exit 1
fi

View File

@ -0,0 +1,54 @@
#!/bin/bash
# Phase 104: read_digits loop(true) + break-only (VM)
source "$(dirname "$0")/../../../lib/test_runner.sh"
export SMOKES_USE_PYVM=0
require_env || exit 2
PASS_COUNT=0
FAIL_COUNT=0
RUN_TIMEOUT_SECS=${RUN_TIMEOUT_SECS:-10}
INPUT="$NYASH_ROOT/apps/tests/phase104_read_digits_loop_true_min.hako"
echo "[INFO] Phase 104: read_digits loop(true) break-only (VM) - $INPUT"
set +e
OUTPUT=$(timeout "$RUN_TIMEOUT_SECS" env \
NYASH_DISABLE_PLUGINS=0 \
HAKO_JOINIR_STRICT=1 \
"$NYASH_BIN" --backend vm "$INPUT" 2>&1)
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -eq 124 ]; then
echo "[FAIL] hakorune timed out (>${RUN_TIMEOUT_SECS}s)"
FAIL_COUNT=$((FAIL_COUNT + 1))
elif [ "$EXIT_CODE" -eq 0 ]; then
EXPECTED=$'2\n1'
CLEAN=$(printf "%s\n" "$OUTPUT" | grep -E '^-?[0-9]+$' | head -n 2 | paste -sd '\n' - | tr -d '\r')
if [ "$CLEAN" = "$EXPECTED" ]; then
echo "[PASS] Output verified: 2 then 1"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "[FAIL] Unexpected output (expected lines: 2 then 1)"
echo "[INFO] output (tail):"
echo "$OUTPUT" | tail -n 60 || true
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
else
echo "[FAIL] hakorune failed with exit code $EXIT_CODE"
echo "[INFO] output (tail):"
echo "$OUTPUT" | tail -n 60 || true
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
echo "[INFO] PASS: $PASS_COUNT, FAIL: $FAIL_COUNT"
if [ "$FAIL_COUNT" -eq 0 ]; then
test_pass "phase104_read_digits_vm: All tests passed"
exit 0
else
test_fail "phase104_read_digits_vm: $FAIL_COUNT test(s) failed"
exit 1
fi