Skip to content

Commit b796fa4

Browse files
Hakureirmclaude
andcommitted
feat(types): §2.5 builtins — abs(int) + range(stop) 1-arg (ADR-0089), incl the abs-of-expr miscompile fix
Two common Python builtins that were broken (LLMs write them constantly): - abs(-5) errored `expected f64, found i64` — bare abs only accepted f64. Now TYPE-PRESERVING: abs(int)->int, abs(float)->float (try_synth_abs_builtin special-case; per-shape lowering Int->__cobrust_int_abs / Float->__cobrust_math_abs; reuses TypeMismatch, no new variant/cascade). - range(5) errored `expected 2, got 1` — range was 2-arg only. range(stop) now injects start=0 and reuses the 2-arg PRELUDE body (try_synth_range_builtin; lower_call prepends Const::Int(0)). The 2-arg for-loop path is untouched. CTO REVIEW caught + fixed a SILENT MISCOMPILE the workflow's e2e missed (audit major): abs() of a COMPUTED integer expression — `abs(a - b)`, `abs(a * b)` — type-checked as Int + built clean but printed NaN/garbage (the i64 bits reinterpreted as a double). ROOT CAUSE: a binary-op result temp had no synthesised type, so neither the int-abs dest-override nor the symbol dispatch saw Int. FIX (two parts, on top of the workflow): 1. lower.rs `synth_expr_ty` gained a BINARY-op arm (extending ADR-0089 §5's unary arm) — a scalar `a ⊕ b` synths to Int/Float (arith), Bool (cmp), Int (bit/shift) when BOTH operands resolve to scalar Int/Float; a coil.Buffer operand (Ty::Adt) stays Ty::None and rides the existing Buffer-binop path (Buffer-SAFE, conservative). 2. intrinsics.rs Kind::MathAbs now dispatches on the call's DESTINATION type (which the type-checker sets correctly for type-preserving abs) rather than the arg operand's MIR temp type (Ty::None for a computed expr). + locked with abs_e2e_04b (abs(3-10)=7, abs(-2*4)=8) + 04c (abs(1.5-4.0)=2.5). The synth_expr_ty binary arm is a general correctness improvement (computed-int-expr temps now type accurately). FULL regression green (the binary arm touches core expr typing): well_typed 288, ill_typed 190, intrinsics_input (LC-100) 101, lc100_stress b1-b4 30/10/30/30, for_range 36 (range(5) now accepted), abs_range e2e 12, math_e2e 13 (abs float), method_call 5 (.abs()/.len() method-form), coil_round (coil.abs distinct). types 155; fmt + clippy (types/mir/codegen/cli/stdlib --all-targets) clean; no new dep. ADR-0089 + a cobrust-first-try skill doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d13c2c commit b796fa4

12 files changed

Lines changed: 1009 additions & 23 deletions

File tree

