Skip to content

Bidirectional type inference: one engine for rules, hover and inlay hints - #413

Merged
MelbourneDeveloper merged 47 commits into
mainfrom
bidirectionaltype-inference
Aug 5, 2026
Merged

Bidirectional type inference: one engine for rules, hover and inlay hints#413
MelbourneDeveloper merged 47 commits into
mainfrom
bidirectionaltype-inference

Conversation

@MelbourneDeveloper

@MelbourneDeveloper MelbourneDeveloper commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Replaces the checker's text-matching type guesswork with a single bidirectional inference engine — one annotation cascade, one inference oracle, one subtyping table per module — shared by the rules and the LSP display surfaces, so a hovered type and a diagnostic can no longer disagree.

What Was Added?

Type-level evaluation (crates/basilisk-checker/src/tyeval/) — a normalization-by-evaluation engine for the type sublanguage, because Python's type hints are Turing-complete so recursive/parameterised aliases must be evaluated, not eagerly expanded:

  • term.rs — ground types, alias applications, kind Type → Type operator values, and conditional types as assignability-guarded rewrites.
  • accept.rs — GHC-style guardedness (contractivity) and regularity acceptance conditions.
  • eval.rs — lazy call-by-need unfolding to weak head normal form, fuel/depth bounded, memoized per application, with a Divergent fallback that projects to gradual Unknown so truncation NEVER invents an error.
  • lower.rs — total gradual lowering from Ruff AST type statements, string forward references included.
  • queries.rs — the memoized Salsa layer (type_alias_env, alias_whnf per (file, alias)).

Annotation cascade (crates/basilisk-checker/src/annotation/) — the checker's single annotation entry point. An annotation is a type expression, so resolving it is a name-resolution problem: alias table → same-file class table → import table → typeshed/builtins → forward reference. Aliases are transparent at every nesting depth regardless of declaration order. Replaces InferredType::from_annotation(<source text>).

Shared per-module type context (rules/shared/module_types.rs)ModuleTypes bundles the cascade, the oracle, and the subtyping table, built once by the driver and handed to rules via the new Rule::check_with_types. Each costs a module walk; a dozen rules building their own made those walks the dominant cost of checking a file.

expr_type::ModuleSpanTypes — the public span-indexed oracle for hover, inlay hints, and completions. Not a second inference algorithm: it wraps the same ModuleTypes/BidirEngine the rules judge with.

Type-torture benchmark corpus (benchmarks/torture/) — 12 cases targeting constructs that break other checkers, each stating the spec section or PEP that makes its expectations authoritative, plus run_torture.py and a committed-baseline scoreboard gate. Measured result: basilisk 12/12, mypy/pyrefly/zuban 11/12, pyright 10/12, ty 6/12.

New test suitesoracle_agreement_tests.rs, subtyping_context_routing_tests.rs, torture_golden_tests.rs, annotation_resolution_tests.rs, tyeval_salsa_tests.rs, assignment_call_synthesis_tests.rs, returns_call_synthesis_tests.rs, class_body_method_binding_tests.rs, decorator_resolution_tests.rs, mutation_kill_constructors_tests.rs, and others.

What Was Changed or Deleted?

