Use this skill to explain why the Compose compiler classified a class or composable parameter as stable, runtime, unknown, or unstable. Covers the 12-phase inference algorithm, the five compiler-level stability types (Certain / Runtime / Unknown / Parameter / Combined), the generic bitmask encoding (Pair=0b11, ImmutableList=0b1), the Known Stable Constructs registry, and the runtime `$stable: Int` field generated by `@StabilityInferred`. Use when the developer asks "why is X classified as Y?"...
Install via CLI
openskills install skydoves/compose-performance-skills---
name: understanding-stability-inference
description: 'Use this skill to explain why the Compose compiler classified a class or composable parameter as stable, runtime, unknown, or unstable. Covers the 12-phase inference algorithm, the five compiler-level stability types (Certain / Runtime / Unknown / Parameter / Combined), the generic bitmask encoding (Pair=0b11, ImmutableList=0b1), the Known Stable Constructs registry, and the runtime `$stable: Int` field generated by `@StabilityInferred`. Use when the developer asks "why is X classified as Y?", when a stability report shows a surprising `runtime stable`, `unknown`, or `unstable` verdict, when generics, inheritance, cycles, interfaces, or cross-module classes are involved, or when the user mentions `$stable`, `@StabilityInferred`, separate compilation, or "the compiler thinks my class is unstable but it looks fine".'
license: Apache-2.0. See LICENSE for complete terms.
metadata:
author: Jaewoong Eum (skydoves)
keywords:
- jetpack-compose
- performance
- stability
- stability-inference
- compose-compiler
- runtime-stability
- stability-inferred
- generics-stability
- separate-compilation
- known-stable-constructs
---
# Understanding Stability Inference — read the compiler's mind
Stability is decided by a 12-phase algorithm baked into the Compose compiler. This skill teaches Claude how the algorithm walks a type so it can explain *why* a report says what it says, and predict classifications before the report is even generated. Pair this with `../diagnosing-compose-stability/SKILL.md` (which generates the report) and `../stabilizing-compose-types/SKILL.md` (which fixes obvious unstable types). Reach for this skill when the simpler skills produce a verdict that surprises the developer.
## When to use this skill
- The developer asks "why is `Foo` classified as `runtime stable` and not `stable`?"
- A report shows `runtime` or `unknown` for a class that "looks fine".
- Generics involved: `Box<T>`, `Wrapper<A, B>`, `Pair<String, Int>`, `ImmutableList<User>`.
- The class lives in another module and ships as a `.class`/`.kotlin_metadata` artifact.
- The developer asks about the `$stable: Int` field, `@StabilityInferred`, or cross-module classification.
- A self-referential type (`class Node(val children: List<Node>)`) is unstable for non-obvious reasons.
- A Java type, an interface, or an abstract base appears in a parameter list and surprises the developer.
## When NOT to use this skill
- The fix is mechanical (`var` → `val`, `List` → `ImmutableList`, `Flow` parameter removal). Use `../stabilizing-compose-types/SKILL.md`.
- No report exists yet. Run `../diagnosing-compose-stability/SKILL.md` first.
- The developer wants CI enforcement of stability. Use `../enforcing-stability-in-ci/SKILL.md`.
## Prerequisites
- Compose Compiler reports already generated, or at least one `<module>-classes.txt` and `<module>-composables.txt` available in `build/compose_compiler/`.
- The developer understands the basic stability vocabulary: `stable`, `unstable`, `skippable`, `restartable`, `@Stable`, `@Immutable`.
- Kotlin 2.0+ with the Compose compiler plugin (Strong Skipping default ON).
## Workflow — diagnostic question and answer tree
Walk the type through the same 12 phases the compiler does. For each call site, ask the questions in order; the first matching phase wins.
The canonical phase order (used everywhere in this skill and matching `references/twelve-phase-algorithm.md`):
1. **Phase 1 — primitive / `String` / function / `Unit` fast path → `Stable`.**
The compiler returns immediately. No field analysis runs. Mention the fast path so the developer knows nothing else was inspected.
2. **Phase 2 — type parameter substitution.**
A bare type variable `T` becomes `Stability.Parameter(T)`; resolution is deferred to the call site that substitutes it.
3. **Phase 3 — nullable unwrap (`Int?` → analyze `Int`).**
Nullability does not change stability; the algorithm strips the `?` and recurses.
4. **Phase 4 — inline class — check underlying type.**
`value class Wrapper(val raw: T)` is exactly as stable as `T`.
5. **Phase 5 — cycle detection (recursive trees → conservative UNSTABLE).**
The algorithm bails on cycles to guarantee termination. The escape hatch is `@Stable`/`@Immutable` on the recursive class, which fires in phase 6 before phase 5 is reached.
6. **Phase 6 — annotations check (`@Stable`, `@Immutable`, `@StableMarker`).**
Yes → `Stability.Certain` (stable). `@Immutable` enables additional optimizations beyond `@Stable` because the compiler can promote reads of properties to static expressions and elide equality checks; `@Stable` only promises change notification.
7. **Phase 7 — Known Stable Constructs registry hit** (`Pair` / `Triple` / `Result` / `ImmutableList` / `dagger.Lazy` / `ClosedRange` / etc.).
Returns `Stability.Parameter` with the registry's bitmask. See `references/bitmask-encoding.md` for the full registry.
8. **Phase 8 — external configuration match** (`stability_config.conf`).
Returns `Stability.Parameter` with the bitmask declared in the config file.
9. **Phase 9 — external module (`@StabilityInferred` annotation generated by separate compilation).**
Returns `Stability.Runtime`. The compiler emits a `$stable: Int` field on the JVM (a mangled top-level property on Native/JS) that the **runtime** queries via `Composer.changed`. Tell the developer this is **not a bug** — `runtime` is the compiler saying "I cannot prove this at compile time, so I will check at runtime".
10. **Phase 10 — Java type (default UNSTABLE — fix via config file).**
Java `final` fields look like `var` to the inference because the algorithm has no Kotlin metadata to read. Fix via `stabilityConfigurationFiles`, not by editing the Java source.
11. **Phase 11 — interface (UNKNOWN; runtime `===`).**
The compiler cannot enumerate implementations from a single call site; the runtime falls back to identity (`===`) for the equality probe.
12. **Phase 12 — field-by-field analysis** (the slow path):
- Walks the **linearized class hierarchy** so inherited fields participate. There is no separate "inheritance" phase — inheritance lives here.
- Any `var` property → `Unstable` (mutation observed without Snapshot integration).
- Any property whose type is `Unstable` → `Unstable` (Combined dominates).
- Otherwise the class is `Stable` (Combined of all-stable fields collapses to Stable).
For full pseudocode of all 12 phases plus the field-by-field loop, see `references/twelve-phase-algorithm.md`.
## Patterns
### Pattern: "Why does `Box<String>` show as `runtime stable`?"
```kotlin
// Source — the developer's class, in a library module
class Box<T>(val value: T)
// Call site, in app module
@Composable fun BoxRow(box: Box<String>) { Text(box.value) }
```
The compiler walks `Box<T>`:
1. Phase 12 (field-by-field) inside the defining module finds one field `value: T`; recursion on `T` hits phase 2 (type parameter) → `Stability.Parameter`. Combined collapses to `Stability.Parameter` with bitmask `0b1` (the single type parameter affects stability).
2. Because `Box` is consumed from a different module, the compiler emits `@StabilityInferred(parameters = 0b1)` on `Box` and a `$stable: Int` field initialized from `T`'s stability at runtime. Downstream call sites pick this up via phase 9.
3. Call-site substitution → `T = String` → String is Certain Stable → bit 0 satisfied.
4. Final report line: `runtime stable class Box<T>` and at the call site `BoxRow` is `skippable`.
```kotlin
// WRONG mental model
// "runtime stable means there is a runtime cost on every recomposition" — partly true but misleading
// WRONG because: the cost is one Int field load and a bitwise AND, performed once when the runtime
// computes the call-site stability. It is far cheaper than the unskipped recomposition it prevents.
```
```kotlin
// RIGHT mental model
// runtime stable = "the compiler proved stability conditional on the type arguments, and emitted
// a $stable: Int field whose bits the runtime ANDs against the substituted argument stabilities".
// The skip decision is still made; it is just made at runtime instead of compile time.
```
### Pattern: "Why does `@Immutable data class Person(val name: String)` enable more optimizations than `@Stable`?"
`@Stable` is a contract: "I will notify Compose of changes". `@Immutable` is a stronger contract: "I will never change". With `@Immutable` the compiler may promote reads of `Person.name` to **static expressions** and elide equality probes for nested usages; with `@Stable` it must still emit equality checks. Both classify as `Stability.Certain`, but the downstream optimizer treats `@Immutable` more aggressively.
```kotlin
// WRONG
@Stable data class Coordinates(val lat: Double, val lng: Double)
// WRONG because: Coordinates never mutates after construction. @Stable understates the contract
// and forfeits static-expression promotion at every read site.
```
```kotlin
// RIGHT
@Immutable data class Coordinates(val lat: Double, val lng: Double)
```
### Pattern: "Why is my recursive tree unstable even though it looks fine?"
```kotlin
data class Node(val id: String, val children: List<Node>)
```
Phase 5 (cycle detection) bails. The compiler does not attempt fixed-point analysis because it would have to assume the answer to prove the answer. The conservative verdict is **Unstable**. Even if every field is otherwise stable, the recursion through `children` returns Unstable to the parent call.
```kotlin
// WRONG — adds @Stable to "force" stability
@Stable data class Node(val id: String, val children: List<Node>)
// WRONG because: List<Node> is a mutable interface backed by ArrayList in practice. The @Stable
// annotation tells the compiler to trust the contract, but the actual List instance can mutate
// between recompositions without notifying Compose, producing silent missed recompositions.
```
```kotlin
// RIGHT
import kotlinx.collections.immutable.ImmutableList
@Immutable data class Node(val id: String, val children: ImmutableList<Node>)
```
`ImmutableList` is in the **Known Stable Constructs registry** (phase 7) with bitmask `0b1`, so the recursion through `children` is permitted: cycle detection still triggers in phase 5, but the registry hit short-circuits the conservative verdict.
### Pattern: "Why does `Set<String>` block skipping but `ImmutableSet<String>` doesn't?"
`kotlin.collections.Set` is an interface (phase 11 → `Unknown`) backed in practice by `LinkedHashSet`, which mutates. `kotlinx.collections.immutable.ImmutableSet` is in the Known Stable Constructs registry with bitmask `0b1`, so it is `Stability.Parameter` and resolves to stable when the element type is stable.
```kotlin
// WRONG
@Composable fun TagRow(tags: Set<String>) { /* ... */ }
// WRONG because: Set is an interface — phase 11 returns Unknown, the call site is non-skippable.
```
```kotlin
// RIGHT
@Composable fun TagRow(tags: ImmutableSet<String>) { /* ... */ }
```
### Pattern: "Why is a class from another module `runtime stable` even when it has only `val`s?"
Separate compilation. At the time the call site compiles, the compiler does not have the full source AST of the dependency, only its `.class` files plus the metadata in `@StabilityInferred(parameters = ...)`. Phase 9 reads that annotation; the runtime resolves the bitmask against actual type arguments via the generated `$stable: Int` field. The classification is correct — there is no extra work to do — but it must be deferred to runtime because cross-module compile-time analysis is impossible without the source.
## Five compiler-level stability types
Cite these by name when answering "why" questions. The compiler stores stability as one of:
- **Stability.Certain** — primitives, String, Unit, function types, enums, `@Stable`/`@Immutable`-annotated classes. Decision is final and compile-time.
- **Stability.Runtime** — separately compiled class. Compile-time emits a `$stable: Int` field and `@StabilityInferred`; runtime ANDs the bits against actual type arguments.
- **Stability.Unknown** — interface, abstract class without concrete analysis, or anything the compiler refuses to commit on. Runtime falls back to `===` identity for the equality probe.
- **Stability.Parameter** — generic. Stability is a function of the type arguments via a bitmask.
- **Stability.Combined** — aggregate of multiple components (fields of a class, or multiple type arguments). **Unstable dominates** — any single Unstable component poisons the whole.
## Bitmask encoding (preview)
`Container<T1, T2, T3>` uses an Int bitmask where bit `i` set means `Ti` participates in stability:
| Type | Bitmask | Reading |
|---|---|---|
| `kotlin.Pair<A, B>` | `0b11` | both A and B affect stability |
| `kotlin.Triple<A, B, C>` | `0b111` | all three affect stability |
| `kotlinx.collections.immutable.ImmutableList<E>` | `0b1` | only E affects stability |
| `java.math.BigInteger` | `0b0` | no parameters; classified as stable regardless of erased type arguments |
The full rules — including how `@StabilityInferred(parameters = ...)` is generated for separately-compiled types and how the `$stable: Int` field is laid out on the JVM versus the mangled top-level property used on Kotlin/Native and Kotlin/JS — are in `references/bitmask-encoding.md`.
## Mandatory rules
- **MUST** teach the developer that `runtime stable` is not a bug or an unstable verdict — it is the compiler's way of saying "stability is conditional on type arguments and will be checked once at runtime via the `$stable: Int` field".
- **MUST NOT** suggest structural changes (changing `var` to `val`, swapping collection types) before explaining *why* the current structure is unstable. Diagnosis before treatment.
- **MUST** distinguish `Stability.Unknown` from `Stability.Unstable` when answering — `Unknown` means "cannot tell" and falls back to identity equality, `Unstable` means "proven unstable" and disables skipping outright.
- **MUST NOT** tell the developer to add `@Stable` to a type whose contract they cannot guarantee. A stability annotation is a contract; breaking it produces silent missed recompositions, which is worse than a non-skippable composable.
- **PREFERRED:** cite the Kotlin compiler source when depth helps. Concrete files: `Stability.kt` (the algebraic data type), `KnownStableConstructs.kt` (the registry), `ClassStabilityTransformer.kt` (the `$stable` field emission), and `StabilityConfigParser.kt` (the config-file reader).
- **PREFERRED:** when explaining a generic, walk the bitmask explicitly: "bit 0 of `Pair`'s bitmask is set, A=String is Certain Stable, satisfied; bit 1 is set, B=List is Unstable, fails — Combined collapses to Unstable".
## Verification
- [ ] Claude can predict, before running the report, whether a candidate type will be classified `Certain`, `Runtime`, `Unknown`, `Parameter`, or `Combined`.
- [ ] Claude can name which of the 12 phases produced the verdict.
- [ ] For a `runtime stable` class, Claude can explain that the compiler emitted `@StabilityInferred(parameters = ...)` on the class declaration and a `$stable: Int` field that the runtime ANDs against substituted type-argument stabilities.
- [ ] Claude refuses to recommend `@Stable` or `@Immutable` on a type whose mutation contract is not guaranteed.
- [ ] Claude correctly identifies `Set<T>`, `List<T>`, `Map<K, V>` as `Unknown` interfaces (not `Unstable`) when explaining why they block skipping.
## References
- Compose stability overview — https://developer.android.com/develop/ui/compose/performance/stability
- Stability — diagnose — https://developer.android.com/develop/ui/compose/performance/stability/diagnose
- Strong Skipping — https://developer.android.com/develop/ui/compose/performance/stability/strongskipping
- Ben Trengrove, "Jetpack Compose Stability Explained" — https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
- Ben Trengrove, "New ways of optimizing stability" — https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc
- Chris Banes, "Composable metrics" — https://chrisbanes.me/posts/composable-metrics/
- skydoves, "Optimize App Performance by Mastering Stability" — https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves/compose-stability-inference — https://github.com/skydoves/compose-stability-inference
- skydoves/compose-stable-marker — https://github.com/skydoves/compose-stable-marker
### Reference files
- `references/twelve-phase-algorithm.md` — pseudocode walkthrough of all 12 phases plus the field-by-field analysis pseudocode.
- `references/bitmask-encoding.md` — generic stability bitmask rules, the Known Stable Constructs registry, the `@StabilityInferred(parameters = 0b1)` annotation generated by the compiler, the runtime `$stable: Int` field on JVM, and the mangled top-level property approach on Native and JS.
No comments yet. Be the first to comment!