Skip to content

Commit ff35306

Browse files
committed
kernel: closure-iota at the machine's recursor exit (Phase B)
Port of IxVM try_iota_c (~/ix-aiur 097e9d5). The machine's Const exit no longer reads its spine back when the head is a recursor on the main ctor-rule path: only the major premise materializes (readback + the same full whnf the eager path uses), and on a hit the rule RHS re-enters the machine with params/motives/minors and post-major arguments as their ORIGINAL closures plus the ctor fields wrapped closed. The rule's Lam-chain betas push them straight into an environment, so unselected minors — dropped match/Decidable branches, the UTF-8 codec class — are never substituted and never read back. Off-main-path cases miss to the plain readback exit by construction, preserving semantics exactly: K recursors (ctor synthesis needs infer + def-eq), Nat literal majors (transient-work cache discipline and the linear-rec/offset shortcuts stay on the plain path), Str literal majors, struct-eta candidates, and stuck majors. cheap_rec mode skips machine-iota entirely, mirroring the eager gating. A miss costs one readback of the major; the plain path's whnf of it then hits a warm cache. Machine-native delta (IxVM Phase C1.5) does NOT port at this layer: their machine lives in a whnf that includes delta, while ours sits inside whnf_core, which must stay delta-free for def-eq's lazy-delta unfold ordering. Spanning the whnf delta loop with closures is a separate, larger restructure. 610 kernel + 200 ixon tests pass (new closure-iota-via-beta unit test covers the spine arithmetic incl. post-major shift); native check_one true on the usual five constants; perf_check whole-env failure parity on 11 envs. Guest cycle benchmarks pending alongside Phase A's.
1 parent 32d91ec commit ff35306

1 file changed

Lines changed: 263 additions & 13 deletions

File tree

crates/kernel/src/whnf.rs

Lines changed: 263 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ impl<M: KernelMode> TypeChecker<'_, M> {
652652
// beta firing — Const-headed terms (e.g. literal recursor loops)
653653
// never pay the closure-wrap + readback overhead.
654654
if matches!(f.data(), ExprData::Lam(..)) {
655-
cur = self.machine_whnf(f, &args, &mut fuel)?;
655+
cur = self.machine_whnf(f, &args, &mut fuel, flags)?;
656656
continue;
657657
}
658658

@@ -713,14 +713,12 @@ impl<M: KernelMode> TypeChecker<'_, M> {
713713
head: KExpr<M>,
714714
args: &[KExpr<M>],
715715
fuel: &mut u32,
716+
flags: WhnfFlags,
716717
) -> Result<KExpr<M>, TcError<M>> {
717718
let mut head = head;
718719
let mut env: MEnv<M> = MEnv::empty();
719-
let mut spine: Vec<Arc<Clo<M>>> = args
720-
.iter()
721-
.rev()
722-
.map(|a| Arc::new(Clo::closed(a.clone())))
723-
.collect();
720+
let mut spine: Vec<Arc<Clo<M>>> =
721+
args.iter().rev().map(|a| Arc::new(Clo::closed(a.clone()))).collect();
724722

725723
loop {
726724
match head.data() {
@@ -748,9 +746,12 @@ impl<M: KernelMode> TypeChecker<'_, M> {
748746
}
749747
let ty2 = clo_subst(&mut self.env.intern, ty, &env, 0);
750748
let body2 = clo_subst(&mut self.env.intern, body, &env, 1);
751-
return Ok(
752-
self.intern(KExpr::lam(name.clone(), bi.clone(), ty2, body2)),
753-
);
749+
return Ok(self.intern(KExpr::lam(
750+
name.clone(),
751+
bi.clone(),
752+
ty2,
753+
body2,
754+
)));
754755
}
755756
},
756757

@@ -783,12 +784,43 @@ impl<M: KernelMode> TypeChecker<'_, M> {
783784
}
784785
},
785786

787+
// Closure-iota (Phase B; mirrors IxVM try_iota_c): a recursor
788+
// head consumes the spine LAZILY. On the main ctor-rule path the
789+
// rule RHS re-enters the machine with the params/motives/minors
790+
// and post-major arguments as their ORIGINAL closures (plus the
791+
// ctor's fields wrapped closed) — the rule's Lam-chain betas push
792+
// them straight into an environment, so unselected minors
793+
// (dropped match/Decidable branches) are never substituted and
794+
// never read back. Anything off the main path misses to the
795+
// plain readback exit, whose `try_iota` redoes the major's whnf
796+
// against a warm cache. Gated like the eager path: cheap_rec
797+
// mode never runs full iota from inside the machine.
798+
ExprData::Const(id, us, _) => {
799+
if !flags.cheap_rec {
800+
let id = id.clone();
801+
let us: Vec<KUniv<M>> = us.to_vec();
802+
if let Some((rhs, new_spine)) =
803+
self.try_iota_clo(&id, &us, &spine)?
804+
{
805+
if *fuel == 0 {
806+
return Err(TcError::MaxRecDepth);
807+
}
808+
*fuel -= 1;
809+
head = rhs;
810+
env = MEnv::empty();
811+
spine = new_spine;
812+
continue;
813+
}
814+
}
815+
let h = head.clone();
816+
return Ok(self.machine_exit(h, &spine));
817+
},
818+
786819
// Stuck or dispatch-owned heads (no loose Vars of their own):
787-
// exit; the outer loop applies iota / prim dispatch / LDecl zeta
820+
// exit; the outer loop applies prim dispatch / LDecl zeta
788821
// exactly as before.
789822
ExprData::FVar(..)
790823
| ExprData::Sort(..)
791-
| ExprData::Const(..)
792824
| ExprData::Nat(..)
793825
| ExprData::Str(..) => {
794826
let h = head.clone();
@@ -838,6 +870,133 @@ impl<M: KernelMode> TypeChecker<'_, M> {
838870
cur
839871
}
840872