Deleted outright (replaced, not deprecated — this repo keeps no legacy code):

  • collection_inference.rs and inference_flow_tests.rs/collection_inference_tests.rs — the second, display-only inference path. Displays now read the one oracle.
  • assignment_compatibility/literal_parse.rs — text-level literal parsing.
  • rules/shared.rs::is_numeric_subtype and 22 rule-local subtype shims across 11 rule files (generics_defaults_*, generics_variance_inference, aliases_implicit, generics_syntax_scoping, generics_typevartuple_callable, narrowing_typeis, callables_subtyping, overloads_evaluation, assignment_compatibility/*). Every rule-side subtype verdict now routes through SubtypingContext; name_subtype is the internal numeric tower only.
  • Resolver fields unconditional_assigns and top_level_return_name_refs, plus collect_unconditional_assigns, collect_try_assignments, collect_if_else_assignments, collect_top_level_return_name_refs — the divergence walker subsumes them.
  • basilisk-zed/languages/python/*.scm — shipping a languages/ dir registers a second language named "Python" and Zed's registry overwrites the built-in entry on a name collision, silently replacing bracket auto-close, f-string/docstring pairs, elif/else auto-dedent, shebang detection and the richer built-in queries. Now binds to Zed's built-in Python, matching how ty/pyrefly/pylsp do it.

Rewrittenrules/names_unbound.rs now tracks definite assignment over all paths with divergence supplied by the inference-driven walker (crate::narrow::stmt_diverges): a branch that provably never falls through (return, raise, a NoReturn-typed call, while True: without break) drops out of the merge instead of poisoning it. It abstains inside loop bodies, except handlers and finally blocks, where "bound" is genuinely path-dependent.

Bug fixnarrow::rebind::bound_names missed PEP 572 walrus targets, because a walrus binds from inside an expression and no statement shape reveals it. Fixed by exporting the resolver's existing collect_walrus_targets rather than adding a second visitor.

Ratchets moved the correct way — mutation baseline 138/145 → 154/161 caught, kill rate 100.0, missed 0.

How Do The Automated Tests Prove It Works?

  • Conformance: 141/141 files, 0 false positives, 0 missed, scored by a freshly cloned unmodified python/typing harness against a clean --release build. conformance_status.csv regenerated from the harness's own results/basilisk/*.toml — every row PASS.
  • oracle_agreement_tests.rs proves the central claim non-tautologically. displayed_type_is_a_type_the_checker_accepts asks the public display oracle what an expression is, then asserts the checker accepts value: <that type> = <expression> — across 11 fixtures including nested collections. a_disagreeing_type_is_rejected is the paired negative, so acceptance carries information rather than passing for an oracle that renders Any. call_results_agree covers the case the deleted display path could not type at all. literal_string_provenance_survives_display pins the PEP 675 hover regression (does not infer generic type #290).
  • names_unbound_tests.rs grew 4 → 22 tests, each pinning one divergence shape: diverging_else_branch_no_diagnostic, raising_else_branch_no_diagnostic, noreturn_call_in_else_no_diagnostic, walrus_in_if_test_no_diagnostic, plus the negatives that keep it honest — non_diverging_else_still_fires, elif_chain_without_else_fires, match_without_catchall_fires, walrus_inside_branch_body_fires.
  • The walrus fix was written test-first. walrus_targets_are_bindings_in_every_expression_position failed RED with {"d", "inner"} before the fix, covering if (a := f()), while (b := g()), print(c := h()) and a comprehension, and asserting a walrus inside a nested def is NOT collected.
  • Torture golden 12/12 with a committed-baseline scoreboard gate that fails on any basilisk regression.
  • Workspace: 7253 tests pass, 0 fail. Coverage 91.73% line / 92.34% region, every one of the 8 projects at or above its coverage-thresholds.json floor; VSIX 94% ≥ 93%; Neovim 44% ≥ 44%; Zed builds for wasm32-wasip2 with 97 tests green. cargo clippy --workspace --all-targets clean at full strictness.

Spec / Doc Changes

  • docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md[TYPEINF-TARGET-TYPELEVEL], [TYPEINF-ANNOTATION-RESOLUTION], [TYPEINF-TARGET-GRADUAL], [TYPEINF-LEGACY].
  • docs/plans/CHECKER-TYPE-NARROWING-INFERENCE-PLAN.md — checklist updated with measured evidence per item, including the items deliberately left open.
  • docs/specs/ZED-SPEC.md[ZED-TREESITTER] rewritten to document language reuse and why a languages/ dir is harmful.
  • docs/specs/RELEASE-MANUAL-VERIFICATION-SPEC.md (new), docs/INDEX.md, CHECKER-ARCHITECTURE-SPEC.md, plus book chapters 8 and 10 with their example projects.

Known follow-ups (not blocking review, stated for honesty)

  • Step 7 of the plan is partial. InferredType::from_annotation went from 11 call sites to 2; both survivors are in alias_match.rs and are documented in-code with the measured reason — migrating them regresses recursive-alias false positives, because the depth-limited matcher recurses on a self-reference leaf the cascade cuts to Unknown. The real retirement is deleting the matcher (Refs Valid recursive PEP 695 JsonValue alias rejected as circular #371), not forcing the call site. The RhsKind type-proxy tail (15 files) and slice_span tail remain.

  • rules/names_unbound.rs is 594 lines, over this repo's 500-LOC ceiling; the split into mod/scan/bindings is mapped but not applied.

  • There is a real ~20% cold-check slowdown vs main. Measured on a quiet machine, with the five pinned competitor binaries confirming machine conditions were within ±3% of main's run (pyright 607.8→624.2 ms, mypy 598.3→612.3, ty 51.8→51.3, pyrefly 159.4→160.8, zuban 40.8→41.2), Basilisk is slower on 26/26 fixtures — mean +20.2%, median +18.2%, from +8.9% (typevar_constraints) to +37.5% (overloads_evaluation). Basilisk remains the fastest tool on every fixture by a wide margin.

    I first suspected the ten rules that build their own subtyping::module_context, and measured that it is not the cause: module_context is O(classes), but the slowdown does not scale with class count. Fixtures with zero classes average +21.5% while fixtures with classes average +18.7%, and the single worst fixture (overloads_evaluation, +37.5%) has no classes at all. The cost is the engine itself — building the annotation cascade and the inference oracle once per module — which is the inherent price of replacing text-matching with real bidirectional inference. Sharing the context across those ten rules is still worth doing for duplication reasons (the Rule::check_with_types doc asks for it), but it is not the performance fix.

  • The local mutation run was interrupted to free the machine for benchmarking; CI runs the full sharded suite.

  • The benchmark page now carries an explicit caveat that figures are produced on a contributor's workstation and are indicative only, pending a move onto isolated CI hardware.

Follow-up commits on this branch

  • Removed the benchmark regression gate. It compared against a baseline this branch had itself committed from a loaded machine, so it passed the real 20% slowdown above while failing honest work depending on what else the workstation was doing. Deleted the Benchmark Ratchet CI job, the gate helpers in summarize.py (read_committed_baseline, find_regressions, parse_basilisk_ms), and the policy block in run.sh. Write-always is untouched. Spec ID CHKARCH-TESTING-BENCH-RATCHETCHKARCH-TESTING-BENCH across all 18 references, rewritten to say how to read the numbers rather than how to gate on them.
  • The conformance gate now proves suite freshness. --gate verifies the graded HEAD against a live git ls-remote of python/typing main and fails on mismatch, closing the hole where --ref, --suite-dir, or --reuse-clone could score 100% against an older tree. Fails closed when the remote is unreachable or the ref will not resolve. Five tests cover accept / stale / unreachable / empty-ref plus a wiring test that --gate cannot skip it. Verified live: gate suite verified: python/typing@a490662 is main tip → 141/141, 0 FP.
  • Split rules/names_unbound.rs (594 LOC) into names_unbound/{mod,scan,bindings}.rs at 173/302/160. Module path unchanged; all 22 tests and the full 53-binary checker suite pass.
  • Excluded benchmarks/torture/cases from ruff format. Those are line-scored fixtures: a one-line def f() -> int: return <bad> deliberately collapses two diagnostics onto the single # E line, and reformatting split them across an unmarked line, taking torture from 12/12 to 11/12.

Known unrelated CI failures

  • Neovim (×3)binary_spec.lua:364 asserts the latest GitHub release contains basilisk-x86_64-unknown-linux-gnu.tar.gz, but release v0.40.0 has zero assets attached. The test is correct; the release is empty. Needs assets published or an owner decision — not fixable in this PR.
  • VS Code (Linux) — 541 passing, 1 failing: binds one webview message handler across singleton re-renders, two 10 s pollUntil waits inside a 15 s timeout. Green on the prior commit and green on Windows for the same commit; a load-dependent webview-ready race. Not weakened to hide it.

Refs #285, #290, #371, #378

Breaking Changes

  • None

# Conflicts:
#	README-pypi.md
#	README.md
#	README.zh.md
#	crates/basilisk-checker/src/rules/names_undefined.rs
#	crates/basilisk-checker/tests/checker/names_undefined_tests.rs
#	crates/basilisk-resolver/src/scope/resolved_module.rs
#	crates/basilisk-resolver/src/scope/typeddict_meta.rs
#	docs/readme/README.src.md
#	docs/readme/README.zh.src.md
#	docs/specs/CHECKER-ARCHITECTURE-SPEC.md
#	vscode-extension/README.md
#	vscode-extension/README.zh.md
#	website/src/_data/conformance_report.json
#	website/src/_data/rules.json
Figures are produced by running make bench on a contributor's local
workstation, so background load shifts the whole table at once. State
that on the page and record that the benchmark is moving onto isolated
CI hardware, rather than presenting local numbers as authoritative.

Also records this run's measured status CSV.

ok = add(1, 2)
bad_type = add("1", 2) # E
bad_arity = add(1) # E
Comment thread book/scripts/test_book_contract.py Fixed
Comment thread benchmarks/torture/cases/param_inference.py Fixed
Comment thread benchmarks/torture/cases/scope_shadowing.py Fixed
Comment thread benchmarks/torture/cases/scope_shadowing.py Fixed
Comment thread benchmarks/torture/cases/typeddict_transitive.py Fixed
…formatting

The benchmark runs on a contributor's workstation against whatever else
that machine is doing. Background load moves every tool in the table
together and can shift absolute times by tens of percent between two runs
of identical code, so a pass/fail gate on that signal fails honest work
and waves through real regressions depending on what else was running.
Worse, a baseline recorded during a loaded run silently raises the bar for
every run compared against it afterwards.

Deleted: the Benchmark Ratchet CI job and its change-scope plumbing, the
gate helpers in summarize.py (read_committed_baseline, find_regressions,
parse_basilisk_ms), and the gate policy in run.sh. The write-always
behaviour is untouched: measured numbers still land in the status CSV
immediately and unconditionally. Renamed CHKARCH-TESTING-BENCH-RATCHET to
CHKARCH-TESTING-BENCH across all 18 references and rewrote the spec
section to state how the numbers should be read instead.

Also fixes two CI failures unrelated to the gate:
- Conformance docs and READMEs were stale; regenerated.
- ruff format: excluded benchmarks/torture/cases from formatting. Those
  are line-scored fixtures where a one-line 'def f() -> int: return <bad>'
  deliberately collapses two diagnostics onto one '# E' line; reformatting
  split them and put one on an unmarked line, failing the scoreboard.
The conformance gate exists so that a test upstream adds TODAY fails us
today. That only holds if the graded tree IS the current python/typing
main tip -- and three flags could reach an older one (--ref naming another
branch, --suite-dir pointing at an earlier checkout, --reuse-clone
re-entering one). Any of those would score 100% against yesterday's tests
and report a pass, which is exactly the failure the gate is meant to catch.

--gate now verifies the graded HEAD against a live 'git ls-remote' of
python/typing main and FAILS on a mismatch. It also fails closed when the
remote is unreachable or the ref does not resolve: an unverifiable score
is not a passing score. Five tests cover accept / stale / unreachable /
empty-ref, plus a wiring test asserting --gate cannot skip the check.

Verified end to end: 141/141 (100%), 0 false positives, with
'gate suite verified: python/typing@a490662 is main tip'.

Also splits rules/names_unbound.rs (594 LOC, over the 500 ceiling) into
names_unbound/{mod,scan,bindings}.rs at 173/302/160 -- rule entry and
traversal, the definite-assignment walk, and the pure binding collection.
Module path is unchanged. All 22 names_unbound tests and the full
basilisk-checker suite (53 binaries) pass.
@MelbourneDeveloper

Copy link
Copy Markdown
Collaborator Author

This PR unblocks the v0.40.0 release

The v0.40.0 release run (30991608586) failed at its first job, the conformance gate:

✗ 99% < 100% threshold
    FAIL enums_expansion.toml: Line 85: Unexpected errors
    ['enums_expansion.py:85:5: error: Type mismatch: `x` is annotated
      `Literal[Answer.Yes, Answer.No]` but assigned answer [assignment_compatibility]']
✗ 1 false positives > 0 ceiling
  files: 141 graded | 140 pass | 1 fail    score: 99%   false positives: 1

Every downstream job was skipped as a result — build wheels, publish binaries, PyPI, VSIX, Neovim, Zed, Homebrew. That single gate failure explains three separate symptoms:

  • v0.40.0 has 0 release assets (v0.39.0 and v0.38.0 each have 11).
  • PyPI is still on 0.39.0, so python/typing's uv.lock cannot pick up anything newer.
  • The Neovim CI leg fails on this PRbinary_spec.lua:364 asserts the latest release contains basilisk-x86_64-unknown-linux-gnu.tar.gz, and the latest release is the empty v0.40.0. The test is correct; the release is empty.

The fix is in this branch

That false positive is the enum-literal expansion bug (Refs #374). The fix, assignment_compatibility/enum_expand.rs, is absent from the v0.40.0 tag and present only here. On this branch both affected files pass:

enums_behaviors|enums_expansion,enums_behaviors.py,enums,PASS,1,0,0
enums_expansion,enums_expansion.py,enums,PASS,1,0,0

Full score on this branch: 141/141 (100%), 0 false positives, 0 missed, graded by the freshly cloned upstream harness at python/typing@a490662 — verified to be the live main tip. The torture corpus covers the same construct in benchmarks/torture/cases/enum_literal_expansion.py, where Basilisk passes and both pyright and zuban raise false positives.

Re-running the release on the current tag would fail the same gate again. Merging this is what lets a release ship.

Note on the release gate itself

It behaved correctly — it refused to publish a 99% build. This PR additionally hardens it so the score can never be measured against a stale suite: --gate now verifies the graded commit against a live git ls-remote of python/typing main and fails closed if it cannot prove freshness.

The fallback landed and worked -- CI downloaded v0.39.0 because v0.40.0
publishes no assets -- which exposed a neighbouring test still asserting
that download() returns the NEWEST tag:

  version should match release tag
  Passed in: 'v0.39.0'   Expected: 'v0.40.0'

download() is right and the assertion was encoding the old assumption. It
now compares against the release find_release_with_asset() resolved, i.e.
the release the binary actually came from. That is stronger than pinning
to the newest tag: it ties the reported version to the artifact on disk,
and it still fails if download() reports a version it did not fetch.

The other fetch_latest_release() callers in this spec use it purely as a
reachability probe and never compare tags, so they are unaffected.
@MelbourneDeveloper
MelbourneDeveloper merged commit 3c32813 into main Aug 5, 2026
25 checks passed
@MelbourneDeveloper
MelbourneDeveloper deleted the bidirectionaltype-inference branch August 5, 2026 11:36
Wamwea added a commit to Wamwea/Basilisk that referenced this pull request Aug 5, 2026
…um fix duplicated by upstream

Upstream Nimblesite#413 independently shipped the enum literal-expansion equivalence
(assignment_compatibility/enum_expand.rs, GitHub Nimblesite#374), so this branch's
duplicate implementation and its two overlapping tests are dropped in favor
of upstream's; the three edge-case tests upstream lacks (single-member enum,
nonmember() attributes, wrong-enum union) are kept. Mutation baseline
resolves to upstream's wider 154/161 pool pending a fresh run on the merged
tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant