Skip to content

Commit f925b52

Browse files
feat(betlang): wire-first toolchain pass + bet:Dist T on the unified runtime (#65)
## What this is Brings the 2026-06-15 **wire-first toolchain pass** + **`bet : Dist T`** work onto the reconciled `main` (which already carries the parallel session's TP-5 intro/elim core and the checker's occurs-check + let-polymorphism). The parallel reconciliation merge resolved the TP-5/checker collisions but dropped this session's wiring; this PR re-applies it surgically on top. Full rationale and the journey's design decisions: **`docs/decisions/2026-06-15-betlang-wiring-and-bet-dist-t.adoc`** (8 ADRs). ## Highlights **`bet : Dist T` (option a) — checker + interpreter + codegen aligned with the Lean proof** - `check_bet`/`check_weighted_bet`/`check_conditional_bet` return `Dist T`; `bet { 1,2,3 } + 1` is now correctly rejected. `echo(bet …) : Echo (Dist T)`. - Interpreter **unified onto `bet_rt::Value`** (one runtime): `bet → Value::Dist`, `sample` draws; AST lambdas **closure-converted** into native `bet_rt` closures. `bet-rt` and `bet-rand` are no longer orphaned. - JS codegen emits **distribution objects** so `sample` works end-to-end (verified under deno). **Wire-first pass** - Justfile: was *unparseable* (`//` line 1) — fixed + Rust recipes (`build-rust`/`test-rust`/`check`/`run`/`compile`/`repl-rust`). - Workspace greened: `bet-wasm` (E0308), `bet-viz` (`im` dep + `Histogram`→`Rectangle` + textplots lifetimes), `bet-chapel` (`rand_pcg`); **3 real `bet-rt` bugs** fixed (queue order, `printf %s` quoting, msgpack `List`→`Bytes`). - `cbindgen` no longer **strips the SPDX header** from generated `betlang.h` (was an automated licence edit). - CLI `bet compile … --target js|llvm|beam`; LSP wired to the **real parser + type checker** (real parse/type diagnostics with spans; obsolete "LALR conflicts" deps re-enabled). - Interpreter surfaces unimplemented forms as **errors, not silent `Unit`**. - `examples/dice.bet` — first working `.bet` fixture. ## Reconciliation choices (honoring owner adjudication) - **TP-5** kept at origin's `echoIntro`/`echoElim` core. The richer comonad-law surface (this session's `MultiStep` shadow of `EchoGradedComonad.agda`) is **TP-5b** (deferred, P3) — preserved in local `964d106` + the ADR. - Origin's **occurs-check + let-polymorphism kept** (complementary; the audit had flagged both gaps). - `docs/echo-types.adoc` left at origin's baseline (owner-managed). ## Verification - **317 workspace tests pass**; `lake build` green; `tools/proof-scan.sh` clean (no banned escape hatches). - End-to-end: `bet check/run/compile examples/dice.bet` all work. ## Deferred / for follow-up (ADR-8) Racket→Rust authority declaration (left as-is); eager-vs-lazy runtime operational model; TP-5b; SEM-1/STAT-1/STAT-2 (Mathlib decision); ABI-1..5 (Idris2 recipe + CI). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents c144993 + 1151c45 commit f925b52

26 files changed

Lines changed: 748 additions & 465 deletions

File tree

Justfile

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// SPDX-License-Identifier: MPL-2.0
2-
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
33
# betlang - Development Tasks
44
# AUTHORITY: AUTHORITY_STACK.mustfile-nickel.scm
55
# All operations MUST be invoked via `just <recipe>`.
@@ -84,6 +84,34 @@ test-tooling:
8484
clean-tooling:
8585
cargo clean
8686

87+
# --- Rust toolchain recipes (the working compiler/interpreter pipeline) ------
88+
# These drive the real multi-crate Rust implementation under compiler/, runtime/
89+
# and tools/. (The Racket recipes above target the historical core/*.rkt tree.)
90+
91+
# Build the entire Rust workspace (all crates)
92+
build-rust:
93+
cargo build --workspace
94+
95+
# Test the entire Rust workspace
96+
test-rust:
97+
cargo test --workspace
98+
99+
# Type-check a betlang source file with the Rust checker
100+
check FILE:
101+
cargo run -q -p bet -- check {{FILE}}
102+
103+
# Run a betlang source file with the Rust interpreter
104+
run FILE:
105+
cargo run -q -p bet -- run {{FILE}}
106+
107+
# Compile a betlang source file to a backend (TARGET = js | llvm | beam)
108+
compile FILE TARGET="js":
109+
cargo run -q -p bet -- compile {{FILE}} --target {{TARGET}}
110+
111+
# Start the Rust REPL
112+
repl-rust:
113+
cargo run -q -p bet -- repl
114+
87115
# ============================================================================
88116
# PROOFS (formal verification — see docs/AFFINESCRIPT-ALIGNMENT.adoc)
89117
# ============================================================================

bindings/chapel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ libc = "0.2"
1818
# Core
1919
rand.workspace = true
2020
rand_distr.workspace = true
21+
rand_pcg.workspace = true # seedable PCG generator for `bet_seed`
2122

2223
[build-dependencies]
2324
cbindgen = "0.28"

bindings/chapel/build.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ fn main() {
1010
.with_language(cbindgen::Language::C)
1111
.with_include_guard("BET_CHAPEL_H")
1212
.with_documentation(true)
13+
// Preserve the SPDX/copyright header on the generated file. cbindgen
14+
// overwrites include/betlang.h wholesale on every build; without this
15+
// the licence header is silently stripped each time (an automated
16+
// licence edit, which estate policy forbids — keep it manual & stable).
17+
.with_header(
18+
"// SPDX-License-Identifier: MPL-2.0\n\
19+
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>\n\
20+
// GENERATED by cbindgen (build.rs) — do not edit by hand.",
21+
)
1322
.generate()
1423
.expect("Unable to generate bindings")
1524
.write_to_file("include/betlang.h");

bindings/chapel/include/betlang.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
// GENERATED by cbindgen (build.rs) — do not edit by hand.
4+
35
#ifndef BET_CHAPEL_H
46
#define BET_CHAPEL_H
57

@@ -143,4 +145,4 @@ void bet_seed(uint64_t seed);
143145
*/
144146
const char *bet_version(void);
145147

146-
#endif /* BET_CHAPEL_H */
148+
#endif /* BET_CHAPEL_H */

compiler/bet-check/src/lib.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,10 @@ fn check_bet(bet: &BetExpr, env: &mut CheckEnv, span: Span) -> CompileResult<Typ
837837

838838
env.unify(&t0, &t1, Some(span))?;
839839
env.unify(&t0, &t2, Some(span))?;
840-
Ok(env.resolve(&t0))
840+
// A `bet` introduces probabilistic choice: it has type `Dist T`, not `T`.
841+
// (Matches the mechanised rule `tBet : … → HasType … (Ty.dist T)` in
842+
// proofs/BetLang.lean; eliminate with `sample : Dist 'a -> 'a`.)
843+
Ok(Type::Dist(Box::new(env.resolve(&t0))))
841844
}
842845

843846
/// Check a weighted bet: branches unify, weights must be numeric.
@@ -863,7 +866,8 @@ fn check_weighted_bet(
863866
for i in 1..branch_types.len() {
864867
env.unify(&branch_types[0], &branch_types[i], Some(span))?;
865868
}
866-
Ok(env.resolve(&branch_types[0]))
869+
// Weighted bet, like uniform bet, is a distribution: `Dist T`.
870+
Ok(Type::Dist(Box::new(env.resolve(&branch_types[0]))))
867871
}
868872

869873
/// Check a conditional bet: condition is Bool/Ternary, all branches unify.
@@ -892,7 +896,9 @@ fn check_conditional_bet(
892896
env.unify(&true_ty, &f0, Some(span))?;
893897
env.unify(&true_ty, &f1, Some(span))?;
894898
env.unify(&true_ty, &f2, Some(span))?;
895-
Ok(env.resolve(&true_ty))
899+
// Conditional bet also yields a distribution over `T`: point-mass on the
900+
// `if_true` value when the condition holds, else the ternary distribution.
901+
Ok(Type::Dist(Box::new(env.resolve(&true_ty))))
896902
}
897903

898904
// ============================================
@@ -1265,7 +1271,11 @@ mod tests {
12651271
],
12661272
};
12671273
let result = check_expr(&dummy(Expr::Bet(bet)), &mut env);
1268-
assert_eq!(result.expect("TODO: handle error"), Type::Int);
1274+
// `bet` introduces probabilistic choice, so its type is `Dist Int`.
1275+
assert_eq!(
1276+
result.expect("TODO: handle error"),
1277+
Type::Dist(Box::new(Type::Int))
1278+
);
12691279
}
12701280

12711281
#[test]
@@ -1699,9 +1709,11 @@ mod tests {
16991709
);
17001710
}
17011711

1702-
/// The core primitive bridges to Echo by composition at the type level:
1703-
/// `echo(bet a b c) : Echo T`. (Runtime branch-tag retention — `bet_echo` —
1704-
/// remains deferred; the *type* story needs no new primitive.)
1712+
/// The core primitive bridges to Echo by composition at the type level.
1713+
/// Since `bet a b c : Dist T`, the composite is `echo(bet a b c) : Echo (Dist T)`
1714+
/// — the retained-loss residue over the *distribution*. (Runtime branch-tag
1715+
/// retention — `bet_echo` — remains deferred; the *type* story needs no new
1716+
/// primitive.)
17051717
#[test]
17061718
fn test_bet_echo_bridge_by_composition() {
17071719
let mut env = CheckEnv::new();
@@ -1714,8 +1726,8 @@ mod tests {
17141726
};
17151727
let e = call("echo", vec![dummy(Expr::Bet(bet))]);
17161728
assert_eq!(
1717-
check_expr(&e, &mut env).expect("echo (bet 1 2 3) : Echo Int"),
1718-
Type::Echo(Box::new(Type::Int))
1729+
check_expr(&e, &mut env).expect("echo (bet 1 2 3) : Echo (Dist Int)"),
1730+
Type::Echo(Box::new(Type::Dist(Box::new(Type::Int))))
17191731
);
17201732
}
17211733

