feat(joinir): Phase 107 balanced depth-scan policy (analysis-only)

This commit is contained in:
nyash-codex
2025-12-17 22:25:34 +09:00
parent 97c65a9e6f
commit f1a570fd45
4 changed files with 606 additions and 0 deletions

View File

@ -0,0 +1,587 @@
//! Phase 107: Balanced depth-scan policy (json_cur find_balanced_* family)
//!
//! Responsibility (analysis only):
//! - Recognize the `depth` scan loop shape with nested-if + `return i`
//! - Produce a Pattern2-compatible break condition + derived recipe inputs
//! - Fail-fast with tagged reasons when the shape is close but unsupported
use crate::ast::{ASTNode, BinaryOperator, LiteralValue, Span};
use crate::mir::join_ir::lowering::common::balanced_depth_scan_emitter::BalancedDepthScanRecipe;
use crate::mir::join_ir::lowering::error_tags;
use crate::mir::join_ir::lowering::loop_update_analyzer::{UpdateExpr, UpdateRhs};
use crate::mir::join_ir::BinOpKind;
use super::PolicyDecision;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub(crate) struct PostLoopEarlyReturnPlan {
pub loop_counter_name: String,
pub bound_name: String,
}
#[derive(Debug, Clone)]
pub(crate) struct BalancedDepthScanPolicyResult {
pub break_condition_node: ASTNode,
pub allowed_body_locals_for_conditions: Vec<String>,
pub carrier_updates_override: BTreeMap<String, UpdateExpr>,
pub derived_recipe: BalancedDepthScanRecipe,
pub post_loop_early_return: PostLoopEarlyReturnPlan,
}
pub(crate) fn classify_balanced_depth_scan_array_end(
condition: &ASTNode,
body: &[ASTNode],
) -> PolicyDecision<BalancedDepthScanPolicyResult> {
classify_balanced_depth_scan(condition, body, "[", "]")
}
fn classify_balanced_depth_scan(
condition: &ASTNode,
body: &[ASTNode],
open: &str,
close: &str,
) -> PolicyDecision<BalancedDepthScanPolicyResult> {
// bounded loop: loop(i < n)
let (loop_counter_name, bound_name) = match extract_bounded_loop_counter(condition) {
Some(v) => v,
None => return PolicyDecision::None,
};
let summary = match extract_depth_scan_shape(body, &loop_counter_name, open, close) {
Ok(v) => v,
Err(reason) => return PolicyDecision::Reject(reason),
};
let depth_delta_name = "depth_delta".to_string();
let depth_next_name = "depth_next".to_string();
if summary.declared_locals.contains(&depth_delta_name) || summary.declared_locals.contains(&depth_next_name) {
return PolicyDecision::Reject(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/name_conflict] 'depth_delta' or 'depth_next' is already declared in the loop body",
));
}
let break_condition_node = ASTNode::BinaryOp {
operator: BinaryOperator::And,
left: Box::new(eq_str(var(&summary.ch_name), close)),
right: Box::new(eq_int(var(&depth_next_name), 0)),
span: Span::unknown(),
};
// Carrier update override (SSOT): depth = depth + depth_delta, i = i + 1
let mut carrier_updates_override: BTreeMap<String, UpdateExpr> = BTreeMap::new();
carrier_updates_override.insert(loop_counter_name.clone(), UpdateExpr::Const(1));
carrier_updates_override.insert(
summary.depth_name.clone(),
UpdateExpr::BinOp {
lhs: summary.depth_name.clone(),
op: BinOpKind::Add,
rhs: UpdateRhs::Variable(depth_delta_name.clone()),
},
);
PolicyDecision::Use(BalancedDepthScanPolicyResult {
break_condition_node,
allowed_body_locals_for_conditions: vec![summary.ch_name.clone(), depth_next_name.clone()],
carrier_updates_override,
derived_recipe: BalancedDepthScanRecipe {
depth_var: summary.depth_name,
ch_var: summary.ch_name,
open: open.to_string(),
close: close.to_string(),
depth_delta_name,
depth_next_name,
},
post_loop_early_return: PostLoopEarlyReturnPlan {
loop_counter_name,
bound_name,
},
})
}
#[derive(Debug)]
struct DepthScanShapeSummary {
ch_name: String,
depth_name: String,
declared_locals: std::collections::BTreeSet<String>,
}
fn extract_bounded_loop_counter(condition: &ASTNode) -> Option<(String, String)> {
match condition {
ASTNode::BinaryOp {
operator: BinaryOperator::Less,
left,
right,
..
} => match (left.as_ref(), right.as_ref()) {
(ASTNode::Variable { name: i, .. }, ASTNode::Variable { name: n, .. }) => {
Some((i.clone(), n.clone()))
}
_ => None,
},
_ => None,
}
}
fn extract_depth_scan_shape(
body: &[ASTNode],
loop_counter_name: &str,
open: &str,
close: &str,
) -> Result<DepthScanShapeSummary, String> {
if body.is_empty() {
return Err(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/empty_body] empty loop body",
));
}
// Collect declared locals to protect derived slot names.
let mut declared_locals: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for stmt in body {
if let ASTNode::Local { variables, .. } = stmt {
for v in variables {
declared_locals.insert(v.clone());
}
}
}
// Find `local ch = s.substring(i, i+1)` (name may vary, but must be a single local).
let ch_name = find_substring_body_local(body, loop_counter_name).ok_or_else(|| {
error_tags::freeze(
"[phase107/balanced_depth_scan/contract/missing_ch] missing body-local `ch = s.substring(i, i+1)`",
)
})?;
// Find open/close branches and extract `depth` name.
let (depth_from_open, depth_from_close, has_return_i) =
find_depth_branches(body, &ch_name, loop_counter_name, open, close)?;
if depth_from_open != depth_from_close {
return Err(error_tags::freeze(&format!(
"[phase107/balanced_depth_scan/contract/depth_mismatch] depth variable differs: open='{}', close='{}'",
depth_from_open, depth_from_close
)));
}
if !has_return_i {
return Err(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/missing_return_i] missing `if depth == 0 { return i }` inside close branch",
));
}
// Require a tail `i = i + 1` at top-level (keeps the family narrow).
let has_tail_inc = body.iter().any(|n| is_inc_assign(n, loop_counter_name, 1));
if !has_tail_inc {
return Err(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/missing_tail_inc] missing `i = i + 1` tail update",
));
}
// Reject other breaks/continues and non-matching returns (fail-fast).
let mut return_count = 0usize;
for stmt in body {
scan_control_flow(stmt, &mut return_count)?;
}
if return_count != 1 {
return Err(error_tags::freeze(&format!(
"[phase107/balanced_depth_scan/contract/return_count] expected exactly 1 return in loop body, got {}",
return_count
)));
}
Ok(DepthScanShapeSummary {
ch_name,
depth_name: depth_from_open,
declared_locals,
})
}
fn scan_control_flow(node: &ASTNode, return_count: &mut usize) -> Result<(), String> {
match node {
ASTNode::Break { .. } => Err(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/unexpected_break] break is not allowed in this family (return-in-loop only)",
)),
ASTNode::Continue { .. } => Err(error_tags::freeze(
"[phase107/balanced_depth_scan/contract/unexpected_continue] continue is not allowed in this family",
)),
ASTNode::Return { .. } => {
*return_count += 1;
Ok(())
}
ASTNode::If {
then_body,
else_body,
..
} => {
for s in then_body {
scan_control_flow(s, return_count)?;
}
if let Some(else_body) = else_body {
for s in else_body {
scan_control_flow(s, return_count)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
fn find_substring_body_local(body: &[ASTNode], loop_counter_name: &str) -> Option<String> {
for stmt in body {
let (name, init) = match stmt {
ASTNode::Local {
variables,
initial_values,
..
} if variables.len() == 1 && initial_values.len() == 1 => (
variables[0].clone(),
initial_values[0].as_deref()?,
),
_ => continue,
};
let (object, method, args) = match init {
ASTNode::MethodCall {
object,
method,
arguments,
..
} => (object.as_ref(), method.as_str(), arguments.as_slice()),
_ => continue,
};
if method != "substring" {
continue;
}
if !matches!(object, ASTNode::Variable { .. }) {
continue;
}
if args.len() != 2 {
continue;
}
if !matches!(&args[0], ASTNode::Variable { name, .. } if name == loop_counter_name) {
continue;
}
if !is_var_plus_int(&args[1], loop_counter_name, 1) {
continue;
}
return Some(name);
}
None
}
fn find_depth_branches(
body: &[ASTNode],
ch_name: &str,
loop_counter_name: &str,
open: &str,
close: &str,
) -> Result<(String, String, bool), String> {
let mut open_depth: Option<String> = None;
let mut close_depth: Option<String> = None;
let mut has_return_i = false;
for stmt in body {
let (cond, then_body) = match stmt {
ASTNode::If {
condition,
then_body,
else_body,
..
} if else_body.is_none() => (condition.as_ref(), then_body.as_slice()),
_ => continue,
};
let lit = match cond {
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
} if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == ch_name) => match right.as_ref() {
ASTNode::Literal { value: LiteralValue::String(s), .. } => s.as_str(),
_ => continue,
},
_ => continue,
};
if lit == open {
let depth_name = find_depth_delta_assign(then_body, 1)?;
open_depth = Some(depth_name);
} else if lit == close {
let depth_name = find_depth_delta_assign(then_body, -1)?;
close_depth = Some(depth_name.clone());
has_return_i = find_depth_zero_return(then_body, &depth_name, loop_counter_name);
}
}
let open_depth = open_depth.ok_or_else(|| {
error_tags::freeze(
"[phase107/balanced_depth_scan/contract/missing_open_branch] missing `if ch == open { depth = depth + 1 }`",
)
})?;
let close_depth = close_depth.ok_or_else(|| {
error_tags::freeze(
"[phase107/balanced_depth_scan/contract/missing_close_branch] missing `if ch == close { depth = depth - 1 ... }`",
)
})?;
Ok((open_depth, close_depth, has_return_i))
}
fn find_depth_delta_assign(stmts: &[ASTNode], delta: i64) -> Result<String, String> {
for stmt in stmts {
if let ASTNode::Assignment { target, value, .. } = stmt {
let depth_name = match target.as_ref() {
ASTNode::Variable { name, .. } => name.clone(),
_ => continue,
};
if delta == 1 && is_add_assign(value.as_ref(), &depth_name, 1) {
return Ok(depth_name);
}
if delta == -1 && is_sub_assign(value.as_ref(), &depth_name, 1) {
return Ok(depth_name);
}
}
}
Err(error_tags::freeze(&format!(
"[phase107/balanced_depth_scan/contract/missing_depth_update] missing `depth = depth {} 1` in branch",
if delta == 1 { "+" } else { "-" }
)))
}
fn find_depth_zero_return(stmts: &[ASTNode], depth_name: &str, loop_counter_name: &str) -> bool {
for stmt in stmts {
let (cond, then_body) = match stmt {
ASTNode::If {
condition,
then_body,
else_body,
..
} if else_body.is_none() => (condition.as_ref(), then_body.as_slice()),
_ => continue,
};
let is_depth_zero = matches!(
cond,
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
}
if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == depth_name)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(0), .. })
);
if !is_depth_zero {
continue;
}
if then_body.iter().any(|n| matches!(
n,
ASTNode::Return { value: Some(v), .. }
if matches!(v.as_ref(), ASTNode::Variable { name, .. } if name == loop_counter_name)
)) {
return true;
}
}
false
}
fn is_inc_assign(node: &ASTNode, var_name: &str, step: i64) -> bool {
match node {
ASTNode::Assignment { target, value, .. } => match target.as_ref() {
ASTNode::Variable { name, .. } if name == var_name => is_add_assign(value.as_ref(), var_name, step),
_ => false,
},
_ => false,
}
}
fn is_add_assign(value: &ASTNode, var_name: &str, step: i64) -> bool {
matches!(
value,
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
}
if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == var_name)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(n), .. } if *n == step)
)
}
fn is_sub_assign(value: &ASTNode, var_name: &str, step: i64) -> bool {
matches!(
value,
ASTNode::BinaryOp {
operator: BinaryOperator::Subtract,
left,
right,
..
}
if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == var_name)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(n), .. } if *n == step)
)
}
fn is_var_plus_int(node: &ASTNode, var_name: &str, n: i64) -> bool {
matches!(
node,
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
}
if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == var_name)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(m), .. } if *m == n)
)
}
fn var(name: &str) -> ASTNode {
ASTNode::Variable {
name: name.to_string(),
span: Span::unknown(),
}
}
fn eq_str(left: ASTNode, s: &str) -> ASTNode {
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left: Box::new(left),
right: Box::new(ASTNode::Literal {
value: LiteralValue::String(s.to_string()),
span: Span::unknown(),
}),
span: Span::unknown(),
}
}
fn eq_int(left: ASTNode, n: i64) -> ASTNode {
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left: Box::new(left),
right: Box::new(ASTNode::Literal {
value: LiteralValue::Integer(n),
span: Span::unknown(),
}),
span: Span::unknown(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Span;
fn span() -> Span {
Span::unknown()
}
fn var_node(name: &str) -> ASTNode {
ASTNode::Variable {
name: name.to_string(),
span: span(),
}
}
fn int_lit(n: i64) -> ASTNode {
ASTNode::Literal {
value: LiteralValue::Integer(n),
span: span(),
}
}
fn str_lit(s: &str) -> ASTNode {
ASTNode::Literal {
value: LiteralValue::String(s.to_string()),
span: span(),
}
}
fn bin(op: BinaryOperator, left: ASTNode, right: ASTNode) -> ASTNode {
ASTNode::BinaryOp {
operator: op,
left: Box::new(left),
right: Box::new(right),
span: span(),
}
}
fn if_then(cond: ASTNode, then_body: Vec<ASTNode>) -> ASTNode {
ASTNode::If {
condition: Box::new(cond),
then_body,
else_body: None,
span: span(),
}
}
#[test]
fn detects_balanced_array_end_min_shape() {
// loop(i < n) {
// local ch = s.substring(i, i+1)
// if ch == "[" { depth = depth + 1 }
// if ch == "]" { depth = depth - 1; if depth == 0 { return i } }
// i = i + 1
// }
let condition = bin(BinaryOperator::Less, var_node("i"), var_node("n"));
let local_ch = ASTNode::Local {
variables: vec!["ch".to_string()],
initial_values: vec![Some(Box::new(ASTNode::MethodCall {
object: Box::new(var_node("s")),
method: "substring".to_string(),
arguments: vec![var_node("i"), bin(BinaryOperator::Add, var_node("i"), int_lit(1))],
span: span(),
}))],
span: span(),
};
let open_branch = if_then(
bin(BinaryOperator::Equal, var_node("ch"), str_lit("[")),
vec![ASTNode::Assignment {
target: Box::new(var_node("depth")),
value: Box::new(bin(BinaryOperator::Add, var_node("depth"), int_lit(1))),
span: span(),
}],
);
let close_branch = if_then(
bin(BinaryOperator::Equal, var_node("ch"), str_lit("]")),
vec![
ASTNode::Assignment {
target: Box::new(var_node("depth")),
value: Box::new(bin(BinaryOperator::Subtract, var_node("depth"), int_lit(1))),
span: span(),
},
if_then(
bin(BinaryOperator::Equal, var_node("depth"), int_lit(0)),
vec![ASTNode::Return {
value: Some(Box::new(var_node("i"))),
span: span(),
}],
),
],
);
let tail_inc = ASTNode::Assignment {
target: Box::new(var_node("i")),
value: Box::new(bin(BinaryOperator::Add, var_node("i"), int_lit(1))),
span: span(),
};
let body = vec![local_ch, open_branch, close_branch, tail_inc];
let decision = classify_balanced_depth_scan_array_end(&condition, &body);
let result = match decision {
PolicyDecision::Use(v) => v,
other => panic!("expected Use, got {:?}", other),
};
assert_eq!(result.post_loop_early_return.loop_counter_name, "i");
assert_eq!(result.post_loop_early_return.bound_name, "n");
assert!(result.allowed_body_locals_for_conditions.contains(&"ch".to_string()));
assert!(result.allowed_body_locals_for_conditions.contains(&"depth_next".to_string()));
assert!(result.carrier_updates_override.contains_key("i"));
assert!(result.carrier_updates_override.contains_key("depth"));
}
}

View File

@ -33,3 +33,4 @@ pub enum PolicyDecision<T> {
pub(in crate::mir::builder) mod p5b_escape_derived_policy;
pub(in crate::mir::builder) mod trim_policy;
pub(in crate::mir::builder) mod loop_true_read_digits_policy;
pub(in crate::mir::builder) mod balanced_depth_scan_policy;

View File

@ -8,6 +8,7 @@ pub mod body_local_slot; // Phase 92 P3: Read-only body-local slot for condition
pub mod dual_value_rewriter; // Phase 246-EX/247-EX: name-based dual-value rewrites
pub mod condition_only_emitter; // Phase 93 P0: ConditionOnly (Derived Slot) recalculation
pub mod body_local_derived_emitter; // Phase 94: Derived body-local (P5b escape "ch" reassignment)
pub mod balanced_depth_scan_emitter; // Phase 107: Balanced depth-scan (find_balanced_* recipe)
pub mod string_accumulator_emitter; // Phase 100 P3-2: String accumulator (out = out + ch)
use crate::mir::loop_form::LoopForm;

View File

@ -0,0 +1,17 @@
//! Phase 107: Balanced depth-scan derived emission (recipe only)
//!
//! This recipe is produced by builder-side policy (`patterns/policies/*`) and
//! consumed by JoinIR lowering to derive:
//! - `depth_delta`: per-iteration delta (-1/0/+1)
//! - `depth_next`: depth + depth_delta (for break checks)
#[derive(Debug, Clone)]
pub struct BalancedDepthScanRecipe {
pub depth_var: String,
pub ch_var: String,
pub open: String,
pub close: String,
pub depth_delta_name: String,
pub depth_next_name: String,
}