Iter 18c.1: Term::Clone schema (no inc emission yet)

Adds (clone X) as a new Term variant. Form-A:
  (let p (expensive_fn x)
    (do consume_a (clone p))    ; explicit RC inc — needs 18c.3
    (do consume_b p))

Schema floor for the LLM-aware RC design (Decision 10): explicit
clone is the author-visible alternative to implicit sharing. In
18c.1 the variant is purely additive — typechecker treats it as
identity (same type as inner), codegen lowers it to the inner
term's IR with no extra calls. Iter 18c.3 will replace the
codegen identity-arm with a single call:

  Term::Clone { value } =>
      let v = self.lower_term(value, ...)?;
      self.emit("call void @ailang_rc_inc(ptr {v})");
      Ok(v)

Match-arm count: ~10 sites across 6 files, all mechanical
structural recursion through `value`. The codegen `lower_term`
arm carries an explicit comment marking the 18c.3 emission seam.

Hash invariant: `(clone X)` uses a new serde tag "clone" that no
pre-18c.1 fixture contains. Every existing fixture's canonical
JSON is bit-identical (git diff examples/ empty).

New fixture examples/clone_demo.{ailx,ail.json}:
  (let x 42 (do io/print_int (clone x)))
Stdout 42 under both --alloc=gc and --alloc=rc.

Tail-position propagates through clone (verify_tail_positions
treats `(clone tail-call)` as itself a tail call), per the
identity-of-clone semantics in 18c.1.

Verified:
- cargo build --workspace clean
- cargo test --workspace green: 52 e2e (+1), 14 surface parse
  (+2 for clone tests), all other buckets unchanged