compiler/bet-codegen/src/lib.rs

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,24 +102,36 @@ pub fn codegen_module(module: &Module, target: Target) -> CompileResult<CodeOutp
102102
fn js_preamble() -> &'static str {
103103
r#"// === Betlang Runtime Preamble ===
104104
105-
// Uniform ternary choice among exactly 3 alternatives
105+
// Uniform ternary choice among exactly 3 alternatives.
106+
// `bet` has type `Dist T`: it returns a *distribution object* (sample with
107+
// `__bet_sample`), not an eager draw — consistent with the checker/interpreter.
106108
function __bet_uniform(a, b, c) {
107-
const r = Math.random();
108-
if (r < 1/3) return a;
109-
if (r < 2/3) return b;
110-
return c;
109+
return {
110+
name: 'bet',
111+
sample() {
112+
const r = Math.random();
113+
if (r < 1/3) return a;
114+
if (r < 2/3) return b;
115+
return c;
116+
},
117+
};
111118
}
112119
113-
// Weighted ternary choice (weights normalised internally)
120+
// Weighted ternary choice (weights normalised internally). Returns a Dist.
114121
function __bet_weighted(alts, weights) {
115-
const total = weights.reduce((s, w) => s + w, 0);
116-
const r = Math.random() * total;
117-
let cumul = 0;
118-
for (let i = 0; i < alts.length; i++) {
119-
cumul += weights[i];
120-
if (r < cumul) return alts[i];
121-
}
122-
return alts[alts.length - 1];
122+
return {
123+
name: 'weighted_bet',
124+
sample() {
125+
const total = weights.reduce((s, w) => s + w, 0);
126+
const r = Math.random() * total;
127+
let cumul = 0;
128+
for (let i = 0; i < alts.length; i++) {
129+
cumul += weights[i];
130+
if (r < cumul) return alts[i];
131+
}
132+
return alts[alts.length - 1];
133+
},
134+
};
123135
}
124136
125137
// --- Distribution objects ---

compiler/bet-eval/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ license.workspace = true
88
[dependencies]
99
bet-syntax = { path = "../bet-syntax" }
1010
bet-core = { path = "../bet-core" }
11+
bet-rt = { path = "../../runtime/bet-rt" } # the unified runtime Value (Dist, …)
12+
bet-rand = { path = "../../runtime/bet-rand" } # centralised ternary/weighted RNG
13+
im.workspace = true # bet_rt::Value::List is im::Vector
1114
rand.workspace = true
1215
rand_distr.workspace = true
1316
thiserror.workspace = true

0 commit comments

Comments
 (0)