Skip to content

multi_inferred means a wider set in Java and TypeScript than in Python: the instantiation set is computed and never read #473

Closed

Description

@swapnilpaliwal-sd

What is inconsistent

multi_inferred is documented as one thing across the three front ends, "a sound SET of
possible targets", and the tier is assigned by identical logic everywhere (call_target_count
1 → known_edge, ≥2 → multi_inferred). What differs is how wide the set is, because only
one front end narrows it by the instantiation set:

python      type_instantiated_reachable is read in 4 rule bodies:
              resolution/dispatch.dl:58, :68
              resolution/annotations.dl:82
              expression-resolution/callee-resolution.dl:114
java        type_instantiated  — computed, exported, NEVER read by any rule
typescript  type_instantiated  — computed, exported, NEVER read by any rule

So a Java or TypeScript multi_inferred fan is CHA-wide: every override the hierarchy
admits, bounded only by dispatch_cap. A Python fan of the same shape is RTA-narrowed, a subtype the program never constructs is not in it.

Why it matters

The two are not the same claim. Given interface Handler with five implementors of which the
program constructs two, Java answers five and Python answers two. Both are sound; the Java
answer is three edges wider than the code can take, and a consumer comparing precision across
two bundles is comparing two different analyses under one label.

It also shows up as measurable imprecision. On a real Java project, type_instantiated holds
3,906 rows and virtual_override 7,751, the instantiation set exists, is the right size to
bite, and is discarded.

What the fix is not

This is NOT "make Java match Python". RTA narrowing is only sound when the instantiation set is
complete for the analysed scope, and the three front ends do not have the same guarantee:

  • a Java type can be constructed reflectively, by a framework, or in a dependency whose IR is
    not staged, type_instantiated covers new, anonymous placements, enum constants and
    ServiceLoader providers, and nothing else;
  • a TypeScript class can be constructed inside a .d.ts-declared library whose body is absent;
  • Python's narrowing already carries an ext_self_dispatch_assumes_closed_world assumption row
    for exactly this reason, and it is the honest precedent: narrow, and DECLARE the assumption.

So the question is per language: is the instantiation set complete enough to narrow with, and
if it is, what assumption row records the closed-world step?

Suggested shape

  1. Decide per front end whether to narrow, and gate it behind a flag rather than changing the
    default silently, the goldens will move, and the movement is the measurement.
  2. Where narrowing is applied, emit an assumption row in the same style as
    self_dispatch_assumes_closed_world, so the bundle records that the fan was narrowed on a
    closed-world premise rather than the consumer having to know.
  3. A/B on every measured codebase: report edges lost, and how many of the lost edges the
    language's oracle says are real. A narrowing that drops a real edge is not a precision win.

Meanwhile

The gap is now documented rather than silent: schema_notes on call_edges states per language
whether the fan is narrowed, and dispatch_candidates + type_instantiated (both populated in
all three since #471) let a consumer do the narrowing itself, that is what the
dispatch_envelope_of canonical query's owner_instantiated column is for. This issue is about
the ENGINE doing it, not the consumer.

Activity

  1. swapnilpaliwal-sd commented on Sep 19, 2026

    @swapnilpaliwal-sd
    ContributorAuthor

    Re-checked at e0167dea. The asymmetry is unchanged, and one count in the filing has moved the wrong way, so recording the current state before anything is decided:

    python      type_instantiated_reachable appears in 5 rule BODIES (was 4 when filed)
                  resolution/dispatch.dl:58                (positive)
                  resolution/dispatch.dl:68                (NEGATED)
                  resolution/annotations.dl:82
                  expression-resolution/expr-type.dl:91
                  expression-resolution/callee-resolution.dl:114
    java        type_instantiated appears in 5 clauses, ALL of them HEADS
                  resolution/instantiation.dl:20, :23, :26
                  config-resolution/services.dl:54, :55
                no rule body reads it
    typescript  type_instantiated appears in 1 clause, a HEAD
                  resolution/reference-types.dl:545
                no rule body reads it
    

    So the label still covers two different analyses: a Java or TypeScript multi_inferred fan is CHA-wide, a Python fan of the same shape is RTA-narrowed, and dispatch_cap is the only thing bounding the first two.

    What is NOT being changed here, and why

    This issue asks for the engine to narrow, per language, behind a flag, with an assumption row, and A/B'd on every measured codebase. I am leaving all four of those undone rather than doing them partially, for a reason that is worth writing down: the A/B is the deliverable, not the rule. RTA narrowing REMOVES targets from a set that is currently sound, so the measurement the issue asks for — "report edges lost, and how many of the lost edges the language's oracle says are real" — is the only thing that can distinguish a precision win from a silent recall loss, and it has to be run on both front ends. A rule landed ahead of that measurement is a guess with a flag on it.

    One thing has changed, in the other direction

    #428 is now fixed (PR #1043) by a rule that reads type_instantiated in a TypeScript rule BODY for the first time — structural_implementor now readmits a structurally conforming class when the program CONSTRUCTS it, where before any nominal implements in the codebase dropped every structural conformer entirely.

    Note the direction, because it is the opposite of this issue's: that reads the instantiation set to widen soundly (a class nothing constructs cannot receive a dispatched call, so requiring instantiation admits the real target and excludes nothing a run can take), where this issue asks about reading it to narrow, which needs the closed-world premise Python declares with ext_self_dispatch_assumes_closed_world and neither Java nor TypeScript currently has grounds for.

    It does mean the set is no longer wholly unread in TypeScript, so the "computed and never read" framing above is now true of Java only, and true of TypeScript only for the narrowing direction.

  2. swapnilpaliwal-sd commented on Sep 29, 2026

    @swapnilpaliwal-sd
    ContributorAuthor

    Fixed by #1788 (merged into 0.1.9).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions