Skip to content

Commit 5d32b71

Browse files
committed
feat(types): accommodate Echo types (structured loss) in the type checker
Add the `Echo T` / `EchoR T` type formers from hyperpolymath/echo-types across betlang's type-system surfaces. `Echo T` is a distinct, proof-relevant structured-loss residue over `T`; `EchoR T` is its strict, non-recoverable weakening (reserved — operations deferred). Design (per maintainer direction): - Distinct from the carrier: unify(Echo T, T) and unify(Echo T, EchoR T) both fail; no implicit forgetting Echo T -> T. - Domain-agnostic in core; the canonical betlang introduction site is probabilistic-support retention (a future `sample_echo : Dist T -> Echo T` keeps the draw/branch residue that `sample` marginalises away). - Ghost/proof-relevant residue: erases to T's representation at runtime; no runtime payload yet. Surfaces: - compiler/bet-core/src/types.rs: Type::Echo / Type::EchoR variants. - compiler/bet-check/src/lib.rs: lower `Echo T`/`EchoR T` (type applications) in ast_type_to_core; structural resolve/unify; distinctness via the mismatch fallthrough. Adds 5 unit tests (lowering, distinctness, structural unification, Echo != EchoR, inference recursion) and fixes the test module's missing `Symbol` import (tests never compiled under `cargo check`). `cargo test -p bet-check`: 27 passed. - proofs/BetLang.lean: Ty.echo / Ty.echoR formers (no Expr/HasType use, so Progress/Preservation are unaffected). - spec/SPEC.core.scm + spec/grammar.ebnf: echo-types semantics entry and grammar note. - docs/echo-types.adoc: design + deferred-work writeup. https://claude.ai/code/session_01NGKc4681nuptfQADqreAfc
1 parent 8868194 commit 5d32b71

6 files changed

Lines changed: 252 additions & 0 deletions

File tree

compiler/bet-check/src/lib.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ impl CheckEnv {
107107
Type::Tuple(elems) => {
108108
Type::Tuple(elems.iter().map(|e| self.resolve(e)).collect())
109109
}
110+
Type::Echo(inner) => Type::Echo(Box::new(self.resolve(inner))),
111+
Type::EchoR(inner) => Type::EchoR(Box::new(self.resolve(inner))),
110112
_ => ty.clone(),
111113
}
112114
}
@@ -161,6 +163,15 @@ impl CheckEnv {
161163
}
162164
Ok(())
163165
}
166+
// Echo/EchoR unify only structurally with their own former — never
167+
// with the bare carrier `T`, nor with each other. This keeps the
168+
// "retained loss" distinction: `Echo T` is not `T` with decoration.
169+
(Type::Echo(a_inner), Type::Echo(b_inner)) => {
170+
self.unify(a_inner, b_inner, span)
171+
}
172+
(Type::EchoR(a_inner), Type::EchoR(b_inner)) => {
173+
self.unify(a_inner, b_inner, span)
174+
}
164175

165176
_ => Err(CompileError::TypeMismatch {
166177
expected: format!("{:?}", a),
@@ -887,6 +898,12 @@ fn ast_type_to_core(ty: &bet_syntax::ast::Type) -> Type {
887898
"Dist" if args.len() == 1 => {
888899
Type::Dist(Box::new(ast_type_to_core(&args[0].node)))
889900
}
901+
"Echo" if args.len() == 1 => {
902+
Type::Echo(Box::new(ast_type_to_core(&args[0].node)))
903+
}
904+
"EchoR" if args.len() == 1 => {
905+
Type::EchoR(Box::new(ast_type_to_core(&args[0].node)))
906+
}
890907
_ => Type::Named(base_name),
891908
}
892909
}
@@ -907,6 +924,9 @@ fn ast_type_to_core(ty: &bet_syntax::ast::Type) -> Type {
907924
#[cfg(test)]
908925
mod tests {
909926
use super::*;
927+
// `Symbol` is re-exported at the bet_syntax crate root (not via
928+
// `bet_syntax::ast::*`), so import it explicitly for the tests.
929+
use bet_syntax::Symbol;
910930

911931
/// Helper: create a dummy-spanned expression.
912932
fn dummy(expr: Expr) -> Spanned<Expr> {
@@ -1152,4 +1172,81 @@ mod tests {
11521172
let result = check_expr(&dummy(par), &mut env).expect("TODO: handle error");
11531173
assert_eq!(result, Type::List(Box::new(Type::Float)));
11541174
}
1175+
1176+
// ---- Echo types (structured loss; see hyperpolymath/echo-types) ----
1177+
1178+
/// `Echo T` / `EchoR T` surface syntax (parsed as a type application)
1179+
/// lowers to the dedicated semantic formers.
1180+
#[test]
1181+
fn test_echo_lowering() {
1182+
use bet_syntax::ast::Type as AstType;
1183+
let app = |name: &str, arg: AstType| {
1184+
AstType::App(
1185+
Box::new(Spanned::dummy(AstType::Named(Symbol::intern(name)))),
1186+
vec![Spanned::dummy(arg)],
1187+
)
1188+
};
1189+
assert_eq!(
1190+
ast_type_to_core(&app("Echo", AstType::Named(Symbol::intern("Int")))),
1191+
Type::Echo(Box::new(Type::Int))
1192+
);
1193+
assert_eq!(
1194+
ast_type_to_core(&app("EchoR", AstType::Named(Symbol::intern("Float")))),
1195+
Type::EchoR(Box::new(Type::Float))
1196+
);
1197+
}
1198+
1199+
/// `Echo T` is distinct from `T`: no implicit forgetting in either
1200+
/// direction. This is the load-bearing invariant — if it unified with the
1201+
/// carrier the checker would lose the entire point of retained loss.
1202+
#[test]
1203+
fn test_echo_distinct_from_carrier() {
1204+
let mut env = CheckEnv::new();
1205+
assert!(env.unify(&Type::Echo(Box::new(Type::Int)), &Type::Int, None).is_err());
1206+
assert!(env.unify(&Type::Int, &Type::Echo(Box::new(Type::Int)), None).is_err());
1207+
}
1208+
1209+
/// `Echo T` unifies structurally with `Echo T'` iff `T` unifies with `T'`.
1210+
#[test]
1211+
fn test_echo_unifies_structurally() {
1212+
let mut env = CheckEnv::new();
1213+
env.unify(
1214+
&Type::Echo(Box::new(Type::Int)),
1215+
&Type::Echo(Box::new(Type::Int)),
1216+
None,
1217+
)
1218+
.expect("Echo Int should unify with Echo Int");
1219+
assert!(env
1220+
.unify(
1221+
&Type::Echo(Box::new(Type::Int)),
1222+
&Type::Echo(Box::new(Type::String)),
1223+
None
1224+
)
1225+
.is_err());
1226+
}
1227+
1228+
/// `Echo T` and `EchoR T` are distinct formers (the residue is a strict
1229+
/// weakening, not interchangeable with the full echo).
1230+
#[test]
1231+
fn test_echo_vs_residue_distinct() {
1232+
let mut env = CheckEnv::new();
1233+
assert!(env
1234+
.unify(
1235+
&Type::Echo(Box::new(Type::Int)),
1236+
&Type::EchoR(Box::new(Type::Int)),
1237+
None
1238+
)
1239+
.is_err());
1240+
}
1241+
1242+
/// Unification recurses through the Echo former, so an inference variable
1243+
/// inside an `Echo` is solved.
1244+
#[test]
1245+
fn test_echo_inference_var_recurses() {
1246+
let mut env = CheckEnv::new();
1247+
let a = env.fresh_var();
1248+
env.unify(&Type::Echo(Box::new(a.clone())), &Type::Echo(Box::new(Type::Int)), None)
1249+
.expect("Echo 'a should unify with Echo Int");
1250+
assert_eq!(env.resolve(&Type::Echo(Box::new(a))), Type::Echo(Box::new(Type::Int)));
1251+
}
11551252
}

compiler/bet-core/src/types.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ pub enum Type {
3434
Option(Box<Type>),
3535
/// Result type
3636
Result(Box<Type>, Box<Type>),
37+
/// Echo type: `Echo T` — a proof-relevant *structured-loss* residue over
38+
/// values of type `T` (after `hyperpolymath/echo-types`). Distinct from
39+
/// `T`: no implicit forgetting `Echo T -> T`. Domain-agnostic in core; its
40+
/// canonical betlang introduction site is probabilistic support retention
41+
/// (a draw/branch is marginalised into `T`, while the echo keeps that
42+
/// residue statically). The residue is ghost/proof-relevant — erased at
43+
/// runtime for now.
44+
Echo(Box<Type>),
45+
/// Echo residue: `EchoR T` — the strict, non-recoverable weakening of
46+
/// `Echo T` (no recovery operation is promised). Reserved former; rich
47+
/// operations are deferred until the residue semantics are settled.
48+
EchoR(Box<Type>),
3749
/// Type variable (for inference)
3850
Var(u32),
3951
/// Named type

docs/echo-types.adoc

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
3+
// @taxonomy: docs/echo-types
4+
= Echo Types in BetLang
5+
:toc:
6+
7+
Integration of *Echo Types* — a first-class notion of **structured loss**
8+
(loss that is not total erasure) — into BetLang's type system. Upstream:
9+
https://github.com/hyperpolymath/echo-types[`echo-types`] (Agda, source of
10+
truth) and https://github.com/hyperpolymath/EchoTypes.jl[`EchoTypes.jl`]
11+
(executable finite-domain companion).
12+
13+
== Concept
14+
15+
Given `f : A → B`, the echo at `y : B` is the fibre
16+
`Echo f y := Σ (x : A), (f x ≡ y)` — a *proof-relevant residue* recording
17+
what was lost when `f` collapsed `x` to `y`. The honest post-retraction
18+
core is the Echo functor (`echo-intro`, `map-over`) plus the **residue**
19+
weakening `EchoR` (a strict, non-recoverable lowering).
20+
21+
BetLang is non-dependent, so it does not represent the literal fibre.
22+
Instead it adds two **unary type formers**:
23+
24+
[cols="1,4"]
25+
|===
26+
| Former | Meaning
27+
28+
| `Echo T`
29+
| A `T`-value carrying a proof-relevant residue of retained loss
30+
(potentially recoverability-bearing). *Distinct from `T`.*
31+
32+
| `EchoR T`
33+
| The strict, non-recoverable residue/retraction of `Echo T`. Reserved
34+
former; rich operations deferred.
35+
|===
36+
37+
== Design decisions (this pass)
38+
39+
. **Distinctness.** `Echo T` is *not* `T`. `unify(Echo T, T)` fails, as
40+
does `unify(Echo T, EchoR T)`. There is no implicit forgetting
41+
`Echo T → T`; introduction and projection are explicit. If `Echo T`
42+
unified with `T`, the checker would lose the entire point of retained
43+
loss.
44+
. **Domain-agnostic core, probabilistic-support bridge.** `Echo` is the
45+
general structured-loss former. Its *canonical betlang introduction
46+
site* is probabilistic support retention — but that is the bridge, not
47+
the definition (so future structured-loss uses are not foreclosed).
48+
+
49+
[source]
50+
----
51+
sample : Dist T -> T // marginalises away which draw/branch fired
52+
sample_echo : Dist T -> Echo T // retains that residue (DEFERRED)
53+
----
54+
. **Ghost / erased residue.** The residue is proof-relevant; at runtime
55+
`Echo T` (and `EchoR T`) erase to `T`'s representation. No runtime
56+
payload is added until operations demand it — avoiding premature
57+
commitment to a representation of branch histories, support traces,
58+
seeds, or likelihood paths.
59+
60+
== Where it lives
61+
62+
[cols="1,3"]
63+
|===
64+
| Surface | Change
65+
66+
| `compiler/bet-core/src/types.rs`
67+
| `Type::Echo(Box<Type>)`, `Type::EchoR(Box<Type>)`.
68+
69+
| `compiler/bet-check/src/lib.rs`
70+
| Lowering `Echo T` / `EchoR T` (parsed as type applications) in
71+
`ast_type_to_core`; structural `resolve`/`unify`; distinctness enforced
72+
by the existing mismatch fallthrough. Unit tests cover lowering,
73+
distinctness, structural unification, `Echo`≠`EchoR`, and inference
74+
recursion.
75+
76+
| `proofs/BetLang.lean`
77+
| `Ty.echo`, `Ty.echoR` constructors. Type *formers* only — no `Expr`
78+
introduces/eliminates them and `HasType` assigns no expression an echo
79+
type, so Progress/Preservation are unaffected.
80+
81+
| `spec/SPEC.core.scm`, `spec/grammar.ebnf`
82+
| `echo-types` semantics entry (incl. the canonical statement) and a
83+
grammar note. `Echo T` already parses as an application type.
84+
|===
85+
86+
== Deferred (next passes)
87+
88+
* Introduction/elimination forms: `echo_intro : T -> Echo T`,
89+
`proj₁ : Echo T -> T`, `echo_to_residue : Echo T -> EchoR T`.
90+
* The residue-retaining `sample_echo` / `bet_echo` operations and their
91+
typing rules.
92+
* Lean typing rules + metatheory for the echo operations once the runtime
93+
residue representation is settled.
94+
* Runtime residue payload (only when an operation requires it).
95+
96+
== References
97+
98+
* `proofs/agda/Echo.agda`, `EchoResidue.agda`,
99+
`EchoProbabilisticSupport.agda` (upstream echo-types).
100+
* `EchoTypes.jl` — the finite-domain executable shadow.

proofs/BetLang.lean

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ inductive Ty : Type where
3131
| unit : Ty
3232
| arrow : Ty → Ty → Ty
3333
| dist : Ty → Ty
34+
-- Echo types (structured loss; see `hyperpolymath/echo-types`). `echo T` is
35+
-- a proof-relevant retained-loss residue over `T`, distinct from `T`;
36+
-- `echoR T` is its strict, non-recoverable weakening. These are type
37+
-- *formers* only at this stage: no `Expr` constructor introduces or
38+
-- eliminates them and `HasType` assigns no expression an echo type, so the
39+
-- carrier's metatheory (Progress/Preservation below) is unaffected. The
40+
-- canonical betlang introduction site (probabilistic-support retention,
41+
-- `Dist T → echo T`) and its typing rules are deferred to a later pass.
42+
| echo : Ty → Ty
43+
| echoR : Ty → Ty
3444
deriving DecidableEq, Repr
3545

3646
/-- BetLang core expressions using de Bruijn indices. -/

spec/SPEC.core.scm

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,35 @@
131131
"Debugging stochastic behavior"
132132
"Conformance testing"))))
133133

134+
;;=========================================================================
135+
;; TYPE SYSTEM EXTENSION: echo types (structured loss)
136+
;;=========================================================================
137+
(echo-types
138+
. ((origin . "hyperpolymath/echo-types (Agda, source of truth); EchoTypes.jl (executable companion)")
139+
(canonical-statement
140+
. "Echo T is a distinct proof-relevant structured-loss type whose first canonical betlang introduction form is probabilistic support retention: sampling or betting may erase branch/draw information into T, while the echo form retains that residue statically.")
141+
142+
(formers
143+
. ((Echo . ((arity . 1)
144+
(meaning . "Echo T : a T-value carrying a proof-relevant residue of retained loss; potentially recoverability-bearing")
145+
(surface-syntax . "Echo T (parsed as a type application, like `Dist T`)")))
146+
(EchoR . ((arity . 1)
147+
(meaning . "EchoR T : the strict, non-recoverable residue/retraction of Echo T; no recovery operation is promised")
148+
(status . "reserved former; rich operations deferred")))))
149+
150+
(typing-rules
151+
. ((distinctness . "Echo T is NOT T: unify(Echo T, T) fails and unify(Echo T, EchoR T) fails. No implicit forgetting Echo T -> T.")
152+
(structural . "Echo T ~ Echo T' iff T ~ T'; likewise EchoR T ~ EchoR T'.")
153+
(domain-agnostic . "Echo is the general structured-loss former in core; the probabilistic-support bridge is its canonical betlang integration, not its definition.")))
154+
155+
(canonical-bridge
156+
. ((sample . "sample : Dist T -> T (marginalises away which draw/branch produced the value)")
157+
(echo-companion . "sample_echo / bet_echo : Dist T -> Echo T (retains that residue) -- DEFERRED")))
158+
159+
(runtime . "Residue is ghost / proof-relevant: Echo T and EchoR T erase to T's representation in codegen for now (no runtime payload until operations demand it).")
160+
161+
(mechanisation . "Type formers Ty.echo / Ty.echoR in proofs/BetLang.lean; Type::Echo / Type::EchoR in compiler/bet-core/src/types.rs; lowered + unified (distinctly) in compiler/bet-check.")))
162+
134163
;;=========================================================================
135164
;; TESTING REQUIREMENTS
136165
;;=========================================================================

spec/grammar.ebnf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ type = arrow_type;
210210
arrow_type = application_type, [ "->", arrow_type ];
211211
212212
application_type = atom_type, { atom_type };
213+
(* Recognised built-in type constructors are written as applications, e.g.
214+
`Dist T`, `List T`, `Echo T`, `EchoR T`. `Echo T` / `EchoR T` are the
215+
structured-loss formers (see SPEC.core.scm > echo-types); `Echo T` is a
216+
distinct type from `T`. *)
213217
214218
atom_type = named_type
215219
| type_variable

0 commit comments

Comments
 (0)