Skip to content

platform: the dispatch envelope is a core table in every language, not a Java-only one - #474

Merged
swapnilpaliwal-sd merged 1 commit into
mainfrom
cha-dispatch-candidates
Sep 14, 2026
Merged

swapnilpaliwal-sd merged 1 commit into
mainfrom
cha-dispatch-candidates

Conversation

@swapnilpaliwal-sd

@swapnilpaliwal-sd swapnilpaliwal-sd commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #471. Stacks on nothing, based directly on main (d54837c9, which is #453 merged).

The gap

call_edges says what the engine concluded. Nothing in the bundle said what the hierarchy
admitted, the set those edges were narrowed from, except in Java. The two core tables
that carry it were empty by construction in the other two front ends, measured on real runs:

java typescript python
overrides 7,751 / 2,541 / 163 0 0
type_instantiated 3,906 / 306 / 263 0 207

schema_notes declared the emptiness in prose, so nothing lied. But bundle-test.sh asserts
the DDL of every non-ext table byte-identical across the three languages, and that assertion
actively hid this: the contract was identical in shape and not in content. Recovering the
fan meant dropping to ext_implementors (2 cols, type-level), ext_structural_implementor
(2 cols, type-level) or ext_mro_position (4 cols, positional), three different queries for
one question, none of which reaches a method.

None of it was missing analysis. It was computed and not projected.

What this adds

dispatch_candidates(base_method_id, candidate_method_id, basis)
fed by basis
java virtual_override, as-is nominal
typescript implementors / structural_implementor × declared_method, joined on the member name nominal, structural
python type_ancestor reversed × mro_lookup mro

plus TypeScript's missing RTA set, one projection over a relation that already held the answer:
type_instantiated(t, "new") :- new_expression_type(_, _, t).

overrides stays as the Java-shaped alias it is, documented as dispatch_candidates filtered
to basis = nominal. A new canonical query dispatch_envelope_of(:qualified_name) joins the
two tables and returns, per candidate, whether its owner is in the RTA set, the column that
lets a consumer narrow the envelope itself.

Reading mro_lookup, not type_defines_method

On the diamond fixture (A; B(A); C(A); D(B, C)) the Python rule yields:

mro  main.C.shared -> main.B.shared

A call resolving to C.shared runs B.shared on a D receiver, because D linearises
[D, B, C, A]. B is not a subtype of C, no "the subtype declares this name" rule
reaches that pair. That row is the whole reason the basis is called mro.

A constructor is not dispatched, and my first rule paired them

new Shape() runs Shape's constructor and never Circle's, however Circle extends Shape. The
first TypeScript rule paired them anyway, because a subclass constructor is a member of the
subclass with the same escaped name. Measured on 01-class-dispatch-and-super: 2 of 5 pairs
were Shape.<constructor> -> Circle.<constructor> and -> Square.<constructor>
, edges no
run can take. Guarded. That case now reads:

nominal  shapes#Shape.area   -> shapes#Circle.area
nominal  shapes#Shape.area   -> shapes#Square.area
nominal  shapes#Shape.prefix -> shapes#Circle.prefix

three constructors declared in the file, none of them present. Java's virtual_override does
not have this defect (checked on two hierarchy cases). Python's __init__/__new__ are
excluded for the same reason: to Python's attribute machinery __init__ is an ordinary MRO
attribute, and mro_lookup cannot know the constructed class is written at the site.

Two departures from the issue

  • mro_lookup is not exported. The issue proposed it; once dispatch_candidates exists
    nothing would query it, and it is (types × visible names × depth), an unmeasured bundle-size
    cost. ext_mro_position still carries the raw linearisation.
  • basis is a string literal in a rule, which Python's literal gate refuses unless it is a
    declared language fact. It is not one, it is an output vocabulary term, written and never
    joined on, exactly the category of call_unresolvable and site_reason, so
    method_dispatch_candidate joins REASON_HEADS rather than the allowlist being widened.

Tests

Nothing downstream consumes this relation, so a rule that stopped emitting it would move no
other golden and no test would notice, which is how it stayed Java-only. Two layers read it now.

