-
-
Notifications
You must be signed in to change notification settings - Fork 158
fix(codegen): a declared numeric type is not a proof that the value is a number #7831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| ### Fixed | ||
|
|
||
| - **A declared numeric type is no longer treated as proof that the value is a | ||
| number** (#7773, #7776). Perry does not enforce annotations at runtime, but | ||
| codegen answered `is_numeric_expr` = `true` on the strength of one and then | ||
| emitted bare f64 arithmetic on whatever the slot actually held. | ||
|
|
||
| That is worse than producing a `NaN`, because arithmetic on a NaN-BOXED value | ||
| is not a no-op: `fadd`/`fmul` propagate the input NaN's payload, so a | ||
| NaN-boxed string comes back out of the instruction still tagged as that | ||
| string and flows on as if nothing happened — `typeof (v * 2)` answered | ||
| `"string"`. Four divergences from Node, all silent: `o.x + 1` gave `NaN` | ||
| where Node concatenates; `const v = o.x; v + 1` looked as though the `+ 1` | ||
| had evaporated; `v * 2` returned the string; and summing a `P[]` with one | ||
| `as any`-stored `Q` element gave `NaN`. | ||
|
|
||
| A new `numeric_proof_is_declared_only` separates "an annotation said so" from | ||
| a real proof. It is deliberately narrower than | ||
| `expr_may_return_boxed_value_from_raw_f64_fallback` (which answers "is there | ||
| a raw-f64 tier worth trying" and stays true for reads with no boxed fallback | ||
| at all): element-shape and class-field loop facts, `Ptr<Shape>` numeric | ||
| fields, scalar replacement, POD records and typed arrays all answer `false` | ||
| and keep their bare loads. `+` then lowers through an inline NaN-box tag test | ||
| — `fadd` on the fast arm, `js_dynamic_string_or_number_add` on the cold one — | ||
| because the spec's `+` dispatches on the runtime value; every other | ||
| arithmetic operator is a plain `ToNumber` and only needed the existing | ||
| residual-coerce rule taught to see a refined LOCAL. | ||
|
|
||
| `expr/mod.rs::lower_numeric_binary_value` turned out to be a second | ||
| arithmetic tier that bypasses `binary::lower` entirely and emits bare | ||
| `fadd`/`fmul` with no residual coerce at all; it was the path both | ||
| refined-local shapes took, and it now hands declared-only operands down the | ||
| same way its two existing `Mod` cases do. | ||
|
|
||
| Two details are load-bearing and are pinned by the test. The diamond covers | ||
| the whole `+` **tree**, not one node each: per-node diamonds make the outer | ||
| add of `s += o.x + 1` consume a phi that LLVM cannot prove is a canonical | ||
| double, which killed the `fadd` in the loop (+38% before fusing, +8.6% | ||
| after). And every leaf is tested except those `expr_produces_canonical_raw_f64` | ||
| vouches for — testing only the declared-only leaves skips the ACCUMULATOR, | ||
| which holds a string the moment this lowering's own cold arm concatenates, | ||
| and summed `16zw1113151719` down to `16zw`. | ||
|
|
||
| Measured on the quiet M1 mini, same runtime in both arms: element-shape clone | ||
| 218 → 217 ms (−0.5%, untouched), `this.v + 1` in a method 70 → 76 ms (+8.6%), | ||
| `s += p.x + p.y` with an escaped receiver 196 → 263 ms (+34.2%). The cost | ||
| falls only on reads nothing could prove, which already pay an inline header | ||
| precheck or a `js_typed_feedback_class_field_get_guard` call. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -290,6 +290,27 @@ pub(crate) fn lower_let( | |
| ty.clone() | ||
| }; | ||
|
|
||
| // #7773: the refinement above copies a DECLARED type — `const v = o.x` on a | ||
| // `x: number` field answers `Number` because the annotation says so, not | ||
| // because anything proved it. Nothing enforces annotations at runtime, so | ||
| // record the local as violable; `numeric_proof_is_declared_only` then makes | ||
| // arithmetic on it re-check the tag instead of trusting the type outright. | ||
| // | ||
| // Only the Any → numeric direction matters. A local the user DECLARED | ||
| // `number` is equally unenforced, but it is also the shape every honest | ||
| // program is made of; the refined case is the one where codegen invented | ||
| // the numeric claim itself, and it is the one both reported shapes need. | ||
| if matches!(ty, perry_hir::types::Type::Any) | ||
| && matches!( | ||
| refined_ty, | ||
| perry_hir::types::Type::Number | perry_hir::types::Type::Int32 | ||
| ) | ||
| { | ||
| if init.is_some_and(|e| crate::type_analysis::numeric_proof_is_declared_only(ctx, e)) { | ||
| ctx.declared_only_numeric_locals.insert(id); | ||
| } | ||
| } | ||
|
Comment on lines
+303
to
+312
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Track all declared-only numeric locals. The current tracking only covers
Add regressions for an explicit 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| // Track closure func_id → local_id mapping so the closure | ||
| // call site in lower_call can look up rest param info. | ||
| if let Some(perry_hir::Expr::Closure { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Remove the
LocalGetrestriction so a declared-only+subtree also gets the residual coerce.numeric_proof_is_declared_onlyanswerstrueforPropertyGet,IndexGet,LocalGet,Binary{Add}, andLogical. This clause matches onlyLocalGet.PropertyGetandIndexGetare already covered, becauseexpr_may_return_boxed_value_from_raw_f64_fallbackis a precondition inside those arms ofnumeric_proof_is_declared_only.Logicalis covered too, becauselower_numeric_logical_for_number_contextapplieslower_operand_as_numberper leaf.Binary{Add}is not covered. Consider(o.x + 1) * 2whereo.xholds a string:+routes tolower_declared_only_numeric_addand its slow arm returns a concatenated string.Mulcallsoperand_needs_residual_coerceon the innerBinary{Add}.is_numeric_expristrue, the boxed-fallback predicate isfalse, and the expression is not aLocalGet, so no coerce is emitted.fmulreceives a NaN-boxed string and propagates the payload.That is the same wrong-
typeoffailure this PR fixes, one operator out. TheLocalGetrestriction buys nothing for the other variants, so dropping it closes the gap without widening behavior elsewhere.🐛 Proposed fix to cover every declared-only operand shape
// `#7773`: a local REFINED to `Number` from a declared field/element // type is `is_numeric_expr`, but the hazard predicate above only // knows how to look at reads, so `const v = o.x; v * 2` emitted a // bare `fmul`. Arithmetic on a NaN-box preserves the payload, so // that multiply returned the string unchanged — `typeof (v * 2)` // answered `"string"`. Every non-`+` arithmetic operator is a plain // `ToNumber` on its operands, so a coerce is the whole fix here; // `+` needs the concat dispatch and gets it from - // `lower_declared_only_numeric_add`. - || matches!(expr, Expr::LocalGet(_)) && numeric_proof_is_declared_only(ctx, expr)) + // `lower_declared_only_numeric_add`. A declared-only `+` SUBTREE + // consumed by a non-`+` operator needs the coerce too: its slow arm + // can return a string, and the enclosing `fmul` would propagate the + // payload. + || numeric_proof_is_declared_only(ctx, expr))📝 Committable suggestion
🤖 Prompt for AI Agents