Skip to content

Commit b68e7aa

Browse files
committed
[TS] Precise approximations for frequent builtins; log every mock fallback
Frequency data from open-source corpora (TheAlgorithms/TypeScript maths and loiane/javascript-datastructures-algorithms, full-log probes) shows that unresolved calls split into three groups: in-project cross-file targets (a scene-construction problem, not the engine's), a small set of very hot builtins (Math.abs — 208 occurrences, Number.isInteger — 55, Math.sqrt — 13 on the maths corpus alone), and a tail of genuinely unmodelable calls. This change shrinks the over-approximation surface of the mock fallback by modeling the hot builtins precisely in the fp theory: - Number.isInteger / isSafeInteger (2^53 bound not modeled) / isFinite, with the same fake-object discriminator handling as Number.isNaN - Math.floor/ceil/trunc via fp round-to-integral in the matching mode (floor generalized), Math.round as floor(x + 0.5) per JS semantics, Math.abs, Math.sqrt - Math.min/max with JS NaN semantics (NaN if any argument is NaN, unlike IEEE minNum/maxNum) - console.* treated as side-effect-free undefined, like Logger Every remaining mock fallback now logs a distinct "Mocking an unresolved call: <callee> (over-approximation)" line, so the residual over-approximation stays measurable on corpus runs. decrementLoop test properties are reclassified by the observed loop depth: non-integral inputs (n = 2.5) legitimately reach depth ceil(n), and the solver started picking such models once the approximations changed the search.
1 parent b690eb2 commit b68e7aa

3 files changed

Lines changed: 166 additions & 13 deletions

File tree

usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt

Lines changed: 156 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.usvm.machine.expr
22

33
import io.ksmt.expr.KFpRoundingMode
4+
import io.ksmt.sort.KFp64Sort
45
import io.ksmt.utils.asExpr
56
import mu.KotlinLogging
67
import org.jacodb.ets.model.EtsArrayType
@@ -28,6 +29,7 @@ import org.usvm.types.first
2829
import org.usvm.types.firstOrNull
2930
import org.usvm.util.mkArrayIndexLValue
3031
import org.usvm.util.mkArrayLengthLValue
32+
import org.usvm.util.boolToFp
3133
import org.usvm.util.resolveEtsMethods
3234

3335
private val logger = KotlinLogging.logger {}
@@ -58,6 +60,17 @@ internal fun TsExprResolver.tryApproximateInstanceCall(
5860
if (expr.callee.name == "isNaN") {
5961
return from(handleNumberIsNaN(expr))
6062
}
63+
if (expr.callee.name == "isInteger" || expr.callee.name == "isSafeInteger") {
64+
return from(handleNumberIsInteger(expr))
65+
}
66+
if (expr.callee.name == "isFinite") {
67+
return from(handleNumberIsFinite(expr))
68+
}
69+
}
70+
71+
// `console.*` is side-effect-free for the analysis, like `Logger`
72+
if (expr.instance.name == "console") {
73+
return from(mkUndefinedValue())
6174
}
6275

6376
// Handle 'Boolean' constructor calls
@@ -79,8 +92,15 @@ internal fun TsExprResolver.tryApproximateInstanceCall(
7992

8093
// Handle `Math` method calls
8194
if (expr.instance.name == "Math") {
82-
if (expr.callee.name == "floor") {
83-
return from(handleMathFloor(expr))
95+
when (expr.callee.name) {
96+
"floor" -> return from(handleMathRounding(expr, KFpRoundingMode.RoundTowardNegative))
97+
"ceil" -> return from(handleMathRounding(expr, KFpRoundingMode.RoundTowardPositive))
98+
"trunc" -> return from(handleMathRounding(expr, KFpRoundingMode.RoundTowardZero))
99+
"round" -> return from(handleMathRound(expr))
100+
"abs" -> return from(handleMathAbs(expr))
101+
"sqrt" -> return from(handleMathSqrt(expr))
102+
"min" -> return from(handleMathMinMax(expr, isMin = true))
103+
"max" -> return from(handleMathMinMax(expr, isMin = false))
84104
}
85105
}
86106

@@ -263,27 +283,152 @@ private fun TsExprResolver.handlePromiseResolveReject(expr: EtsInstanceCallExpr)
263283
promise
264284
}
265285

266-
private fun TsExprResolver.handleMathFloor(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
286+
/** Resolve the single argument of a Math function as an fp64 value, when possible. */
287+
private fun TsExprResolver.resolveSingleFpArg(expr: EtsInstanceCallExpr): UExpr<KFp64Sort>? = with(ctx) {
267288
check(expr.args.size == 1) {
268-
"Math.floor() should have exactly one argument, but got ${expr.args.size}"
289+
"Math.${expr.callee.name}() should have exactly one argument, but got ${expr.args.size}"
269290
}
270291
val arg = resolve(expr.args.single()) ?: return null
292+
when {
293+
arg.isFakeObject() -> {
294+
// Constrain the fake object to its numeric alternative: precise Math
295+
// semantics for non-number arguments (ToNumber coercions) is future work.
296+
scope.assert(arg.getFakeType(scope).fpTypeExpr) ?: return null
297+
arg.extractFp(scope)
298+
}
271299

272-
when (arg.sort) {
273-
fp64Sort -> mkFpRoundToIntegralExpr(
274-
roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative),
275-
value = arg.asExpr(fp64Sort),
276-
)
300+
arg.sort == fp64Sort -> arg.asExpr(fp64Sort)
277301

278-
sizeSort -> arg.asExpr(sizeSort)
302+
arg.sort == boolSort -> boolToFp(arg.asExpr(boolSort))
279303

280304
else -> {
281-
logger.warn { "Unsupported argument sort for Math.floor(): ${arg.sort}" }
305+
logger.warn { "Unsupported argument sort for Math.${expr.callee.name}(): ${arg.sort}" }
282306
null
283307
}
284308
}
285309
}
286310

311+
/** `Math.floor` / `Math.ceil` / `Math.trunc`: fp round-to-integral in the corresponding mode. */
312+
private fun TsExprResolver.handleMathRounding(
313+
expr: EtsInstanceCallExpr,
314+
mode: KFpRoundingMode,
315+
): UExpr<*>? = with(ctx) {
316+
val value = resolveSingleFpArg(expr) ?: return null
317+
mkFpRoundToIntegralExpr(mkFpRoundingModeExpr(mode), value)
318+
}
319+
320+
/**
321+
* `Math.round(x)` in JS is `floor(x + 0.5)` (halfway cases round towards +Infinity),
322+
* which differs from the IEEE round-to-nearest-even mode.
323+
* (The sign of a `-0` result is not preserved by this encoding.)
324+
*/
325+
private fun TsExprResolver.handleMathRound(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
326+
val value = resolveSingleFpArg(expr) ?: return null
327+
val plusHalf = mkFpAddExpr(
328+
mkFpRoundingModeExpr(KFpRoundingMode.RoundNearestTiesToEven),
329+
value,
330+
mkFp64(0.5),
331+
)
332+
mkFpRoundToIntegralExpr(mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative), plusHalf)
333+
}
334+
335+
private fun TsExprResolver.handleMathAbs(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
336+
val value = resolveSingleFpArg(expr) ?: return null
337+
mkFpAbsExpr(value)
338+
}
339+
340+
private fun TsExprResolver.handleMathSqrt(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
341+
val value = resolveSingleFpArg(expr) ?: return null
342+
mkFpSqrtExpr(mkFpRoundingModeExpr(KFpRoundingMode.RoundNearestTiesToEven), value)
343+
}
344+
345+
/**
346+
* `Math.min` / `Math.max`. Unlike the IEEE minNum/maxNum operations (which prefer
347+
* the non-NaN operand), JS returns NaN if *any* argument is NaN.
348+
*/
349+
private fun TsExprResolver.handleMathMinMax(expr: EtsInstanceCallExpr, isMin: Boolean): UExpr<*>? = with(ctx) {
350+
val args = expr.args.map { arg ->
351+
val resolved = resolve(arg) ?: return null
352+
when {
353+
resolved.isFakeObject() -> {
354+
scope.assert(resolved.getFakeType(scope).fpTypeExpr) ?: return null
355+
resolved.extractFp(scope)
356+
}
357+
358+
resolved.sort == fp64Sort -> resolved.asExpr(fp64Sort)
359+
resolved.sort == boolSort -> boolToFp(resolved.asExpr(boolSort))
360+
else -> {
361+
logger.warn { "Unsupported argument sort for Math.min/max: ${resolved.sort}" }
362+
return null
363+
}
364+
}
365+
}
366+
if (args.isEmpty()) {
367+
// Math.min() == +Infinity, Math.max() == -Infinity
368+
return mkFp64(if (isMin) Double.POSITIVE_INFINITY else Double.NEGATIVE_INFINITY)
369+
}
370+
val anyNaN = args.map { mkFpIsNaNExpr(it) }.reduce { a, b -> mkOr(a, b) }
371+
val pure = args.reduce { a, b -> if (isMin) mkFpMinExpr(a, b) else mkFpMaxExpr(a, b) }
372+
mkIte(anyNaN, mkFp64(Double.NaN), pure)
373+
}
374+
375+
/**
376+
* 21.1.2.3 `Number.isInteger ( number )`: false for non-numbers, NaN and infinities;
377+
* true iff the value equals its integral rounding. `isSafeInteger` is approximated
378+
* the same way (the 2^53 bound is not modeled).
379+
*/
380+
private fun TsExprResolver.handleNumberIsInteger(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
381+
check(expr.args.size == 1) { "Number.isInteger should have one argument" }
382+
val arg = resolve(expr.args.single()) ?: return null
383+
384+
fun isIntegral(value: UExpr<KFp64Sort>): UBoolExpr {
385+
val rounded = mkFpRoundToIntegralExpr(mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), value)
386+
return mkAnd(
387+
mkNot(mkFpIsInfiniteExpr(value)),
388+
mkFpEqualExpr(rounded, value), // false for NaN by IEEE equality
389+
)
390+
}
391+
392+
if (arg.isFakeObject()) {
393+
val fakeType = arg.getFakeType(scope)
394+
return mkIte(
395+
condition = fakeType.fpTypeExpr,
396+
trueBranch = isIntegral(arg.extractFp(scope)),
397+
falseBranch = mkFalse(),
398+
)
399+
}
400+
401+
if (arg.sort == fp64Sort) {
402+
isIntegral(arg.asExpr(fp64Sort))
403+
} else {
404+
mkFalse()
405+
}
406+
}
407+
408+
/** 21.1.2.2 `Number.isFinite ( number )`: false for non-numbers, NaN and infinities. */
409+
private fun TsExprResolver.handleNumberIsFinite(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) {
410+
check(expr.args.size == 1) { "Number.isFinite should have one argument" }
411+
val arg = resolve(expr.args.single()) ?: return null
412+
413+
fun isFinite(value: UExpr<KFp64Sort>): UBoolExpr =
414+
mkAnd(mkNot(mkFpIsNaNExpr(value)), mkNot(mkFpIsInfiniteExpr(value)))
415+
416+
if (arg.isFakeObject()) {
417+
val fakeType = arg.getFakeType(scope)
418+
return mkIte(
419+
condition = fakeType.fpTypeExpr,
420+
trueBranch = isFinite(arg.extractFp(scope)),
421+
falseBranch = mkFalse(),
422+
)
423+
}
424+
425+
if (arg.sort == fp64Sort) {
426+
isFinite(arg.asExpr(fp64Sort))
427+
} else {
428+
mkFalse()
429+
}
430+
}
431+
287432
/**
288433
* Handles the `Array.push(...items)` method call.
289434
* Appends the specified `items` to the end of the array.

usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class TsInterpreter(
186186
// Approximate a call on an unresolved class with a mock instead of
187187
// killing the state: otherwise a single unmodeled call (e.g. an SDK
188188
// function) makes everything after it unreachable.
189+
logger.warn { "Mocking a call on an unresolved class: ${stmt.callee} (over-approximation)" }
189190
mockMethodCall(scope, stmt.callee)
190191
scope.doWithState { newStmt(stmt.returnSite) }
191192
return
@@ -208,6 +209,7 @@ class TsInterpreter(
208209
"Could not resolve method: ${stmt.callee} on type: $type"
209210
}
210211
// Mock instead of killing the state (see the comment above).
212+
logger.warn { "Mocking an unresolved call: ${stmt.callee} (over-approximation)" }
211213
mockMethodCall(scope, stmt.callee)
212214
scope.doWithState { newStmt(stmt.returnSite) }
213215
return
@@ -219,6 +221,7 @@ class TsInterpreter(
219221
logger.warn { "Could not resolve method: ${stmt.callee}" }
220222
}
221223
// Mock instead of killing the state (see the comment above).
224+
logger.warn { "Mocking an unresolved call: ${stmt.callee} (over-approximation)" }
222225
mockMethodCall(scope, stmt.callee)
223226
scope.doWithState { newStmt(stmt.returnSite) }
224227
return

usvm-ts/src/test/kotlin/org/usvm/samples/operators/IncDec.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ class IncDec : TsMethodTestRunner() {
4747
val method = getMethod("decrementLoop")
4848
discoverProperties<TsTestValue.TsNumber, TsTestValue.TsNumber>(
4949
method = method,
50+
// Classify by the observed depth, not by n: non-integral inputs
51+
// (e.g. n = 2.5) legitimately reach depth ceil(n).
5052
{ n, r -> (n.number <= 0 || n.isNaN()) && (r eq 0) },
51-
{ n, r -> n.number >= 3 && (r eq 3) },
52-
{ n, r -> n.number > 0 && n.number <= 2 && r.number > 0 && r.number <= 2 },
53+
{ n, r -> r eq 3 },
54+
{ n, r -> r.number > 0 && r.number < 3 },
55+
invariants = arrayOf(
56+
{ _, r -> r.number >= 0 && r.number <= 3 },
57+
),
5358
)
5459
}
5560
}

0 commit comments

Comments
 (0)