A per-case .envelope golden in all three suites (16 java, 18 typescript, 3 python).
Hash-free, so it is portable; the case's library IR is loaded so a library-declared base is
named (lib:dep.Strategy.run -> app.ExtImplA.run) rather than printing as an anonymous
lib:?; and the declaration line is appended where a qualified name is shared, because Java's
enum-constant fan prints three distinct methods under one name.

bundle-test.sh asserts the envelope and the RTA set are non-empty in every language,
inside the per-language loop rather than once outside it, an assertion that runs once passes
on Java and never looks. It discriminates: reverting the TypeScript and Python halves produces
12 failures, and Java passes either way, which is the asymmetry itself. TypeScript's fixture
also carries a duck type nothing constructs, so owner_instantiated is asserted to return both
values rather than being constant.

Verification

  • java 39/39, typescript 53/53, python 15/15, all clean, no existing golden moved
  • bundle: ok (3 languages, one schema across them, canonical queries run, schema doc current)
  • Java's envelope equals virtual_override row-for-row on every case, it is a projection
  • trial-merged into reshape (reshape: parser/ (subtree) + graph/ + bin/axiom-graph. One repository for the whole pipeline #469): every file lands in graph/ by rename detection, one
    trivial conflict (reshape renamed the bundle's CSV dir graph/ → csv/), and the bundle
    test passes there

…t a Java-only one

`call_edges` says what the engine CONCLUDED. Nothing in the bundle said what the hierarchy
ADMITTED — the set those edges were narrowed from — except in Java, and the two tables that
carried it were empty by construction in the other two front ends:

    overrides           java 7751 / 2541 / 163 rows on real projects   typescript 0   python 0
    type_instantiated   java 3906 / 306 / 263                          typescript 0   python 207

`schema_notes` declared the emptiness in prose, so nothing lied. But bundle-test.sh asserts
the DDL of every non-ext table byte-identical across the three languages, and that assertion
actively hid this: the contract was identical in SHAPE and not in CONTENT. A consumer opening
two bundles and running one query got a full answer from one and silence from the other, with
no error. Recovering the fan meant dropping to ext_implementors (2 cols, type-level),
ext_structural_implementor (2 cols, type-level) or ext_mro_position (4 cols, positional) —
three different queries for one question, none of which reaches a method.

None of this was missing analysis. It was computed and not projected. So:

    dispatch_candidates(base_method_id, candidate_method_id, basis)

    java        virtual_override, as-is                                    basis nominal
    typescript  implementors / structural_implementor x declared_method,
                joined on the member name                                  basis nominal, structural
    python      type_ancestor reversed x mro_lookup                        basis mro

and TypeScript's missing RTA set, one projection over a relation that already held it:

    type_instantiated(t, "new") :- new_expression_type(_, _, t).

`overrides` stays as the Java-shaped alias it is, documented as dispatch_candidates filtered
to basis = nominal. `basis` is in the vocabulary per language, so a consumer that trusts only
declarations filters `structural` out; `dispatch_envelope_of(:qualified_name)` joins the two
new tables and returns, per candidate, whether its owner is in the RTA set — the column that
lets a consumer narrow the envelope itself.

READING mro_lookup, NOT type_defines_method, is what makes the Python set honest. On the
diamond fixture (A; B(A); C(A); D(B,C)) it yields `C.shared -> B.shared`: a call resolving to
C.shared runs B.shared on a D receiver, because D linearises [D, B, C, A]. B is not a subtype
of C, so no "the subtype declares this name" rule reaches that pair.

A CONSTRUCTOR IS NOT DISPATCHED, and the first TypeScript rule paired them anyway — a subclass
constructor is a member of the subclass with the same escaped name. MEASURED on
01-class-dispatch-and-super: 2 of 5 pairs were `Shape.<constructor> -> Circle.<constructor>`
and `-> Square.<constructor>`, edges no run can take. Guarded on both sides. Java's
virtual_override does not have this defect (checked on two hierarchy cases); Python's
__init__/__new__ are excluded for the same reason, since to Python's attribute machinery
__init__ IS an ordinary MRO attribute and mro_lookup cannot know the constructed class is
written at the site.

The issue also proposed exporting mro_lookup. It is not exported: once dispatch_candidates
exists nothing would query it, and it is (types x visible names x depth) — an unmeasured
bundle-size cost. ext_mro_position still carries the raw linearisation.

`basis` is a string literal in a rule, which Python's literal gate refuses unless it is a
declared language fact. It is not one — it is an output vocabulary term, written and never
joined on, exactly the category of call_unresolvable and site_reason — so
method_dispatch_candidate joins REASON_HEADS rather than the allowlist being widened.

TESTS. Nothing downstream consumes this relation, so a rule that stopped emitting it would
move no other golden and no test would notice — which is how it stayed Java-only. Two layers
now read it:

  - a per-case `.envelope` golden in all three suites (16 java, 18 typescript, 3 python),
    hash-free so it is portable, with the case's library IR loaded so a library-declared base
    is named rather than printing as an anonymous `lib:?`, and with the declaration line
    appended where a qualified name is shared (Java's enum-constant fan prints three distinct
    methods as one name);
  - bundle-test.sh asserts the envelope and the RTA set are NON-EMPTY IN EVERY LANGUAGE,
    inside the per-language loop rather than once outside it — an assertion that runs once
    passes on Java and never looks. It discriminates: reverting the TypeScript and Python
    halves produces 12 failures, and Java passes either way, which is the asymmetry itself.

THE TIERS ARE CONSISTENT IN SHAPE AND WERE NOT IN THEIR CONTRACT. All three front ends assign
the tier by identical logic (target count 1 -> known_edge, >=2 -> multi_inferred, 0 -> boundary
or ambiguous), and the four shared tiers exist everywhere. Two things were nonetheless wrong in
the schema, both found while wiring this table up:

  - `multi_inferred` was described as "virtual dispatch over INSTANTIATED subtypes". That is RTA
    language, and type_instantiated is read in a rule body in PYTHON ONLY -- Java and TypeScript
    compute and export it and never consume it, so their fans are CHA-wide, bounded by the
    dispatch cap. The shared description now says only what is true everywhere, and three
    per-language notes say how wide the fan actually is. The engine-side question is #473.
  - The three language-specific tiers sit on OPPOSITE sides of the trust line and neither is in
    the shared set: Java's ambiguous_anon is a blind spot, TypeScript's ambient_terminal and
    intrinsic_terminal are RESOLVED single-target edges. So `tier IN (known_edge,
    multi_inferred)` -- the obvious filter, and the one the shipped blast_radius query used --
    silently drops resolved edges in TypeScript and nowhere else. A note on call_edges states
    the resolved set and the blind-spot set per language, and blast_radius now traverses the
    resolved terminals too.

Fixes #471.
@swapnilpaliwal-sd swapnilpaliwal-sd added the enhancement New feature or request label Sep 14, 2026
@swapnilpaliwal-sd
swapnilpaliwal-sd merged commit 69a0383 into main Sep 14, 2026
@swapnilpaliwal-sd
swapnilpaliwal-sd deleted the cha-dispatch-candidates branch September 14, 2026 03:08
swapnilpaliwal-sd added a commit that referenced this pull request Sep 14, 2026
Rename detection carried every edited file from src/ to graph/ on its own. One conflict,
in the bundle test: reshape renamed the bundle's CSV output directory graph/ -> csv/
(the repository now HAS a top-level graph/), and #474 added dispatch_candidates to the
same core-table loop. Both, not either.

The new files — 37 .envelope goldens and test/tools/envelope_report.py — needed placing
by hand: git can infer where an EDITED file went when its directory was renamed, but not
where a file created on the other branch belongs.

Verified on the merged tree: bundle-test.sh passes (3 languages, one schema across them,
canonical queries run, schema doc current), the Python literal gate passes, all 37 goldens
are under graph/test/*/expected, and the three rule files carry method_dispatch_candidate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The dispatch envelope is Java-only: two core tables ship empty in TypeScript and Python although the engine computes them

1 participant