- ail render round-trip exact
- clone_demo --alloc=gc → 42; --alloc=rc → 42
This commit is contained in:
2026-05-08 02:26:12 +02:00
parent 5cd0a3400a
commit 7dfc88a4f3
12 changed files with 221 additions and 2 deletions
+5
View File
@@ -969,6 +969,11 @@ fn walk_term(
scope.remove(name);
}
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity for dependency-walk
// purposes. The wrapper introduces no new symbols.
walk_term(value, out, builtins, scope);
}
}
}
+12
View File
@@ -1319,6 +1319,18 @@ fn alloc_rc_produces_same_stdout_as_gc() {
assert_eq!(stdout_rc.trim(), "42");
}
/// Iter 18c.1: `Term::Clone` is a pure schema addition. In 18c.1 the
/// wrapper is identity for both typechecker (same type as inner) and
/// codegen (same SSA reg, no extra IR) — programs that contain
/// `(clone X)` produce the same stdout they would have produced
/// without the wrapper. Iter 18c.3 will replace the codegen
/// passthrough with `call void @ailang_rc_inc` under `--alloc=rc`.
#[test]
fn clone_demo_is_identity_in_18c1() {
let stdout = build_and_run("clone_demo.ail.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
/// fixture (`std_list_demo`) so more allocation sites — folds, maps,
/// cross-module ctors — are exercised under `--alloc=rc`. Same
+12
View File
@@ -1125,6 +1125,11 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> {
verify_tail_positions(body, false)?;
verify_tail_positions(in_term, is_tail)
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity. Tail-position propagates
// through unchanged — `(clone tail-call)` is a tail call.
verify_tail_positions(value, is_tail)
}
}
}
@@ -1626,6 +1631,13 @@ pub(crate) fn synth(
}
in_ty
}
Term::Clone { value } => {
// Iter 18c.1: `(clone X)` typechecks identically to `X`.
// No constraint generated, no environment change. The
// wrapper records author intent for the future RC inc/dec
// emission pass (18c.3); typing is pure passthrough.
synth(value, env, locals, effects, in_def, subst, counter)
}
}
}
+6
View File
@@ -357,6 +357,10 @@ impl<'a> Lifter<'a> {
lhs: Box::new(self.lift_in_term(lhs, locals, in_def)?),
rhs: Box::new(self.lift_in_term(rhs, locals, in_def)?),
}),
Term::Clone { value } => Ok(Term::Clone {
// Iter 18c.1: structural recursion through the wrapper.
value: Box::new(self.lift_in_term(value, locals, in_def)?),
}),
Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.3: post-order traversal — lift any inner
// LetRecs first. Within the body's scope, `name` and
@@ -691,6 +695,8 @@ fn contains_any_letrec(m: &Module) -> bool {
Term::Lam { body, .. } => term_has_letrec(body),
Term::Seq { lhs, rhs } => term_has_letrec(lhs) || term_has_letrec(rhs),
Term::LetRec { .. } => true,
// Iter 18c.1: identity passthrough for the letrec-detection scan.
Term::Clone { value } => term_has_letrec(value),
}
}
for def in &m.defs {
+15
View File
@@ -179,6 +179,11 @@ fn walk(t: &Term, out: &mut NonEscapeSet) {
walk(body, out);
walk(in_term, out);
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity at IR level — only walk
// sub-terms looking for Let-allocation candidates.
walk(value, out);
}
Term::Lit { .. } | Term::Var { .. } => {}
}
}
@@ -338,6 +343,12 @@ fn escapes(t: &Term, tainted: &BTreeSet<String>, in_tail: bool) -> bool {
// declare escape.
true
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity. The wrapper's escape
// verdict is exactly that of its inner term, threaded
// through with the same `in_tail` context.
escapes(value, tainted, in_tail)
}
}
}
@@ -433,6 +444,10 @@ fn collect_free_vars(t: &Term, bound: &mut BTreeSet<String>, out: &mut BTreeSet<
collect_free_vars(body, bound, out);
collect_free_vars(in_term, bound, out);
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity for free-var collection.
collect_free_vars(value, bound, out);
}
}
}
+18
View File
@@ -1154,6 +1154,11 @@ impl<'a> Emitter<'a> {
// here is a bug.
unreachable!("Term::LetRec eliminated by desugar")
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity. Iter 18c.3 will emit
// `call void @ailang_rc_inc` here under --alloc=rc.
self.lower_term(value)
}
}
}
@@ -2308,6 +2313,11 @@ impl<'a> Emitter<'a> {
// Iter 16b.1: eliminated by desugar before codegen.
unreachable!("Term::LetRec eliminated by desugar")
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity for capture analysis —
// free vars of `(clone X)` are exactly the free vars of `X`.
Self::collect_captures(value, bound, captures, captures_set, builtins, top_level);
}
}
}
@@ -2814,6 +2824,10 @@ impl<'a> Emitter<'a> {
// Iter 16b.1: eliminated by desugar before codegen.
unreachable!("Term::LetRec eliminated by desugar")
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity — same type as inner.
self.synth_with_extras(value, extras)
}
}
}
}
@@ -3203,6 +3217,10 @@ fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> Term {
// monomorphisation pass runs.
unreachable!("Term::LetRec eliminated by desugar")
}
Term::Clone { value } => Term::Clone {
// Iter 18c.1: structural recursion through the wrapper.
value: Box::new(apply_subst_to_term(value, subst)),
},
}
}
+9
View File
@@ -291,6 +291,15 @@ pub enum Term {
lhs: Box<Term>,
rhs: Box<Term>,
},
/// Iter 18c.1: explicit RC clone. Lowers identically to its inner
/// term in 18c.1; in 18c.3 the codegen will emit
/// `call void @ailang_rc_inc(ptr %v)` before returning `%v` under
/// `--alloc=rc`. The variant is additive: `(clone X)` round-trips
/// through every pre-18c.1 fixture without their hashes changing
/// because none of them use the new tag.
Clone {
value: Box<Term>,
},
}
/// One arm of a [`Term::Match`].
+26
View File
@@ -320,6 +320,10 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet<String>) {
collect_used_in_term(body, used);
collect_used_in_term(in_term, used);
}
Term::Clone { value } => {
// Iter 18c.1: identity for the used-name walk.
collect_used_in_term(value, used);
}
}
}
@@ -502,6 +506,11 @@ impl Desugarer {
lhs: Box::new(self.desugar_term(lhs, scope)),
rhs: Box::new(self.desugar_term(rhs, scope)),
},
Term::Clone { value } => Term::Clone {
// Iter 18c.1: pure structural recursion through the
// wrapper. Same pattern as `Term::Let`'s value branch.
value: Box::new(self.desugar_term(value, scope)),
},
Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.1: lift to a synthetic top-level fn (no-capture).
// Iter 16b.2: extend the lift to the path-1 safe subset —
@@ -1109,6 +1118,10 @@ pub fn free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut BTreeSet<
b_in.insert(name.clone());
free_vars_in_term(in_term, &b_in, out);
}
Term::Clone { value } => {
// Iter 18c.1: `(clone X)` has the same free vars as `X`.
free_vars_in_term(value, bound, out);
}
}
}
@@ -1244,6 +1257,10 @@ pub fn subst_var(t: &Term, from: &str, to: &str) -> Term {
in_term: Box::new(in_rw),
}
}
Term::Clone { value } => Term::Clone {
// Iter 18c.1: structural recursion through the wrapper.
value: Box::new(subst_var(value, from, to)),
},
}
}
@@ -1356,6 +1373,10 @@ pub fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[Stri
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
in_term: Box::new(subst_call_with_extras(in_term, name, lifted, extras)),
},
Term::Clone { value } => Term::Clone {
// Iter 18c.1: structural recursion through the wrapper.
value: Box::new(subst_call_with_extras(value, name, lifted, extras)),
},
}
}
@@ -1404,6 +1425,8 @@ pub fn find_non_callee_use(t: &Term, name: &str) -> Option<Term> {
}
Term::LetRec { body, in_term, .. } => find_non_callee_use(body, name)
.or_else(|| find_non_callee_use(in_term, name)),
// Iter 18c.1: clone is identity for the non-callee scan.
Term::Clone { value } => find_non_callee_use(value, name),
}
}
@@ -1444,6 +1467,7 @@ mod tests {
Term::LetRec { body, in_term, .. } => {
any_nested_ctor(body) || any_nested_ctor(in_term)
}
Term::Clone { value } => any_nested_ctor(value),
}
}
@@ -1467,6 +1491,7 @@ mod tests {
Term::Lam { body, .. } => any_let_rec(body),
Term::Seq { lhs, rhs } => any_let_rec(lhs) || any_let_rec(rhs),
Term::LetRec { .. } => true,
Term::Clone { value } => any_let_rec(value),
}
}
@@ -2566,6 +2591,7 @@ mod tests {
Term::LetRec { body, in_term, .. } => {
any_lit_pattern(body) || any_lit_pattern(in_term)
}
Term::Clone { value } => any_lit_pattern(value),
}
}
+83 -2
View File
@@ -37,7 +37,7 @@
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
//! | app-term | tail-app-term | match-term | ctor-term
//! | do-term | tail-do-term | seq-term | lam-term | if-term
//! | let-term | let-rec-term
//! | let-term | let-rec-term | clone-term
//! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom
@@ -62,6 +62,7 @@
//! type-attr
//! body-attr
//! "(" "in" term ")" ")"
//! clone-term ::= "(" "clone" term ")" ; Iter 18c.1
//!
//! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild
//! pat-var ::= ident
@@ -752,6 +753,7 @@ impl<'a> Parser<'a> {
"if" => self.parse_if(),
"let" => self.parse_let(),
"let-rec" => self.parse_let_rec(),
"clone" => self.parse_clone(),
other => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
Err(ParseError::Production {
@@ -759,7 +761,7 @@ impl<'a> Parser<'a> {
message: format!(
"unknown term head `{other}`; expected one of \
`app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \
`tail-do`, `seq`, `term-ctor`, `lit-unit`"
`tail-do`, `seq`, `term-ctor`, `clone`, `lit-unit`"
),
pos,
})
@@ -1033,6 +1035,37 @@ impl<'a> Parser<'a> {
})
}
/// Iter 18c.1: `(clone TERM)` — explicit RC clone wrapper. In 18c.1
/// the wrapper is identity for typechecker and codegen; in 18c.3
/// the codegen will emit `call void @ailang_rc_inc` before yielding
/// the inner value's SSA reg under `--alloc=rc`.
fn parse_clone(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("clone-term")?;
self.expect_keyword("clone")?;
// Exactly one inner term, then `)`.
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production {
production: "clone-term",
message: "clone expects exactly one term argument".into(),
pos,
});
}
let inner = self.parse_term()?;
if !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production {
production: "clone-term",
message: "clone expects exactly one term argument".into(),
pos,
});
}
self.expect_rparen("clone-term")?;
Ok(Term::Clone {
value: Box::new(inner),
})
}
// ---- patterns -------------------------------------------------------
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
@@ -1231,6 +1264,54 @@ mod tests {
);
}
/// Iter 18c.1: `(clone X)` parses to `Term::Clone { value: Var "x" }`.
#[test]
fn parses_clone_wraps_inner_term() {
let m = parse(
r#"
(module m
(fn id
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body (clone x))))
"#,
)
.unwrap();
let body = match &m.defs[0] {
Def::Fn(fd) => &fd.body,
_ => panic!("expected fn"),
};
match body {
Term::Clone { value } => match value.as_ref() {
Term::Var { name } => assert_eq!(name, "x"),
other => panic!("expected Var inside Clone, got {other:?}"),
},
other => panic!("expected Clone, got {other:?}"),
}
}
/// Iter 18c.1: `(clone)` with no inner term is rejected with a
/// clear message.
#[test]
fn rejects_clone_without_argument() {
let err = parse(
r#"
(module m
(fn id
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body (clone))))
"#,
)
.err()
.expect("parse should fail");
let msg = format!("{err}");
assert!(
msg.contains("clone expects exactly one term argument"),
"diagnostic should explain clone's arity, got: {msg}"
);
}
/// Iter 16b.1: minimal `(let-rec ...)` round-trips through the
/// parser into a `Term::LetRec` whose `name`, `params`, `body` and
/// `in_term` line up with the source.
+8
View File
@@ -362,6 +362,14 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
write_term(out, rhs, level);
out.push(')');
}
Term::Clone { value } => {
// Iter 18c.1: print as `(clone <inner>)`. The wrapper is
// identity at typecheck/codegen in 18c.1; only authored
// intent is recorded for the future inc/dec emission pass.
out.push_str("(clone ");
write_term(out, value, level);
out.push(')');
}
}
}
+1
View File
@@ -0,0 +1 @@
{"defs":[{"body":{"body":{"args":[{"t":"clone","value":{"name":"x","t":"var"}}],"op":"io/print_int","t":"do"},"name":"x","t":"let","value":{"lit":{"kind":"int","value":42},"t":"lit"}},"doc":"Use `(clone x)` on a let-bound Int; print it.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"clone_demo","schema":"ailang/v0"}
+26
View File
@@ -0,0 +1,26 @@
; Iter 18c.1 — `(clone X)` schema floor.
;
; The `clone` form is an additive `Term` variant. In 18c.1 it is
; identity for both the typechecker and the codegen — `(clone X)`
; has the same type as `X` and lowers to the same SSA reg with no
; extra IR. Iter 18c.3 will replace the codegen passthrough with
; `call void @ailang_rc_inc(ptr %v)` under `--alloc=rc`; for now
; the wrapper's only role is to record author intent.
;
; This fixture wraps a let-bound `Int` in `(clone x)` at its single
; use site. The inner term is a primitive value (Int) so RC inc is
; meaningless even in 18c.3 — the point is purely that the schema
; round-trips and the binary still produces the expected `42`.
;
; Expected stdout:
; 42
(module clone_demo
(fn main
(doc "Use `(clone x)` on a let-bound Int; print it.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let x 42
(do io/print_int (clone x))))))