From c4ccd240c5a7ac9e7d4bbc08912c26116f63eee6 Mon Sep 17 00:00:00 2001 From: simontreanor Date: Mon, 28 Sep 2026 10:00:44 +0100 Subject: [PATCH] native: annotate module-level values; discarded matches run in place mypyc's front end rejected two shapes a real multi-module program produces: - A module-level value another module reads ("Cannot determine type"). Native mode now writes the type down: `_X: X = X()` for a nullary singleton, and the inferred type on the first assignment of a top-level value bound once, when Python can spell it. - A unit match in statement position, hoisted into a temp that is only ever assigned unit calls ("has inferred type None"). The arms now evaluate in place. --- INTERNALS.md | 8 +++- ROADMAP.md | 10 ++-- src/lowering/mod.rs | 113 ++++++++++++++++++++++++++++++++++++++++---- tests/compile.rs | 34 +++++++++++++ 4 files changed, 152 insertions(+), 13 deletions(-) diff --git a/INTERNALS.md b/INTERNALS.md index 00fc0cf..8ba7eb4 100644 --- a/INTERNALS.md +++ b/INTERNALS.md @@ -412,7 +412,13 @@ A `Lowerer.native` flag, set by `lowering::lower_native_typed` (single file, cal spells data class fields that name a user type (`"Expr"`), and rewrites each top-level `PyStmt::FuncDef` whose binding type is known into a `PyStmt::TypedFuncDef` using `py_type_annotation` over `Lowerer.binding_types` (span of the binding target → `types::Ty`, from - `TypeSpan::raw`). An unspellable type leaves its position unannotated. + `TypeSpan::raw`). An unspellable type leaves its position unannotated. The same pass annotates + module-level values, which mypyc cannot type when another module reads them: a nullary singleton + `_X: X = X()`, and the first assignment of a top-level value bound once. +- **Discarded matches.** `Lowerer::push_discarded` emits a statement-position expression. In + native mode, when its value is a temp set by an `if` ladder, `discard_temp` turns each assignment + of the temp into a bare evaluation, since mypyc rejects a local whose every assignment is a unit + call. - **Build.** `src/native_build.rs` writes the native output to a directory, runs `python -m mypyc --allow-redefinition-new --local-partial-types` over every module but `_pyfun_rt.py`, removes the sources and writes `__main__.py`. `bench/mypyc_run.py` and `.github/workflows/native-bench.yml` diff --git a/ROADMAP.md b/ROADMAP.md index 625ad4c..8b912c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -515,10 +515,12 @@ map_build 1.64× vs hand-written. **The real-program audit (the Scrabble game, locally):** its native output passes plain mypy with the build's flags (64 errors down to 0 after the shared runtime's fields became `Any`, unit returns went bare, and three boundary error codes were disabled), and every replay prints the same. - mypyc itself still stops on **"Cannot determine type" for module-level values another module reads - at import time** (nullary singletons like `board._QW`, layout tables): that is the next native - blocker. **Remaining:** that, or-patterns whose alternatives bind names, and the closure/`seq` - audit under mypyc. + mypyc's front end now accepts the whole game too: module-level values are annotated (another + module's reads were "Cannot determine type"), and a unit match in statement position runs its arms + in place (a temp assigned only unit calls was "inferred type None"). It stops only at `build_ext` + on a machine without a C compiler, so the compiled game hasn't been timed. **Remaining:** a + compiled run of a real program on a machine with a C toolchain, or-patterns whose alternatives + bind names, and the closure/`seq` audit under mypyc. The original reasoning, kept for the record: the checker knows every binding's inferred type, so the emitter could produce fully annotated Python whose annotations cannot lie, then compile it with mypyc into a C extension — native speed diff --git a/src/lowering/mod.rs b/src/lowering/mod.rs index eb42ac9..1128ab1 100644 --- a/src/lowering/mod.rs +++ b/src/lowering/mod.rs @@ -947,6 +947,55 @@ impl Lowerer { } } } + // Module-level values another module reads need their type written down: + // mypyc cannot infer it across modules ("Cannot determine type"). Each + // nullary singleton names its class, and a top-level value binding bound + // once gets its inferred type when Python can spell it. + let mut value_types: HashMap = HashMap::new(); + let mut bound_count: HashMap = HashMap::new(); + for item in &module.items { + if let Item::Let(b) = item { + for name in b.bound_names() { + *bound_count.entry(py_value_name(&name)).or_default() += 1; + } + if b.params.is_empty() + && let Some(name) = b.name() + && let Some(ty) = self.binding_types.get(&b.target_span.span()) + && let Some(ann) = py_type_annotation(ty, &spell) + { + value_types.insert(py_value_name(name), ann); + } + } + } + // Only a name's first assignment carries the annotation (a top-level + // `let mut` is reassigned by `<-`). + let mut annotated: HashSet = HashSet::new(); + for stmt in &mut py.body { + let PyStmt::Assign { target, value } = stmt else { + continue; + }; + if !annotated.insert(target.clone()) { + continue; + } + let singleton_class = match value { + PyExpr::Call { func, args } if args.is_empty() => match func.as_ref() { + PyExpr::Name(class) + if class_names.contains(class) && *target == format!("_{class}") => + { + Some(class.clone()) + } + _ => None, + }, + _ => None, + }; + if let Some(class) = singleton_class { + *target = format!("{target}: {class}"); + } else if bound_count.get(target.as_str()) == Some(&1) + && let Some(ann) = value_types.get(target.as_str()) + { + *target = format!("{target}: {ann}"); + } + } // The top-level function bindings and their types. let mut fn_types: HashMap = HashMap::new(); for item in &module.items { @@ -2067,11 +2116,8 @@ impl Lowerer { } BlockStmt::Expr(e) if i == last => out.extend(self.lower_return(e, &locals)?), BlockStmt::Expr(e) => { - let (mut s, v) = self.lower_value(e, &locals)?; - out.append(&mut s); - if !matches!(v, PyExpr::NoneLit) { - out.push(PyStmt::Expr(v)); - } + let (s, v) = self.lower_value(e, &locals)?; + self.push_discarded(&mut out, s, v); } } } @@ -2438,11 +2484,11 @@ impl Lowerer { } BlockStmt::Expr(e) => { let (mut s, v) = self.lower_value(e, &locals)?; - out.append(&mut s); if i == last { + out.append(&mut s); value = v; - } else if !matches!(v, PyExpr::NoneLit) { - out.push(PyStmt::Expr(v)); + } else { + self.push_discarded(&mut out, s, v); } } } @@ -5569,6 +5615,26 @@ impl Lowerer { /// /// The checker has proved the unguarded arms cover the scrutinee's type, so /// the last arm, when unguarded, is a plain `else`. + /// Emit a statement-position expression whose value is dropped. In native + /// mode a value match hoisted into a temp that nothing reads evaluates its + /// arms in place instead: mypyc rejects a local whose every assignment is a + /// unit call ("has inferred type None"). + fn push_discarded(&self, out: &mut Vec, mut stmts: Vec, value: PyExpr) { + if self.native + && let PyExpr::Name(tmp) = &value + && tmp.starts_with("_pf_t") + && let Some(PyStmt::If { .. }) = stmts.last() + { + discard_temp(&mut stmts, tmp); + out.append(&mut stmts); + return; + } + out.append(&mut stmts); + if !matches!(value, PyExpr::NoneLit) { + out.push(PyStmt::Expr(value)); + } + } + fn try_lower_native_match( &mut self, scrutinee: &Expr, @@ -11092,6 +11158,37 @@ fn py_param_names(names: &[String]) -> Vec { } /// Append `target = value` to a (possibly empty) statement list. +/// Turn every assignment of `tmp` in an `if` ladder (as built by +/// `try_lower_native_match`) into a bare evaluation of its value, dropping it +/// when the value is a bare name (evaluating one does nothing). +fn discard_temp(stmts: &mut Vec, tmp: &str) { + let mut i = 0; + while i < stmts.len() { + match &mut stmts[i] { + PyStmt::If { body, orelse, .. } => { + discard_temp(body, tmp); + discard_temp(orelse, tmp); + } + PyStmt::Assign { target, value } if target == tmp => { + let value = std::mem::replace(value, PyExpr::NoneLit); + match value { + PyExpr::NoneLit => { + stmts.remove(i); + continue; + } + PyExpr::Name(_) => { + stmts.remove(i); + continue; + } + other => stmts[i] = PyStmt::Expr(other), + } + } + _ => {} + } + i += 1; + } +} + fn with_assign(mut stmts: Vec, target: &str, value: PyExpr) -> Vec { stmts.push(PyStmt::Assign { target: target.to_string(), diff --git a/tests/compile.rs b/tests/compile.rs index b6dca67..836b1a4 100644 --- a/tests/compile.rs +++ b/tests/compile.rs @@ -4016,6 +4016,40 @@ fn native_annotates_top_level_functions_it_can_spell() { assert_eq!(out, ["4.0", "4", "['A']", "Some(1)", "2"]); } +#[test] +fn native_annotates_module_level_values_for_other_modules() { + // mypyc cannot infer a module-level value's type from another module, so + // native mode writes singletons' and top-level values' types down. + let src = "type Cell = Plain | Double\n\ + let layout = [Plain, Double]\n\ + let size = List.len layout\n\ + print size"; + let Some((py, out)) = run_native(src) else { + return; + }; + assert!(py.contains("_Double: Double = Double()"), "{py}"); + assert!(py.contains("layout: list[Cell] = "), "{py}"); + assert!(py.contains("size: int = "), "{py}"); + assert_eq!(out, ["2"]); +} + +#[test] +fn native_evaluates_a_discarded_match_in_place() { + // A unit match in statement position runs its arms directly: a temp + // assigned only unit calls is one mypyc rejects ("inferred type None"). + let src = "let report o =\n \ + match o:\n \ + case Some n: print n\n \ + case None: print \"none\"\n \ + print \"done\"\n\ + report (Some 1)"; + let Some((py, out)) = run_native(src) else { + return; + }; + assert!(!py.contains("_pf_t"), "{py}"); + assert_eq!(out, ["1", "done"]); +} + #[test] fn native_annotates_block_local_functions_and_leaves_unit_returns_bare() { let src = "type Shape = Circle float | Square float\n\