873+
/// Closure-spine iota for the machine's `Const` exit: the main
874+
/// ctor-rule path of [`Self::try_iota_with_flags`], consuming the
875+
/// machine spine lazily. Returns the level-instantiated rule RHS and
876+
/// the machine spine to re-enter with — the params/motives/minors and
877+
/// post-major arguments ride through as their original closures
878+
/// (never read back; only the major materializes), the ctor's field
879+
/// arguments are wrapped closed.
880+
///
881+
/// Everything off the main path returns `None`, and the caller falls
882+
/// back to the plain readback exit whose `try_iota` is complete:
883+
/// - K recursors (nullary-ctor synthesis needs infer + def-eq),
884+
/// - `Nat` literal majors (the transient-work discipline must keep
885+
/// literal succ-chains out of the interner and the whnf caches, and
886+
/// the linear-rec/offset shortcuts live on the plain path),
887+
/// - `Str` literal majors (ctor coercion + re-whnf),
888+
/// - struct-eta candidates and genuinely stuck majors.
889+
///
890+
/// A miss costs one readback of the major closure; the plain path's
891+
/// own whnf of that major then hits a warm cache.
892+
fn try_iota_clo(
893+
&mut self,
894+
rec_id: &KId<M>,
895+
rec_us: &[KUniv<M>],
896+
spine: &[Arc<Clo<M>>],
897+
) -> Result<Option<(KExpr<M>, Vec<Arc<Clo<M>>>)>, TcError<M>> {
898+
let recr = match self.try_get_const(rec_id)? {
899+
Some(KConst::Recr {
900+
k,
901+
params,
902+
motives,
903+
minors,
904+
indices,
905+
rules,
906+
lvls,
907+
..
908+
}) => {
909+
if k {
910+
return Ok(None);
911+
}
912+
let major_idx = u64_to_usize::<M>(params + motives + minors + indices)?;
913+
if spine.len() <= major_idx {
914+
return Ok(None);
915+
}
916+
// H6: level params arity (lean4lean Reduce.lean:76).
917+
if rec_us.len() as u64 != lvls {
918+
return Ok(None);
919+
}
920+
IotaInfo {
921+
k,
922+
params: u64_to_usize::<M>(params)?,
923+
motives: u64_to_usize::<M>(motives)?,
924+
minors: u64_to_usize::<M>(minors)?,
925+
indices: u64_to_usize::<M>(indices)?,
926+
major_idx,
927+
rules,
928+
lvls,
929+
}
930+
},
931+
_ => return Ok(None),
932+
};
933+
934+
// Materialize ONLY the major. `spine` is nearest-LAST, so the i-th
935+
// argument nearest-first sits at `spine[len - 1 - i]`.
936+
let len = spine.len();
937+
let major_clo = spine[len - 1 - recr.major_idx].clone();
938+
let major = clo_readback(&mut self.env.intern, &major_clo);
939+
// Mirror the eager path's pre- and post-whnf offset cleanups so
940+
// `Nat.add base k` majors expose a `Nat.succ` layer here instead of
941+
// missing to a second full attempt.
942+
let major = match self.cleanup_nat_offset_major(&major)? {
943+
Some(cleaned) => cleaned,
944+
None => major,
945+
};
946+
let mut major_whnf = self.whnf(&major)?;
947+
if matches!(major_whnf.data(), ExprData::Nat(..)) {
948+
return Ok(None);
949+
}
950+
if let Some(cleaned) = self.cleanup_nat_offset_major(&major_whnf)? {
951+
major_whnf = cleaned;
952+
}
953+
if matches!(major_whnf.data(), ExprData::Str(..)) {
954+
return Ok(None);
955+
}
956+
957+
let (ctor_head, ctor_args) = collect_app_spine(&major_whnf);
958+
let ctor_id = match ctor_head.data() {
959+
ExprData::Const(id, _, _) => id.clone(),
960+
_ => return Ok(None),
961+
};
962+
let (cidx, ctor_fields) = match self.try_get_const(&ctor_id)? {
963+
Some(KConst::Ctor { cidx, fields, .. }) => {
964+
(u64_to_usize::<M>(cidx)?, u64_to_usize::<M>(fields)?)
965+
},
966+
_ => return Ok(None),
967+
};
968+
if cidx >= recr.rules.len() {
969+
return Ok(None);
970+
}
971+
// H5: nfields ≤ major args (lean4lean Reduce.lean:75).
972+
if ctor_fields > ctor_args.len() {
973+
return Ok(None);
974+
}
975+
976+
crate::perf::record_iota_histo(&rec_id.addr);
977+
let rule = &recr.rules[cidx];
978+
let rec_us_vec: Vec<_> = rec_us.to_vec();
979+
let rhs = self.instantiate_univ_params(&rule.rhs, &rec_us_vec)?;
980+
981+
// New machine spine, nearest-LAST. Nearest-first the rule sees
982+
// `pmm ++ ctor fields ++ post-major`; in machine order that is the
983+
// post-major prefix of `spine` (indices below the major's slot),
984+
// then the fields reversed, then the pmm suffix of `spine`. The
985+
// index arguments between pmm and the major are dropped, as in the
986+
// eager path.
987+
let pmm_end = recr.params + recr.motives + recr.minors;
988+
let field_start = ctor_args.len() - ctor_fields;
989+
let post_len = len - 1 - recr.major_idx;
990+
let mut new_spine: Vec<Arc<Clo<M>>> =
991+
Vec::with_capacity(post_len + ctor_fields + pmm_end);
992+
new_spine.extend_from_slice(&spine[..post_len]);
993+
for arg in ctor_args[field_start..].iter().rev() {
994+
new_spine.push(Arc::new(Clo::closed(arg.clone())));
995+
}
996+
new_spine.extend_from_slice(&spine[len - pmm_end..]);
997+
Ok(Some((rhs, new_spine)))
998+
}
999+
8411000
/// WHNF without delta: whnf_core → proj-app → nat/native/string → quot.
8421001
/// Projection values use full WHNF, preserving the public/full semantics.
8431002
pub fn whnf_no_delta(
@@ -3672,8 +3831,7 @@ mod tests {
36723831
let body = AE::app(AE::var(1, ()), AE::var(0, ()));
36733832
let lam2 = AE::lam((), (), sort0(), AE::lam((), (), sort0(), body));
36743833
let app = AE::app(lam2, opaque.clone());
3675-
let expected =
3676-
AE::lam((), (), sort0(), AE::app(opaque, AE::var(0, ())));
3834+
let expected = AE::lam((), (), sort0(), AE::app(opaque, AE::var(0, ())));
36773835
assert_eq!(tc.whnf(&app).unwrap(), expected);
36783836
}
36793837

@@ -3702,6 +3860,98 @@ mod tests {
37023860
assert_eq!(tc.whnf(&app).unwrap(), opaque);
37033861
}
37043862

3863+
#[test]
3864+
fn whnf_machine_closure_iota_via_beta() {
3865+
use super::super::constant::RecRule;
3866+
// A beta whose body is a recursor application: the machine's Const
3867+
// exit must take the closure-iota path (rule args ride through as
3868+
// closures) and produce the same result as the eager iota.
3869+
//
3870+
// Unit-like inductive `U` with one ctor `U.mk` (no params/fields)
3871+
// and recursor `U.rec : (motive) (minor) (major) → minor`.
3872+
let u_id = mk_id("Test.U");
3873+
let u_mk_id = mk_id("Test.U.mk");
3874+
let u_rec_id = mk_id("Test.U.rec");
3875+
let mut env = env_with_id();
3876+
env.insert(
3877+
u_id.clone(),
3878+
KConst::Indc {
3879+
name: (),
3880+
level_params: (),
3881+
lvls: 0,
3882+
params: 0,
3883+
indices: 0,
3884+
is_rec: true, // disable struct-eta so the ctor path is the only route
3885+
is_refl: false,
3886+
is_unsafe: false,
3887+
nested: 0,
3888+
block: u_id.clone(),
3889+
member_idx: 0,
3890+
ty: sort0(),
3891+
ctors: vec![u_mk_id.clone()],
3892+
lean_all: (),
3893+
},
3894+
);
3895+
env.insert(
3896+
u_mk_id.clone(),
3897+
KConst::Ctor {
3898+
name: (),
3899+
level_params: (),
3900+
is_unsafe: false,
3901+
lvls: 0,
3902+
induct: u_id.clone(),
3903+
cidx: 0,
3904+
params: 0,
3905+
fields: 0,
3906+
ty: AE::cnst(u_id.clone(), Box::new([])),
3907+
},
3908+
);
3909+
env.insert(
3910+
u_rec_id.clone(),
3911+
KConst::Recr {
3912+
name: (),
3913+
level_params: (),
3914+
k: false,
3915+
is_unsafe: false,
3916+
lvls: 0,
3917+
params: 0,
3918+
indices: 0,
3919+
motives: 1,
3920+
minors: 1,
3921+
block: u_id.clone(),
3922+
member_idx: 0,
3923+
ty: sort0(),
3924+
// rule rhs: λ motive minor. minor
3925+
rules: vec![RecRule {
3926+
ctor: (),
3927+
fields: 0,
3928+
rhs: AE::lam(
3929+
(),
3930+
(),
3931+
sort0(),
3932+
AE::lam((), (), sort0(), AE::var(0, ())),
3933+
),
3934+
}],
3935+
lean_all: (),
3936+
},
3937+
);
3938+
let mut tc = TypeChecker::new(&mut env);
3939+
let rec = AE::cnst(u_rec_id, Box::new([]));
3940+
let mk = AE::cnst(u_mk_id, Box::new([]));
3941+
let opaque = AE::cnst(mk_id("opaque"), Box::new([]));
3942+
// (λ m. U.rec Sort0 m U.mk #2) opaque
3943+
// → machine beta (m := opaque), Const(U.rec) exit → closure-iota
3944+
// → minor = opaque, post-major arg #3 shifts to #2.
3945+
let body = AE::app(
3946+
AE::app(AE::app(AE::app(rec, sort0()), AE::var(0, ())), mk),
3947+
AE::var(3, ()),
3948+
);
3949+
let lam = AE::lam((), (), sort0(), body);
3950+
let app = AE::app(lam, opaque.clone());
3951+
let expected = AE::app(opaque, AE::var(2, ()));
3952+
assert_eq!(tc.whnf(&app).unwrap(), expected);
3953+
}
3954+
37053955
#[test]
37063956
fn whnf_machine_chained_beta() {
37073957
let mut env = env_with_id();

0 commit comments

Comments
 (0)