diff --git a/cc-perf-campaign/codex/REPORT_yf6_admission.md b/cc-perf-campaign/codex/REPORT_yf6_admission.md new file mode 100644 index 0000000000..df8bfb3d01 --- /dev/null +++ b/cc-perf-campaign/codex/REPORT_yf6_admission.md @@ -0,0 +1,104 @@ +# YF6 typed-i1 admission report + +## SHA + +Implementation commit: `ae8e15aa28491a63312a59050b14b70d4831a82c`. + +The implementation commit was pushed to +`fork/perf/typed-numeric-predicate`; `git ls-remote` resolved that branch to +the same SHA before this report-only child commit was created. + +## Failing condition + +The failure is return-type inference, not the erased-parameter proof, capture +analysis, typed-body safety walk, comparison floor, typed lowering, or a clone +instruction budget. + +`crates/perry-hir/src/lower_types.rs:315` caps recursive expression-type +inference at 48 levels, and `infer_type_from_expr` returns `Type::Any` at +`crates/perry-hir/src/lower_types.rs:343-345`. The old `LogicalAnd | +LogicalOr` rule recursively inferred both children. YF6's 135 left-associated +`||` joins exceed that depth, so an inner left subtree became `Any`; the +logical unification propagated `Any` to the root. `infer_body_return_type` +then rejected that `Any` at `crates/perry-hir/src/lower_types.rs:923-933`, and +`crates/perry-hir/src/lower_decl/fn_decl.rs:372-383` consequently left +`Function.return_type` at its initial `Any` fallback. The smaller probe's 22 +`||` joins remain below 48 and therefore infer `Boolean`. + +At codegen admission, that metadata produces +`TypedCloneRejectionReason::ReturnTypeNotI1` at +`crates/perry-codegen/src/codegen/typed_abi.rs:1277-1279`. +`typed_i1_function_rejection_reason` only tries erased-predicate admission when +the declared-path reason is `ParamNotI1` +(`crates/perry-codegen/src/codegen/typed_abi.rs:839-842`), so YF6 never reaches +the override. Calling `erased_numeric_predicate_param_reps` directly would +also fail its Boolean-return rule at +`crates/perry-codegen/src/codegen/typed_abi.rs:1210-1220`. + +All later rules pass once the return type is Boolean: + +- `typed_param_rep_for_type` deliberately does not type `Any` + (`crates/perry-codegen/src/codegen/typed_abi.rs:144-155`), after which the + erased rule sees referenced parameter `q` and assigns guarded `F64` + (`crates/perry-codegen/src/codegen/typed_abi.rs:1223-1239`). The real + top-level function has no captures. +- `typed_i1_body_rejection_reason` accepts the one-return straight-line body + (`crates/perry-codegen/src/codegen/typed_abi.rs:1705-1739`): every logical + node is `And`/`Or`, and every comparison has `q` plus an integer literal as + f64-safe operands. +- `numeric_comparisons_in_typed_i1_expr` + (`crates/perry-codegen/src/codegen/typed_abi.rs:1191-1207`) counts 212, which + clears `ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS = 4` at line 1189. +- There is no per-function instruction budget in the typed top-level clone + selection or typed-i1 lowering path. + +## Change + +`LogicalAnd`/`LogicalOr` return inference now flattens logical joins onto an +explicit worklist (`crates/perry-hir/src/lower_types.rs:357-388`, selected at +line 500). It applies the same sound rule as before: every leaf must infer to +the same non-`Any` type. The general depth-48 guard remains intact. A separate +512-node work cap bounds repeated inference cost and prevents the unbounded +O(n²) behavior that the original guard protects against, while covering YF6's +423 logical/comparison AST nodes. + +`crates/perry-codegen/tests/yf6_admission.rs` parses and lowers the exact +2,274-byte YF6 fixture verbatim, appends a typed-string `codePointAt` caller, +and asserts: + +- the inferred YF6 return type is `Boolean`; +- `YF6$typed_i1` exists with 76 `fcmp oge`, 76 `fcmp ole`, and 60 `fcmp oeq`; +- the clone has no `@js_rel_` calls; +- the generic fallback remains present; +- the public wrapper guards and calls the clone while retaining the fallback; +- the possibly-`undefined` `codePointAt` result enters through that guarded + public wrapper. + +Restoring recursive logical inference makes the inferred-return assertion and +clone assertions fail. + +## Gates + +`df -g /` reported 0 GB available, below the binding 12 GB threshold. No cargo +invocation was made, so neither required cargo gate was run: + +- NOT RUN: `cargo test -p perry-codegen` +- NOT RUN: `cargo build --release -p perry` + +Non-cargo checks completed: `rustfmt --check`, `git diff --check`, exact fixture +`cmp`, source operator counts (76 `>=`, 76 `<=`, 60 `===`, 76 `&&`, 135 +`||`), and SHA-256 equality with the supplied source +(`ffa564fc9612a0a1011f60d343d43777d082437547af2679ecb1568f478d1051`). + +## Perrymaster request + +Rebuild the compiler from implementation SHA +`ae8e15aa28491a63312a59050b14b70d4831a82c` on the I7-view tree, compile the +cc bundle with I7-view's configuration, and verify: + +```sh +nm | grep -c 'perry_fn_cli_2_1_112_js__YF6\$typed_i1' +``` + +The identity must be `1`. Then collect the rows versus I7-view and the perf +draw, using marker `YF6` self from the 9.46% baseline. diff --git a/changelog.d/9921-typed-numeric-predicate.md b/changelog.d/9921-typed-numeric-predicate.md new file mode 100644 index 0000000000..63b079a579 --- /dev/null +++ b/changelog.d/9921-typed-numeric-predicate.md @@ -0,0 +1,4 @@ +### Changed + +- Boolean helpers with erased numeric parameters now use a guarded typed clone + for comparison-heavy predicates, avoiding repeated generic relational calls. diff --git a/crates/perry-codegen/Cargo.toml b/crates/perry-codegen/Cargo.toml index 05faf97664..9b29a3d8db 100644 --- a/crates/perry-codegen/Cargo.toml +++ b/crates/perry-codegen/Cargo.toml @@ -76,3 +76,4 @@ llvm-sys = { version = "221", features = ["no-llvm-linking"], optional = true } # dist` still compile the library with `testing` off. [dev-dependencies] perry-codegen = { path = ".", features = ["testing"] } +perry-parser.workspace = true diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 21eb811fb3..80eafc46c3 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -25,9 +25,10 @@ use super::spec_abi::{ }; use super::typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_function_body_name, lower_typed_f64_body, - lower_typed_i1_body, lower_typed_i32_body, lower_typed_string_body, typed_f64_function_name, - typed_i1_function_name, typed_i32_function_name, typed_param_reps_for_params, - typed_string_function_name, TypedFunctionTrampolineKind, TypedParamRep, + lower_typed_i1_body_with_seed_locals, lower_typed_i32_body, lower_typed_string_body, + typed_f64_function_name, typed_i1_function_name, typed_i1_function_param_reps, + typed_i32_function_name, typed_param_reps_for_params, typed_string_function_name, + TypedFunctionTrampolineKind, TypedParamRep, }; /// Internal body name for a self-recursive allocator whose arena-state pointer @@ -144,7 +145,7 @@ pub(super) fn compile_typed_i1_function( .cloned() .ok_or_else(|| anyhow!("function name not resolved for {}", f.name))?; let llvm_name = typed_i1_function_name(&generic_name); - let param_reps = typed_param_reps_for_params(&f.params) + let param_reps = typed_i1_function_param_reps(f) .ok_or_else(|| anyhow!("typed-i1 function '{}' has unsupported parameter", f.name))?; let params: Vec<(LlvmType, String)> = f .params @@ -159,7 +160,13 @@ pub(super) fn compile_typed_i1_function( let value = { let blk = lf.block_mut(0).unwrap(); - lower_typed_i1_body(blk, &f.params, &f.body)? + let seed_reps = f + .params + .iter() + .zip(param_reps.iter().copied()) + .map(|(param, rep)| (param.id, rep)) + .collect(); + lower_typed_i1_body_with_seed_locals(blk, &f.params, &f.body, HashMap::new(), seed_reps)? }; lf.block_mut(0).unwrap().ret(I1, &value); Ok(()) @@ -288,7 +295,7 @@ fn emit_public_typed_function_trampoline( .unwrap_or_else(|| vec![TypedParamRep::F64; f.params.len()]), TypedFunctionTrampolineKind::I32 => typed_param_reps_for_params(&f.params) .unwrap_or_else(|| vec![TypedParamRep::I32; f.params.len()]), - TypedFunctionTrampolineKind::I1 => typed_param_reps_for_params(&f.params) + TypedFunctionTrampolineKind::I1 => typed_i1_function_param_reps(f) .unwrap_or_else(|| vec![TypedParamRep::I1; f.params.len()]), TypedFunctionTrampolineKind::StringRef => typed_param_reps_for_params(&f.params) .unwrap_or_else(|| vec![TypedParamRep::StringRef; f.params.len()]), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d3d84cb5a1..4b3c0ec455 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1831,7 +1831,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> match typed_abi::typed_i1_function_rejection_reason(f) { None => { typed_i1_functions.insert(f.id); - if let Some(reps) = typed_abi::typed_param_reps_for_params(&f.params) { + if let Some(reps) = typed_abi::typed_i1_function_param_reps(f) { typed_i1_function_param_reps.insert(f.id, reps); } } diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index a9153644f4..3479105751 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -164,6 +164,22 @@ pub(crate) fn typed_param_reps_for_params( .collect() } +/// Parameter representations for a Boolean-returning top-level function. +/// +/// In addition to declared scalar types, admit erased (`Any`/`Unknown`) +/// parameters when the complete straight-line body proves that they are used +/// as numbers. The public JSValue entry guards every inferred F64 parameter +/// once and retains the unchanged generic body for guard failure. Four +/// comparisons is the deliberately small profitability floor: below it the +/// entry guard and duplicate body can cost as much as the generic relational +/// fast paths they replace. +pub(crate) fn typed_i1_function_param_reps(function: &Function) -> Option> { + if typed_i1_function_rejection_reason_impl(function).is_none() { + return typed_param_reps_for_params(&function.params); + } + erased_numeric_predicate_param_reps(function) +} + pub(crate) fn typed_f64_closure_capture_reps( expr: &Expr, module_local_types: &HashMap, @@ -782,7 +798,7 @@ pub(crate) fn is_typed_i32_function_candidate(function: &Function) -> bool { #[allow(dead_code)] pub(crate) fn is_typed_i1_function_candidate(function: &Function) -> bool { - typed_i1_function_rejection_reason_impl(function).is_none() + typed_i1_function_rejection_reason(function).is_none() } #[allow(dead_code)] @@ -820,7 +836,14 @@ pub(crate) fn typed_i32_function_rejection_reason( pub(crate) fn typed_i1_function_rejection_reason( function: &Function, ) -> Option { - typed_i1_function_rejection_reason_impl(function) + let declared_reason = typed_i1_function_rejection_reason_impl(function); + if matches!(declared_reason, Some(TypedCloneRejectionReason::ParamNotI1)) + && erased_numeric_predicate_param_reps(function).is_some() + { + None + } else { + declared_reason + } } pub(crate) fn typed_string_function_rejection_reason( @@ -1163,6 +1186,85 @@ pub(crate) fn typed_string_closure_rejection_reason_with_types( typed_string_body_rejection_reason(body, locals) } +const ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS: usize = 4; + +fn numeric_comparisons_in_typed_i1_expr( + expr: &Expr, + locals: &HashMap, +) -> usize { + match expr { + Expr::Compare { left, right, .. } + if expr_is_typed_f64_safe(left, locals) && expr_is_typed_f64_safe(right, locals) => + { + 1 + } + Expr::Logical { left, right, .. } => { + numeric_comparisons_in_typed_i1_expr(left, locals) + + numeric_comparisons_in_typed_i1_expr(right, locals) + } + Expr::Unary { operand, .. } => numeric_comparisons_in_typed_i1_expr(operand, locals), + _ => 0, + } +} + +fn erased_numeric_predicate_param_reps(function: &Function) -> Option> { + if function.is_async + || function.is_generator + || function.was_plain_async + || !function.captures.is_empty() + || !matches!(function.return_type, Type::Boolean) + || function.params.iter().any(|param| { + param.default.is_some() || param.is_rest || param.arguments_object.is_some() + }) + { + return None; + } + + let mut referenced = HashSet::new(); + crate::collectors::collect_ref_ids_in_stmts(&function.body, &mut referenced); + let mut inferred_erased = false; + let reps: Vec = function + .params + .iter() + .map(|param| { + if let Some(rep) = typed_param_rep_for_type(¶m.ty) { + return Some(rep); + } + if matches!(param.ty, Type::Any | Type::Unknown) && referenced.contains(¶m.id) { + inferred_erased = true; + return Some(TypedParamRep::F64); + } + None + }) + .collect::>()?; + if !inferred_erased { + return None; + } + + let locals: HashMap = function + .params + .iter() + .zip(reps.iter().copied()) + .map(|(param, rep)| (param.id, rep)) + .collect(); + if typed_i1_body_rejection_reason(&function.body, locals.clone()).is_some() { + return None; + } + let comparisons = function + .body + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Return(Some(expr)) => Some(expr), + _ => None, + }) + .map(|expr| numeric_comparisons_in_typed_i1_expr(expr, &locals)) + .sum::(); + (comparisons >= ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS).then_some(reps) +} + fn typed_i1_function_rejection_reason_impl( function: &Function, ) -> Option { diff --git a/crates/perry-codegen/tests/fixtures/yf6_real.js b/crates/perry-codegen/tests/fixtures/yf6_real.js new file mode 100644 index 0000000000..604730a4de --- /dev/null +++ b/crates/perry-codegen/tests/fixtures/yf6_real.js @@ -0,0 +1 @@ +function YF6(q){return q>=4352&&q<=4447||q===8986||q===8987||q===9001||q===9002||q>=9193&&q<=9196||q===9200||q===9203||q===9725||q===9726||q===9748||q===9749||q>=9776&&q<=9783||q>=9800&&q<=9811||q===9855||q>=9866&&q<=9871||q===9875||q===9889||q===9898||q===9899||q===9917||q===9918||q===9924||q===9925||q===9934||q===9940||q===9962||q===9970||q===9971||q===9973||q===9978||q===9981||q===9989||q===9994||q===9995||q===10024||q===10060||q===10062||q>=10067&&q<=10069||q===10071||q>=10133&&q<=10135||q===10160||q===10175||q===11035||q===11036||q===11088||q===11093||q>=11904&&q<=11929||q>=11931&&q<=12019||q>=12032&&q<=12245||q>=12272&&q<=12287||q>=12289&&q<=12350||q>=12353&&q<=12438||q>=12441&&q<=12543||q>=12549&&q<=12591||q>=12593&&q<=12686||q>=12688&&q<=12773||q>=12783&&q<=12830||q>=12832&&q<=12871||q>=12880&&q<=42124||q>=42128&&q<=42182||q>=43360&&q<=43388||q>=44032&&q<=55203||q>=63744&&q<=64255||q>=65040&&q<=65049||q>=65072&&q<=65106||q>=65108&&q<=65126||q>=65128&&q<=65131||q>=94176&&q<=94180||q>=94192&&q<=94198||q>=94208&&q<=101589||q>=101631&&q<=101662||q>=101760&&q<=101874||q>=110576&&q<=110579||q>=110581&&q<=110587||q===110589||q===110590||q>=110592&&q<=110882||q===110898||q>=110928&&q<=110930||q===110933||q>=110948&&q<=110951||q>=110960&&q<=111355||q>=119552&&q<=119638||q>=119648&&q<=119670||q===126980||q===127183||q===127374||q>=127377&&q<=127386||q>=127488&&q<=127490||q>=127504&&q<=127547||q>=127552&&q<=127560||q===127568||q===127569||q>=127584&&q<=127589||q>=127744&&q<=127776||q>=127789&&q<=127797||q>=127799&&q<=127868||q>=127870&&q<=127891||q>=127904&&q<=127946||q>=127951&&q<=127955||q>=127968&&q<=127984||q===127988||q>=127992&&q<=128062||q===128064||q>=128066&&q<=128252||q>=128255&&q<=128317||q>=128331&&q<=128334||q>=128336&&q<=128359||q===128378||q===128405||q===128406||q===128420||q>=128507&&q<=128591||q>=128640&&q<=128709||q===128716||q>=128720&&q<=128722||q>=128725&&q<=128728||q>=128732&&q<=128735||q===128747||q===128748||q>=128756&&q<=128764||q>=128992&&q<=129003||q===129008||q>=129292&&q<=129338||q>=129340&&q<=129349||q>=129351&&q<=129535||q>=129648&&q<=129660||q>=129664&&q<=129674||q>=129678&&q<=129734||q===129736||q>=129741&&q<=129756||q>=129759&&q<=129770||q>=129775&&q<=129784||q>=131072&&q<=196605||q>=196608&&q<=262141} diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index b14de19aa9..71f6b64c92 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -8730,6 +8730,60 @@ fn typed_i1_numeric_predicate_module() -> Module { } } +fn typed_i1_erased_numeric_predicate_module() -> Module { + let mut module = typed_i1_numeric_predicate_module(); + module.name = "typed_i1_erased_numeric_predicate.ts".to_string(); + + let range = |lo, hi| Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Compare { + op: CompareOp::Ge, + left: Box::new(local(1)), + right: Box::new(int(lo)), + }), + right: Box::new(Expr::Compare { + op: CompareOp::Le, + left: Box::new(local(1)), + right: Box::new(int(hi)), + }), + }; + let equal = |value| Expr::Compare { + op: CompareOp::Eq, + left: Box::new(local(1)), + right: Box::new(int(value)), + }; + let predicate = vec![ + range(0x1100, 0x115f), + equal(0x2329), + range(0x2e80, 0x303e), + equal(0xa015), + ] + .into_iter() + .reduce(|left, right| Expr::Logical { + op: LogicalOp::Or, + left: Box::new(left), + right: Box::new(right), + }) + .unwrap(); + + module.functions[0].name = "is_wide".to_string(); + module.functions[0].params = vec![param(1, "q", Type::Any)]; + module.functions[0].body = vec![Stmt::Return(Some(predicate))]; + + module.functions[1].name = "caller".to_string(); + module.functions[1].params = vec![param(3, "s", Type::String)]; + module.functions[1].body = vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::StringCodePointAt { + string: Box::new(local(3)), + index: Box::new(int(0)), + }], + type_args: Vec::new(), + byte_offset: 0, + }))]; + module +} + fn typed_i1_i32_predicate_module() -> Module { Module { name: "typed_i1_i32_predicate.ts".to_string(), @@ -11833,6 +11887,51 @@ fn typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper() { ); } +#[test] +fn typed_i1_erased_numeric_predicate_guards_once_and_uses_f64_comparisons() { + let ir = String::from_utf8( + compile_module(&typed_i1_erased_numeric_predicate_module(), empty_opts()).unwrap(), + ) + .unwrap(); + let public = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide"; + let typed = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide$typed_i1"; + let generic_body = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide$generic"; + let caller = "perry_fn_typed_i1_erased_numeric_predicate_ts__caller"; + let wrapper_ir = function_ir_section(&ir, public); + let typed_ir = defined_function_ir_section(&ir, typed); + let generic_ir = defined_function_ir_section(&ir, generic_body); + let caller_ir = body_ir_section(&ir, caller); + + assert!( + ir.contains(&format!("define internal i1 @{typed}(double %arg1)")), + "erased numeric predicate should have an f64 typed clone:\n{ir}" + ); + assert!( + typed_ir.contains("fcmp oge double") + && typed_ir.contains("fcmp ole double") + && typed_ir.contains("fcmp oeq double") + && !typed_ir.contains("@js_rel_"), + "typed predicate should contain only native f64 comparisons:\n{typed_ir}" + ); + assert!( + generic_ir.contains("call double @js_rel_ge(") + && generic_ir.contains("call double @js_rel_le("), + "generic fallback should preserve JS relational semantics:\n{generic_ir}" + ); + assert!( + wrapper_ir.contains(", 32761") + && wrapper_ir.contains(&format!("call i1 @{typed}(double ")) + && wrapper_ir.contains(&format!("call double @{generic_body}(")), + "public wrapper should guard once and retain the generic fallback:\n{wrapper_ir}" + ); + assert!( + caller_ir.contains("call double @js_string_code_point_at(") + && caller_ir.contains(&format!("call double @{public}(")) + && !caller_ir.contains(&format!("call i1 @{typed}(")), + "codePointAt's undefined case should use the guarded public entry:\n{caller_ir}" + ); +} + #[test] fn typed_i1_i32_predicate_function_uses_i32_params_and_public_wrapper() { let ir = diff --git a/crates/perry-codegen/tests/yf6_admission.rs b/crates/perry-codegen/tests/yf6_admission.rs new file mode 100644 index 0000000000..95460de36b --- /dev/null +++ b/crates/perry-codegen/tests/yf6_admission.rs @@ -0,0 +1,159 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; + +const YF6_REAL: &str = include_str!("fixtures/yf6_real.js"); + +fn defined_function_ir_section<'a>(ir: &'a str, symbol: &str) -> &'a str { + let needle = format!("@{symbol}("); + let mut search_start = 0; + let start = loop { + let relative = ir[search_start..] + .find(&needle) + .unwrap_or_else(|| panic!("function `{symbol}` definition not found in IR:\n{ir}")); + let symbol_start = search_start + relative; + let line_start = ir[..symbol_start] + .rfind('\n') + .map_or(0, |newline| newline + 1); + if ir[line_start..symbol_start] + .trim_start() + .starts_with("define ") + { + break line_start; + } + search_start = symbol_start + needle.len(); + }; + let rest = &ir[start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |close| close + 3); + &rest[..end] +} + +/// The IR of `symbol` together with every specialisation clone the lowering +/// may have split it into (`symbol$spec_*`, `symbol$generic`, ...). A caller +/// whose parameter is typed `string` is lowered as a guarded pair of clones +/// behind `js_typed_string_arg_guard`, so the call it makes lives in the +/// clones, not in the public entry. +fn defined_function_ir_sections_with_clones(ir: &str, symbol: &str) -> String { + let mut out = String::new(); + let mut search_start = 0; + while let Some(relative) = ir[search_start..].find("define ") { + let line_start = search_start + relative; + let line_end = ir[line_start..] + .find('\n') + .map_or(ir.len(), |newline| line_start + newline); + let line = &ir[line_start..line_end]; + let defines_symbol_or_clone = line + .find(&format!("@{symbol}")) + .map(|at| { + let after = &line[at + symbol.len() + 1..]; + after.starts_with('(') || after.starts_with('$') + }) + .unwrap_or(false); + if defines_symbol_or_clone { + let rest = &ir[line_start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |close| close + 3); + out.push_str(&rest[..end]); + out.push('\n'); + search_start = line_start + end; + } else { + search_start = line_end.max(line_start + 1); + } + } + assert!( + !out.is_empty(), + "function `{symbol}` (or a clone of it) not found in IR:\n{ir}" + ); + out +} + +#[test] +fn real_yf6_erased_predicate_gets_typed_i1_clone() { + // Keep YF6 byte-for-byte identical to the cc bundle. The typed caller is + // appended separately so codePointAt's possible `undefined` exercises the + // public guard instead of proving a raw-f64 direct call. + let source = + format!("{YF6_REAL}\nfunction caller(s: string){{return YF6(s.codePointAt(0))}}\n"); + + let ir = std::thread::Builder::new() + .name("real-yf6-codegen".to_string()) + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let ast = perry_parser::parse_typescript(&source, "yf6_admission.ts") + .expect("real YF6 should parse"); + let hir = perry_hir::lower_module(&ast, "yf6_admission.ts", "yf6_admission.ts") + .expect("real YF6 should lower"); + + let yf6 = hir + .functions + .iter() + .find(|function| function.name == "YF6") + .expect("YF6 should be a top-level HIR function"); + assert_eq!( + yf6.return_type, + Type::Boolean, + "the full logical chain must retain its inferred Boolean return type" + ); + + let options = CompileOptions { + emit_ir_only: true, + ..CompileOptions::default() + }; + String::from_utf8(compile_module(&hir, options).expect("real YF6 should codegen")) + .expect("LLVM IR should be UTF-8") + }) + .expect("spawn YF6 codegen thread") + .join() + .expect("YF6 codegen thread panicked"); + + let public = "perry_fn_yf6_admission_ts__YF6"; + let typed = "perry_fn_yf6_admission_ts__YF6$typed_i1"; + let generic = "perry_fn_yf6_admission_ts__YF6$generic"; + let caller = "perry_fn_yf6_admission_ts__caller"; + let typed_ir = defined_function_ir_section(&ir, typed); + let generic_ir = defined_function_ir_section(&ir, generic); + let wrapper_ir = defined_function_ir_section(&ir, public); + // The caller takes a `string`, so the lowering may split it into guarded + // specialisation clones; the YF6 call is in whichever clone carries the body. + let caller_ir = defined_function_ir_sections_with_clones(&ir, caller); + + assert!( + typed_ir.starts_with(&format!("define internal i1 @{typed}(double ")), + "YF6 should have an f64-to-i1 typed clone:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp oge double").count(), + 76, + "YF6 clone should lower every >= natively:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp ole double").count(), + 76, + "YF6 clone should lower every <= natively:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp oeq double").count(), + 60, + "YF6 clone should lower every === natively:\n{typed_ir}" + ); + assert!( + !typed_ir.contains("@js_rel_"), + "YF6 clone must not retain generic relational calls:\n{typed_ir}" + ); + + assert!( + generic_ir.contains("call double @js_rel_ge(") + && generic_ir.contains("call double @js_rel_le("), + "YF6's generic JSValue fallback must remain intact:\n{generic_ir}" + ); + assert!( + wrapper_ir.contains(", 32761") + && wrapper_ir.contains(&format!("call i1 @{typed}(double ")) + && wrapper_ir.contains(&format!("call double @{generic}(double ")), + "YF6's public wrapper must guard the value and retain both paths:\n{wrapper_ir}" + ); + assert!( + caller_ir.contains("call double @js_string_code_point_at(") + && caller_ir.contains(&format!("call double @{public}(double ")) + && !caller_ir.contains(&format!("call i1 @{typed}(double ")), + "codePointAt's undefined case must enter through YF6's guarded wrapper (in the caller or any of its specialisation clones):\n{caller_ir}" + ); +} diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 0e44e0d3f0..cb44af140d 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -313,6 +313,12 @@ fn url_encoding_constructor_type(ctx: &LoweringContext, callee: &ast::Expr) -> O /// source never nests literals this deep, so the cap loses no practical /// precision while keeping pathological/minified inputs tractable. const INFER_TYPE_RECURSION_CAP: u32 = 48; +// Logical chains are common in generated/minified predicates and are normally +// left-deep. Walk them without spending one recursion level per `&&`/`||`, but +// retain a fixed work bound so repeated inference while lowering a pathological +// chain cannot restore #5258's unbounded O(n²) behavior. This admits up to 256 +// boolean-valued leaves, including cc's 212-comparison YF6 predicate. +const INFER_LOGICAL_CHAIN_NODE_CAP: usize = 512; const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; @@ -343,6 +349,44 @@ pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> T }) } +/// Infer a tree made only from `&&` / `||` joins by flattening those joins onto +/// an explicit worklist. The ordinary logical inference rule is associative at +/// the type level: the result is a concrete type exactly when every leaf has +/// the same concrete type. Keeping non-logical leaves on the regular inference +/// path preserves all existing type rules. +fn infer_logical_chain_type(root: &ast::BinExpr, ctx: &LoweringContext) -> Type { + let mut pending = vec![root.right.as_ref(), root.left.as_ref()]; + let mut visited = 1usize; + let mut common: Option = None; + + while let Some(expr) = pending.pop() { + visited += 1; + if visited > INFER_LOGICAL_CHAIN_NODE_CAP { + return Type::Any; + } + + if let ast::Expr::Bin(bin) = expr { + if matches!(bin.op, ast::BinaryOp::LogicalAnd | ast::BinaryOp::LogicalOr) { + pending.push(bin.right.as_ref()); + pending.push(bin.left.as_ref()); + continue; + } + } + + let ty = infer_type_from_expr(expr, ctx); + if matches!(ty, Type::Any) { + return Type::Any; + } + match &common { + None => common = Some(ty), + Some(expected) if *expected == ty => {} + Some(_) => return Type::Any, + } + } + + common.unwrap_or(Type::Any) +} + fn infer_type_from_expr_inner(expr: &ast::Expr, ctx: &LoweringContext) -> Type { match expr { // Literals @@ -453,15 +497,7 @@ fn infer_type_from_expr_inner(expr: &ast::Expr, ctx: &LoweringContext) -> Type { // operand types match, else `Any` (dynamic truthiness/isArray). // Extends the #3527 fix (right = `Any` → `Any`) to the // mismatched-type case. - LogicalAnd | LogicalOr => { - let left = infer_type_from_expr(&bin.left, ctx); - let right = infer_type_from_expr(&bin.right, ctx); - if left == right && !matches!(right, Type::Any) { - right - } else { - Type::Any - } - } + LogicalAnd | LogicalOr => infer_logical_chain_type(bin, ctx), // One rule with the HIR-level `??` inference: an unknown left // stays unknown, only a nullish left takes the right type. // Pre-fix this arm answered the RIGHT type for an `Any` left,