Bidirectional type inference: one engine for rules, hover and inlay hints - #413
Conversation
# 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 |
…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.
This PR unblocks the v0.40.0 releaseThe v0.40.0 release run (30991608586) failed at its first job, the conformance gate: 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:
The fix is in this branchThat false positive is the enum-literal expansion bug (Refs #374). The fix, Full score on this branch: 141/141 (100%), 0 false positives, 0 missed, graded by the freshly cloned upstream harness at 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 itselfIt 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: |
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.
…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.
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, kindType → Typeoperator 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 aDivergentfallback that projects to gradualUnknownso truncation NEVER invents an error.lower.rs— total gradual lowering from Ruff ASTtypestatements, string forward references included.queries.rs— the memoized Salsa layer (type_alias_env,alias_whnfper(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. ReplacesInferredType::from_annotation(<source text>).Shared per-module type context (
rules/shared/module_types.rs) —ModuleTypesbundles the cascade, the oracle, and the subtyping table, built once by the driver and handed to rules via the newRule::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 sameModuleTypes/BidirEnginethe 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, plusrun_torture.pyand a committed-baseline scoreboard gate. Measured result: basilisk 12/12, mypy/pyrefly/zuban 11/12, pyright 10/12, ty 6/12.New test suites —
oracle_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.rsandinference_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_subtypeand 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 throughSubtypingContext;name_subtypeis the internal numeric tower only.unconditional_assignsandtop_level_return_name_refs, pluscollect_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 alanguages/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/elseauto-dedent, shebang detection and the richer built-in queries. Now binds to Zed's built-in Python, matching how ty/pyrefly/pylsp do it.Rewritten —
rules/names_unbound.rsnow 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, aNoReturn-typed call,while True:withoutbreak) drops out of the merge instead of poisoning it. It abstains inside loop bodies,excepthandlers andfinallyblocks, where "bound" is genuinely path-dependent.Bug fix —
narrow::rebind::bound_namesmissed PEP 572 walrus targets, because a walrus binds from inside an expression and no statement shape reveals it. Fixed by exporting the resolver's existingcollect_walrus_targetsrather 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?
python/typingharness against a clean--releasebuild.conformance_status.csvregenerated from the harness's ownresults/basilisk/*.toml— every row PASS.oracle_agreement_tests.rsproves the central claim non-tautologically.displayed_type_is_a_type_the_checker_acceptsasks the public display oracle what an expression is, then asserts the checker acceptsvalue: <that type> = <expression>— across 11 fixtures including nested collections.a_disagreeing_type_is_rejectedis the paired negative, so acceptance carries information rather than passing for an oracle that rendersAny.call_results_agreecovers the case the deleted display path could not type at all.literal_string_provenance_survives_displaypins the PEP 675 hover regression (does not infer generic type #290).names_unbound_tests.rsgrew 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.walrus_targets_are_bindings_in_every_expression_positionfailed RED with{"d", "inner"}before the fix, coveringif (a := f()),while (b := g()),print(c := h())and a comprehension, and asserting a walrus inside a nesteddefis NOT collected.coverage-thresholds.jsonfloor; VSIX 94% ≥ 93%; Neovim 44% ≥ 44%; Zed builds forwasm32-wasip2with 97 tests green.cargo clippy --workspace --all-targetsclean 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 alanguages/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_annotationwent from 11 call sites to 2; both survivors are inalias_match.rsand 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 toUnknown. The real retirement is deleting the matcher (Refs Valid recursive PEP 695 JsonValue alias rejected as circular #371), not forcing the call site. TheRhsKindtype-proxy tail (15 files) andslice_spantail remain.rules/names_unbound.rsis 594 lines, over this repo's 500-LOC ceiling; the split intomod/scan/bindingsis 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% ofmain'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_contextisO(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 (theRule::check_with_typesdoc 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
Benchmark RatchetCI job, the gate helpers insummarize.py(read_committed_baseline,find_regressions,parse_basilisk_ms), and the policy block inrun.sh. Write-always is untouched. Spec IDCHKARCH-TESTING-BENCH-RATCHET→CHKARCH-TESTING-BENCHacross all 18 references, rewritten to say how to read the numbers rather than how to gate on them.--gateverifies the graded HEAD against a livegit ls-remoteofpython/typing mainand fails on mismatch, closing the hole where--ref,--suite-dir, or--reuse-clonecould 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--gatecannot skip it. Verified live:gate suite verified: python/typing@a490662 is main tip→ 141/141, 0 FP.rules/names_unbound.rs(594 LOC) intonames_unbound/{mod,scan,bindings}.rsat 173/302/160. Module path unchanged; all 22 tests and the full 53-binary checker suite pass.benchmarks/torture/casesfromruff format. Those are line-scored fixtures: a one-linedef f() -> int: return <bad>deliberately collapses two diagnostics onto the single# Eline, and reformatting split them across an unmarked line, taking torture from 12/12 to 11/12.Known unrelated CI failures
binary_spec.lua:364asserts the latest GitHub release containsbasilisk-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.binds one webview message handler across singleton re-renders, two 10 spollUntilwaits 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