Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bd53dad
[TS Calls] Add guarded semantic model registry and execution
CaelmBleidd Aug 28, 2026
7fb29a7
[TS] Document fake value representation invariants
CaelmBleidd Aug 28, 2026
67ed325
[TS Calls] Harden guarded semantic model execution
CaelmBleidd Aug 28, 2026
160e43e
[TS Calls] Remove redundant named arguments
CaelmBleidd Aug 29, 2026
fad3d15
[TS Calls] Separate intrinsic model implementations
CaelmBleidd Aug 31, 2026
34597f5
[TS Calls] Simplify guarded semantic models
CaelmBleidd Sep 7, 2026
bcc9d62
[TS Calls] Finish model contract simplification
CaelmBleidd Sep 7, 2026
eba8e4e
[TS Calls] Support unresolved Array.shift elements
CaelmBleidd Sep 8, 2026
9251124
[TS Calls] Read unresolved arrays from symbolic memory
CaelmBleidd Sep 17, 2026
baebc66
[TS Calls] Preserve array storage and input types across shifts
CaelmBleidd Sep 18, 2026
35dce95
[TS Calls] Normalize receivers before instance-call dispatch
CaelmBleidd Sep 18, 2026
c9e8d12
[TS Calls] Preserve approximation arguments and slice length
CaelmBleidd Sep 18, 2026
9e36359
[TS Calls] Normalize slice bounds and strengthen argument regression
CaelmBleidd Sep 18, 2026
d0ac4fa
Address model catalog review and preserve unresolved array storage
CaelmBleidd Sep 18, 2026
45431c0
Preserve typed removal results and validate array storage regressions
CaelmBleidd Sep 18, 2026
49c60cb
Format replay failure diagnostics
CaelmBleidd Sep 18, 2026
e72d8d0
Keep array initialization type bounds in core
CaelmBleidd Sep 18, 2026
f4ec3ba
fix(ts): warn before pruning unknown-call paths
CaelmBleidd Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions usvm-ts/UNKNOWN_CALL_MODELS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
# TypeScript unknown-call models

This document describes the semantic-model path used when the normal TypeScript interpreter cannot execute a call.

## Mental model

There are only three stages:

1. The regular interpreter and existing compatibility approximations try to execute the call.
2. If execution cannot continue, `TsUnknownCallModelCatalog` selects one enabled semantic model by its target.
3. If no model handles the call or a model leaves a residual state, the configured fallback is applied.

```text
normal execution
|
| cannot execute
v
enabled model with matching target? -- no --> fallback
|
yes
v
model accepts these inputs? -------- no --> fallback
|
yes
v
model successors + optional residual ----> residual uses fallback
```

The catalog contains model objects directly. There are no implementation-kind values, backend registrations, or
separate descriptor and implementation IDs.

## Configuration

Unknown-call behavior is configured directly in `TsOptions`:

```kotlin
TsOptions(
unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf("ts.array.shift")),
unknownCallFallback = TsResidualCallPolicy.STOP_PATH,
)
```

### `unknownCallModelSelection`

This is the only model-selection setting.

| Value | Meaning |
| --- | --- |
| `TsUnknownCallModelSelection.All` | Enable every built-in model. This is the default. |
| `TsUnknownCallModelSelection.Only(emptySet())` | Disable every built-in model. |
| `TsUnknownCallModelSelection.Only(setOf("id", ...))` | Enable exactly the listed built-in model IDs. |

Unknown IDs are rejected when the machine creates its immutable per-run catalog. The selected models are captured at that
point, so later mutations of the selection set cannot change an active run.

Use the model's `id`, for example `ts.array.shift`. A target method name, class name, source filename, or fingerprint is
not a model ID.

Built-ins are `object` implementations of the sealed `TsBuiltInUnknownCallModel` interface in the
`org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; adding a
model requires no manual registry entry. Discovery and the default catalog are computed once.

The built-in catalog currently contains one model:

| ID | Implementation | Accepted calls |
| --- | --- | --- |
| `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. |

The common instance-call pipeline splits fake-value wrappers and conditional references under their runtime-kind
and branch guards before selecting an approximation or resolving a method. A wrapped array can therefore use the
model, including through an `any` alias. An unknown or non-array receiver does not become an array merely because
the method is named `shift`. A definitely-array receiver with an unresolved element sort remains applicable and uses
the fake-value representation described below.

### `unknownCallFallback`

The fallback is applied when:

- no enabled model target matches the call;
- the selected model returns `null` because it cannot safely handle the concrete inputs;
- a model returns a satisfiable `residualGuard`.

The available policies are:

| Policy | Behavior |
| --- | --- |
| `STOP_PATH` | Prune the unsupported state. This is the default. |
| `FRESH_SYMBOLIC_RETURN` | Continue with a fresh symbolic result and ignore unknown side effects and exceptions. |

`FRESH_SYMBOLIC_RETURN` is deliberately imprecise. Use it only when opaque continuation is preferable to pruning.

## Model identity and target

Every model implements `TsUnknownCallModel`:

```kotlin
interface TsUnknownCallModel {
val id: String
val target: TsUnknownCallTarget

fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution?
}
```

### Choosing an ID

Use a stable semantic name:

```text
<ecosystem>.<owner-or-type>.<operation>[.<semantic-variant>]
```

Examples:

- `ts.array.shift`
- `ts.array.pop`
- `node.buffer.copy`

The ID is used for configuration, observer events, and catalog fingerprints. Do not include:

- an implementation mechanism such as `intrinsic`;
- a hash;
- a version number;
- a supported-domain label.

Keep the same ID if an equivalent model is later reimplemented by another mechanism.

### Choosing a target

`TsUnknownCallTarget` matches stable call metadata declaratively:

```kotlin
TsUnknownCallTarget(
methodName = "shift",
failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION,
)
```

Only `methodName` is required. Add `enclosingClassName` or `failureReason` when the method name alone is too broad.
The catalog indexes method names, failure reasons, and enclosing classes. Overlapping enabled targets fail while
building that index; lookup returns either one model or no match, and never hides ambiguity. Catalog order is never
a priority rule. IDs and their SHA-256 fingerprint are computed once; byte-length prefixes distinguish ID sequences
such as `["ab", "c"]` and `["a", "bc"]`.

The target identifies a call family. State-dependent checks, such as the receiver's symbolic runtime type, belong in
`apply`.

The built-in array target intentionally combines the method name with `PARTIAL_APPROXIMATION` instead of a class name.
That failure reason is emitted only after the regular approximation path has classified the receiver as an
`EtsArrayType` using the normalized receiver's storage type. An `any` alias of a known array can satisfy that check;
a receiver without array-type evidence cannot. The model still validates the resolved receiver and array shape
before changing memory.

## Applicability and residual states

There is no separate `EXACT` or `PARTIAL` flag.

- `apply(...) == null` means the model rejects the complete call. The dispatcher uses fallback.
- `residualGuard == null` means the returned execution completely handles the accepted state.
- A non-null `residualGuard` sends precisely that symbolic subdomain to fallback.

For example, a model may handle an array receiver under `isArray` and leave `!isArray` as residual:

```kotlin
TsUnknownCallModelExecution(
successors = listOf(
TsUnknownCallModelSuccessor(
guard = isArray,
completion = completion,
),
),
residualGuard = ctx.mkNot(isArray),
)
```

Model authors are responsible for making successor guards and the residual guard disjoint and exhaustive. This
property belongs in focused model tests; the dispatcher does not invoke the solver a second time merely to validate a
model on every call.

## When to write an intrinsic

An intrinsic directly builds guarded successors and symbolic-memory operations in Kotlin. Use it only for an operation
that TypeScript cannot express without losing symbolic efficiency or correctness.

`Array.shift` is the built-in example because shifting a symbolic array is naturally represented by symbolic-memory
`memcpy` operations. A resolved element sort uses one canonical array region. Unresolved elements use three
payload regions (boolean, number, and address) and two boolean kind selectors. Reference kind is derived as
`!(booleanKind || numberKind)`; the exactly-one constraint excludes both primitive selectors being true. Default
allocated slots therefore represent references, including undefined. `Unknown[]` names the canonical reference
storage region, not a claim that every TypeScript array has unresolved elements.

`copyArrayElements` moves all five regions for unresolved arrays, including allocated arrays created by `slice` or
`concat`. `reverse` applies the same index permutation to every region. Scalar writes store complete fake wrappers
in the reference region, overriding older payloads and selectors. Reads and test reconstruction use the same reader.
The removed `shift` element is materialized before forking so its kind constraint and updated solver models are
inherited by every successor.

`concat` handles arrays with compatible storage sorts and scalar elements that fit the destination. Calls requiring
conversion between storage sorts, or runtime spreading of a fake/untyped argument, use normal call resolution and
fallback. Existing `fill` bounds and the finite `reverse`/`fill` caps remain approximation limitations.
Array reads, writes, length access, and `shift` use the storage type known to symbolic memory when it is unique.
Widening a local from `number[]` to `any[]` therefore keeps the same element and length regions.

Good intrinsic candidates include:

- bulk symbolic-memory copy or fill;
- symbolic collection primitives;
- solver operations unavailable in the modeled language;
- type-system operations that cannot be represented faithfully by ordinary code.

Do not write an intrinsic merely because a library method is stateful.

## Source-model migration

A source model uses the same `TsUnknownCallModel` object and the same ID, target, successor, and residual contract.
The source-model work in PR #380 should extend a successor completion with the EtsIR entry point and resolved inputs,
make the model's EtsIR files visible in the analysis scene, and enter that method through the regular interpreter.
Receiver binding, arguments, returns, exceptions, heap changes, aliases, and nested calls then use normal interpreter
semantics. They must not be reimplemented in a source-specific dispatcher or backend registry.

The model checks its supported domain before entering EtsIR. An unsupported call returns `null`; a guarded supported
subdomain uses the complementary residual guard and the same configured fallback. Recursive redirection is prevented
by tracking the active model ID in execution state, not by creating a second catalog.

`Array.pop` is the source-model example. Its TypeScript body uses indexing and `length`; it must not call `pop` again.
The existing `Array.shift` intrinsic remains the example for engine-only symbolic-memory `memcpy`.

## Dynamic receivers

A method name does not prove the receiver type. In particular, `value.shift()` may call a user-defined property rather
than `Array.prototype.shift`.

Instance calls share receiver normalization before built-in approximations and ordinary method lookup. It reuses
`extractValue` to select a fake payload together with its kind constraint and `splitUHeapRef` to retain conditional
reference guards. Each feasible alternative continues through the existing virtual-call statement. This preserves
supported primitive calls such as `valueOf` and the existing `toString` approximation, while null and undefined
receivers take the property-access exception path. Other primitive calls use `NON_REFERENCE_RECEIVER` fallback.
Receiver normalization does not make the existing built-in approximations exact.

Use this decision rule after normalization:

| Receiver knowledge | Action |
| --- | --- |
| Definitely the modeled built-in receiver type | Apply the model. |
| Definitely another type | Return `null`; use fallback. |
| Possibly the modeled type, with a trustworthy built-in target | Use a type guard and residual complement. |
| `any`/unknown without proof of the built-in target | Return `null`; use fallback. |

Never choose `typeStreamOf(receiver).firstOrNull()` as proof. It returns one possible type, not necessarily the only
possible type. Use a statically proven type, `singleOrNull()` where uniqueness is guaranteed, or an explicit symbolic
type guard.

## Fingerprints

The catalog sorts enabled models by ID and hashes their length-prefixed IDs. Therefore model registration order does
not affect the fingerprint and ambiguous concatenations cannot collide merely because of ID boundaries.

The fingerprint identifies the frozen enabled model set for one run. It is not a version and must not be used as a
manually maintained configuration value. Experiment metadata records the tool revision separately. If model source
can change independently of that revision, the runner also records a content hash for the external source or generated
artifact; that content identity is experiment metadata, not another model ID, version, or compatibility setting. Keep
the catalog fingerprint based only on enabled model IDs rather than adding implementation-specific fingerprint fields
to the common model contract.

## Observation

Every completed model or fallback decision produces `TsUnknownCallEvent` through `TsInterpreterObserver.onUnknownCall`.
A model event is emitted once after all satisfiable successor callbacks complete. If a callback throws, dispatch does
not report success for the discarded step. A partially supported call may report both a model and a fallback event.

- `ModelApplied(modelId)` identifies the semantic model.
- `ResidualFallback(policy)` records the effective fallback.
- `event.outcome` is derived from the decision and is not stored as a second independent value.

Observer failures are logged and cannot alter symbolic exploration.
55 changes: 33 additions & 22 deletions usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,46 @@ import org.usvm.UExpr
import org.usvm.machine.expr.TsUnresolvedSort
import org.usvm.machine.interpreter.TsStepScope
import org.usvm.machine.state.TsMethodResult
import org.usvm.machine.state.TsState
import org.usvm.machine.types.mkFakeValue

fun mockMethodCall(
scope: TsStepScope,
method: EtsMethodSignature,
resultType: EtsType = method.returnType,
) {
val result = makeFreshUnknownCallResult(scope, resultType)

scope.doWithState {
val result: UExpr<*>
if (resultType is EtsVoidType) {
result = ctx.mkUndefinedValue()
} else {
val sort = ctx.typeToSort(resultType)
result = when (sort) {
is UAddressSort -> makeSymbolicRefUntyped()

is TsUnresolvedSort -> scope.calcOnState {
mkFakeValue(
scope = scope,
boolValue = makeSymbolicPrimitive(ctx.boolSort),
fpValue = makeSymbolicPrimitive(ctx.fp64Sort),
refValue = makeSymbolicRefUntyped(),
)
}

else -> makeSymbolicPrimitive(sort)
}
}

methodResult = TsMethodResult.Success.MockedCall(result, method)
setMockMethodCallResult(method, result)
}
}

/** Stores a prepared opaque result on this state without applying callee effects or exceptions. */
internal fun TsState.setMockMethodCallResult(
method: EtsMethodSignature,
result: UExpr<*>,
) {
methodResult = TsMethodResult.Success.MockedCall(result, method)
}

/** Creates a fresh opaque result through [scope], keeping solver models consistent with new constraints. */
internal fun makeFreshUnknownCallResult(
scope: TsStepScope,
resultType: EtsType,
): UExpr<*> = scope.calcOnState {
if (resultType is EtsVoidType) return@calcOnState ctx.mkUndefinedValue()

when (val sort = ctx.typeToSort(resultType)) {
is UAddressSort -> makeSymbolicRefUntyped()

is TsUnresolvedSort -> mkFakeValue(
scope,
boolValue = makeSymbolicPrimitive(ctx.boolSort),
fpValue = makeSymbolicPrimitive(ctx.fp64Sort),
refValue = makeSymbolicRefUntyped(),
)

else -> makeSymbolicPrimitive(sort)
}
}
Loading
Loading