--- 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`, `Wrapper`, `Pair`, `ImmutableList`. - 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)`) 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 `-classes.txt` and `-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` show as `runtime stable`?" ```kotlin // Source — the developer's class, in a library module class Box(val value: T) // Call site, in app module @Composable fun BoxRow(box: Box) { Text(box.value) } ``` The compiler walks `Box`: 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` 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) ``` 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) // WRONG because: List 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) ``` `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` block skipping but `ImmutableSet` 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) { /* ... */ } // WRONG because: Set is an interface — phase 11 returns Unknown, the call site is non-skippable. ``` ```kotlin // RIGHT @Composable fun TagRow(tags: ImmutableSet) { /* ... */ } ``` ### 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` uses an Int bitmask where bit `i` set means `Ti` participates in stability: | Type | Bitmask | Reading | |---|---|---| | `kotlin.Pair` | `0b11` | both A and B affect stability | | `kotlin.Triple` | `0b111` | all three affect stability | | `kotlinx.collections.immutable.ImmutableList` | `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`, `List`, `Map` 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.