‎crates/cobrust-cli/src/build/intrinsics.rs‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ pub const MATH_CEIL_RUNTIME_SYMBOL: &str = "__cobrust_math_ceil";
226226
pub const MATH_ROUND_RUNTIME_SYMBOL: &str = "__cobrust_math_round";
227227
/// `abs(x: f64) -> f64` → `__cobrust_math_abs(f64) -> f64`.
228228
pub const MATH_ABS_RUNTIME_SYMBOL: &str = "__cobrust_math_abs";
229+
/// ADR-0089 §5 — type-preserving `abs(x: i64) -> i64` →
230+
/// `__cobrust_int_abs(i64) -> i64`. Picked (instead of the f64
231+
/// `__cobrust_math_abs`) when the `abs(x)` argument resolves to `Int`.
232+
pub const INT_ABS_RUNTIME_SYMBOL: &str = "__cobrust_int_abs";
229233
/// `pow(base: f64, exp: f64) -> f64` → `__cobrust_math_pow(f64, f64) -> f64`.
230234
pub const MATH_POW_RUNTIME_SYMBOL: &str = "__cobrust_math_pow";
231235
/// `sin(x: f64) -> f64` → `__cobrust_math_sin(f64) -> f64`.
@@ -1485,7 +1489,7 @@ pub fn rewrite_print(module: &mut Module) -> Result<(), IntrinsicError> {
14851489
let Terminator::Call {
14861490
func,
14871491
args,
1488-
destination: _,
1492+
destination,
14891493
target: _,
14901494
unwind: _,
14911495
} = term
@@ -2195,7 +2199,6 @@ pub fn rewrite_print(module: &mut Module) -> Result<(), IntrinsicError> {
21952199
| Kind::MathFloor
21962200
| Kind::MathCeil
21972201
| Kind::MathRound
2198-
| Kind::MathAbs
21992202
| Kind::MathSin
22002203
| Kind::MathCos
22012204
| Kind::MathTan
@@ -2212,7 +2215,6 @@ pub fn rewrite_print(module: &mut Module) -> Result<(), IntrinsicError> {
22122215
Kind::MathFloor => MATH_FLOOR_RUNTIME_SYMBOL,
22132216
Kind::MathCeil => MATH_CEIL_RUNTIME_SYMBOL,
22142217
Kind::MathRound => MATH_ROUND_RUNTIME_SYMBOL,
2215-
Kind::MathAbs => MATH_ABS_RUNTIME_SYMBOL,
22162218
Kind::MathSin => MATH_SIN_RUNTIME_SYMBOL,
22172219
Kind::MathCos => MATH_COS_RUNTIME_SYMBOL,
22182220
Kind::MathTan => MATH_TAN_RUNTIME_SYMBOL,
@@ -2224,6 +2226,59 @@ pub fn rewrite_print(module: &mut Module) -> Result<(), IntrinsicError> {
22242226
args.clear();
22252227
args.push(x_arg);
22262228
}
2229+
// ---- ADR-0089 §5: type-preserving `abs(x)` per-shape ----
2230+
// Python's `abs` is type-preserving: `abs(-5) == 5` (int),
2231+
// `abs(-5.0) == 5.0` (float). The type-checker
2232+
// (`try_synth_abs_builtin`) accepts BOTH and returns Int /
2233+
// Float respectively; here we pick the matching runtime
2234+
// symbol from the arg's resolved type (mirrors the
2235+
// `Kind::Print` / `Kind::LenPoly` monomorphization):
2236+
// Int → __cobrust_int_abs (i64 -> i64; i64::MIN
2237+
// saturates at i64::MAX per cobrust-stdlib
2238+
// `abs_i64`).
2239+
// else → __cobrust_math_abs (f64 -> f64; the historical
2240+
// float path AND the fallback for a `Constant`
2241+
// float / unresolved local — the type-checker
2242+
// guarantees a non-numeric arg never reaches
2243+
// here).
2244+
Kind::MathAbs => {
2245+
if args.len() != 1 {
2246+
return Err(IntrinsicError::PrintArgUnsupported {
2247+
found: format!("abs: expected 1 arg, got {}", args.len()),
2248+
});
2249+
}
2250+
// Resolve the arg's effective type. `Ref(T)` unwraps
2251+
// once so `abs(&x)` routes by the inner numeric type.
2252+
// A `Constant::Int` literal (`abs(-5)`) is the Int
2253+
// path; a `Constant::Float` literal is the Float path.
2254+
// `abs` is TYPE-PRESERVING, so the call's DESTINATION local
2255+
// is typed `Int` for abs(int) / `Float` for abs(float) by
2256+
// the type-checker (lower_call's `_callret` override). We
2257+
// dispatch on the DEST type — NOT the arg operand's MIR
2258+
// local type, which is `Ty::None` for a COMPUTED int
2259+
// expression like `abs(a - b)` (a binary-op temp) and
2260+
// misrouted such calls to the f64 shim = a SILENT MISCOMPILE
2261+
// (the i64 bits reinterpreted as a double). The arg-literal
2262+
// `Constant::Int` is the fallback if the dest is unresolved.
2263+
let dest_ty = local_ty.get(&destination.local.0).map(|t| match t {
2264+
Ty::Ref(inner) => (**inner).clone(),
2265+
other => other.clone(),
2266+
});
2267+
let is_int = match dest_ty {
2268+
Some(Ty::Int) => true,
2269+
Some(Ty::Float) => false,
2270+
_ => matches!(&args[0], Operand::Constant(Constant::Int(_))),
2271+
};
2272+
let sym = if is_int {
2273+
INT_ABS_RUNTIME_SYMBOL
2274+
} else {
2275+
MATH_ABS_RUNTIME_SYMBOL
2276+
};
2277+
let x_arg = args[0].clone();
2278+
*func = Operand::Constant(Constant::Str(sym.to_string()));
2279+
args.clear();
2280+
args.push(x_arg);
2281+
}
22272282
Kind::MathPow => {
22282283
// pow(base: f64, exp: f64) -> f64
22292284
if args.len() != 2 {
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// ADR-0089 end-to-end corpus for the type-PRESERVING free-function
2+
// `abs(x)` builtin and the Python-canonical 1-arg `range(stop)` form —
3+
// two §2.5 LLM-first builtin fixes (the spellings a Python-trained LLM
4+
// reaches for constantly).
5+
//
6+
// Per ADR-0089 §3/§5: bare `abs(x)` is now type-preserving like Python's
7+
// — `abs(-5) == 5` (an `int`, usable in int arithmetic) and
8+
// `abs(-5.0) == 5.0` (a `float`), where the pre-ADR-0089 type-checker
9+
// rejected `abs(-5)` with the misleading `type mismatch: expected f64,
10+
// found i64`. Per ADR-0089 §4: `range(5)` now means `range(0, 5)` (the
11+
// 1-arg form), where the pre-fix path rejected it with `wrong number of
12+
// arguments: expected 2, got 1`.
13+
//
14+
// These tests REAL-compile -> link -> spawn a `.cb` program and assert
15+
// the produced executable's stdout / exit code, differentially against
16+
// python3.11 semantics (the printed values match Python — modulo
17+
// cobrust's whole-float print formatting, which drops the trailing `.0`
18+
// so `abs(-5.0)` prints `5`, the same value 5.0 Python prints as `5.0`).
19+
//
20+
// Per `feedback_p9_clippy_stall_pattern.md`: module-level test-only lint
21+
// allow header.
22+
23+
#![allow(clippy::cast_possible_truncation)]
24+
#![allow(clippy::cast_possible_wrap)]
25+
#![allow(clippy::cast_precision_loss)]
26+
#![allow(clippy::cast_sign_loss)]
27+
#![allow(clippy::cast_lossless)]
28+
#![allow(clippy::format_push_string)]
29+
#![allow(clippy::let_unit_value)]
30+
#![allow(clippy::ignored_unit_patterns)]
31+
#![allow(clippy::unwrap_used)]
32+
#![allow(clippy::expect_used)]
33+
#![allow(clippy::similar_names)]
34+
#![allow(clippy::missing_panics_doc)]
35+
#![allow(clippy::uninlined_format_args)]
36+
#![allow(clippy::single_char_pattern)]
37+
#![allow(clippy::assertions_on_constants)]
38+
#![allow(clippy::needless_pass_by_value)]
39+
40+
use std::io::Write;
41+
use std::path::{Path, PathBuf};
42+
use std::process::{Command, Stdio};
43+
44+
fn cobrust_binary() -> PathBuf {
45+
PathBuf::from(env!("CARGO_BIN_EXE_cobrust"))
46+
}
47+
48+
fn workspace_root() -> PathBuf {
49+
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
50+
manifest
51+
.parent()
52+
.and_then(Path::parent)
53+
.map(Path::to_path_buf)
54+
.expect("workspace root")
55+
}
56+
57+
struct TempPath {
58+
_temp_dir: tempfile::TempDir,
59+
path: PathBuf,
60+
}
61+
62+
impl std::ops::Deref for TempPath {
63+
type Target = Path;
64+
65+
fn deref(&self) -> &Self::Target {
66+
&self.path
67+
}
68+
}
69+
70+
fn write_cb(name: &str, contents: &str) -> TempPath {
71+
let dir = tempfile::tempdir().expect("create temp source dir");
72+
let path = dir.path().join(format!("{name}.cb"));
73+
std::fs::write(&path, contents).expect("write temp .cb");
74+
TempPath {
75+
_temp_dir: dir,
76+
path,
77+
}
78+
}
79+
80+
struct BuiltExe {
81+
_temp_dir: tempfile::TempDir,
82+
path: PathBuf,
83+
}
84+
85+
impl std::ops::Deref for BuiltExe {
86+
type Target = Path;
87+
88+
fn deref(&self) -> &Self::Target {
89+
&self.path
90+
}
91+
}
92+
93+
/// Invoke `cobrust build`; return `(exit_code, exe, stderr)`.
94+
fn run_build_exe(src: &Path) -> (i32, BuiltExe, String) {
95+
let bin = cobrust_binary();
96+
let exe_dir = tempfile::tempdir().expect("create temp exe dir");
97+
let exe = exe_dir.path().join(src.file_stem().unwrap());
98+
let out = Command::new(&bin)
99+
.arg("build")
100+
.arg(src)
101+
.arg("-o")
102+
.arg(&exe)
103+
.arg("--quiet")
104+
.current_dir(workspace_root())
105+
.output()
106+
.expect("invoke cobrust build");
107+
let code = out.status.code().unwrap_or(-1);
108+
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
109+
(
110+
code,
111+
BuiltExe {
112+
_temp_dir: exe_dir,
113+
path: exe,
114+
},
115+
stderr,
116+
)
117+
}
118+
119+
fn run_exe(exe: &Path, args: &[&str], stdin_bytes: &[u8]) -> (i32, String, String) {
120+
let mut child = Command::new(exe)
121+
.args(args)
122+
.stdin(Stdio::piped())
123+
.stdout(Stdio::piped())
124+
.stderr(Stdio::piped())
125+
.spawn()
126+
.expect("spawn produced exe");
127+
{
128+
let stdin = child.stdin.as_mut().expect("stdin");
129+
let _ = stdin.write_all(stdin_bytes);
130+
}
131+
let out = child.wait_with_output().expect("wait_with_output");
132+
(
133+
out.status.code().unwrap_or(-1),
134+
String::from_utf8_lossy(&out.stdout).into_owned(),
135+
String::from_utf8_lossy(&out.stderr).into_owned(),
136+
)
137+
}
138+
139+
/// Build + run; assert build succeeds, run exits 0, stdout matches.
140+
fn assert_build_run(name: &str, src: &str, expected_stdout: &str) {
141+
let path = write_cb(name, src);
142+
let (build_code, exe, build_stderr) = run_build_exe(&path);
143+
assert_eq!(
144+
build_code, 0,
145+
"{name}: build failed; stderr=\n{build_stderr}\n--- source ---\n{src}"
146+
);
147+
let (run_code, stdout, run_stderr) = run_exe(&exe, &[], b"");
148+
assert_eq!(run_code, 0, "{name}: run failed; stderr={run_stderr}");
149+
assert_eq!(
150+
stdout, expected_stdout,
151+
"{name}: stdout mismatch\nstderr={run_stderr}"
152+
);
153+
}
154+
155+
// =====================================================================
156+
// abs_e2e_01 — `print(abs(-5))` -> "5\n". The arg is an i64; the result
157+
// is an i64 (type-preserving). Lowers to __cobrust_int_abs. ADR-0089 §3.
158+
// =====================================================================
159+
160+
#[test]
161+
fn abs_e2e_01_neg_int_returns_int() {
162+
let src = "fn main() -> i64:\n print(abs(-5))\n return 0\n";
163+
assert_build_run("abs_e2e_01", src, "5\n");
164+
}
165+
166+
// =====================================================================
167+
// abs_e2e_02 — `print(abs(5))` -> "5\n" (already-positive int).
168+
// =====================================================================
169+
170+
#[test]
171+
fn abs_e2e_02_pos_int_returns_int() {
172+
let src = "fn main() -> i64:\n print(abs(5))\n return 0\n";
173+
assert_build_run("abs_e2e_02", src, "5\n");
174+
}
175+
176+
// =====================================================================
177+
// abs_e2e_03 — `print(abs(-5.0))` -> "5\n". The arg is an f64; the
178+
// result is an f64 (5.0). cobrust prints whole floats without the
179+
// trailing `.0`, so stdout is "5\n" (the value 5.0 — Python's `5.0`).
180+
// Routes to __cobrust_math_abs (the float regression path). ADR-0089 §5.
181+
// =====================================================================
182+
183+
#[test]
184+
fn abs_e2e_03_neg_float_returns_float() {
185+
let src = "fn main() -> i64:\n print(abs(-5.0))\n return 0\n";
186+
assert_build_run("abs_e2e_03", src, "5\n");
187+
}
188+
189+
// =====================================================================
190+
// abs_e2e_04 — `abs(0)` -> "0\n" (zero, int).
191+
// =====================================================================
192+
193+
#[test]
194+
fn abs_e2e_04_zero_int_returns_zero() {
195+
let src = "fn main() -> i64:\n print(abs(0))\n return 0\n";
196+
assert_build_run("abs_e2e_04", src, "0\n");
197+
}
198+
199+
// =====================================================================
200+
// abs_e2e_04b — `abs` of a COMPUTED integer expression (`a - b`, `a * b`).
201+
// The load-bearing regression for the silent-miscompile fix: a binary-op
202+
// result temp synths to `Int` (lower.rs synth_expr_ty Bin arm) so the
203+
// int-abs dest+lowering fire. Before the fix `abs(a - b)` re-interpreted
204+
// the i64 bits as a double and printed NaN/garbage. ADR-0089 §5.
205+
// =====================================================================
206+
207+
#[test]
208+
fn abs_e2e_04b_int_binary_expr_returns_int() {
209+
// abs(3 - 10) == 7, abs(-2 * 4) == 8 (NOT NaN).
210+
let src = "fn main() -> i64:\n let a: i64 = 3\n let b: i64 = 10\n print(abs(a - b))\n print(abs(-2 * 4))\n return 0\n";
211+
assert_build_run("abs_e2e_04b", src, "7\n8\n");
212+
}
213+
214+
#[test]
215+
fn abs_e2e_04c_float_binary_expr_returns_float() {
216+
// abs(1.5 - 4.0) == 2.5 (the float path stays correct).
217+
let src = "fn main() -> i64:\n let x: f64 = 1.5\n let y: f64 = 4.0\n print(abs(x - y))\n return 0\n";
218+
assert_build_run("abs_e2e_04c", src, "2.5\n");
219+
}
220+
221+
// =====================================================================
222+
// abs_e2e_05 — `abs(-5) + 1` -> "6\n": the int result is usable in int
223+
// arithmetic (the §2.5 first-try win — `abs` returns an int, not a
224+
// float, so the `+ 1` stays integer).
225+
// =====================================================================
226+
227+
#[test]
228+
fn abs_e2e_05_int_result_in_arithmetic() {
229+
let src = "fn main() -> i64:\n let r: i64 = abs(-5) + 1\n print(r)\n return 0\n";
230+
assert_build_run("abs_e2e_05", src, "6\n");
231+
}
232+
233+
// =====================================================================
234+
// abs_e2e_06 — `abs(n)` on an i64 variable (the local-typed path, not a
235+
// literal) -> "7\n".
236+
// =====================================================================
237+
238+
#[test]
239+
fn abs_e2e_06_abs_of_int_var() {
240+
let src = "fn main() -> i64:\n let n: i64 = -7\n print(abs(n))\n return 0\n";
241+
assert_build_run("abs_e2e_06", src, "7\n");
242+
}
243+
244+
// =====================================================================
245+
// range_e2e_01 — `for i in range(5):` sums to 10 (0+1+2+3+4). The 1-arg
246+
// `range(stop)` form (== `range(0, stop)`). ADR-0089 §4.
247+
// =====================================================================
248+
249+
#[test]
250+
fn range_e2e_01_one_arg_sums_to_ten() {
251+
let src = "fn main() -> i64:\n let total: i64 = 0\n for i in range(5):\n total = total + i\n print(total)\n return 0\n";
252+
assert_build_run("range_e2e_01", src, "10\n");
253+
}
254+
255+
// =====================================================================
256+
// range_e2e_02 — `range(0)` is empty: the loop body never runs, the
257+
// accumulator stays 0.
258+
// =====================================================================
259+
260+
#[test]
261+
fn range_e2e_02_zero_is_empty() {
262+
let src = "fn main() -> i64:\n let total: i64 = 0\n for i in range(0):\n total = total + i\n print(total)\n return 0\n";
263+
assert_build_run("range_e2e_02", src, "0\n");
264+
}
265+
266+
// =====================================================================
267+
// range_e2e_03 — 2-arg `range(start, stop)` REGRESSION: `range(2, 5)`
268+
// sums to 9 (2+3+4). The 2-arg form is NOT intercepted by the 1-arg
269+
// special-case and keeps its existing lowering.
270+
// =====================================================================
271+
272+
#[test]
273+
fn range_e2e_03_two_arg_regression_sums_to_nine() {
274+
let src = "fn main() -> i64:\n let total: i64 = 0\n for i in range(2, 5):\n total = total + i\n print(total)\n return 0\n";
275+
assert_build_run("range_e2e_03", src, "9\n");
276+
}
277+
278+
// =====================================================================
279+
// range_e2e_04 — 1-arg `range(n)` with a variable stop -> sums 0..n-1.
280+
// `range(4)` -> 0+1+2+3 == 6.
281+
// =====================================================================
282+
283+
#[test]
284+
fn range_e2e_04_one_arg_var_stop() {
285+
let src = "fn main() -> i64:\n let n: i64 = 4\n let total: i64 = 0\n for i in range(n):\n total = total + i\n print(total)\n return 0\n";
286+
assert_build_run("range_e2e_04", src, "6\n");
287+
}

0 commit comments

Comments
 (0)