From 41d406bcbb27664a86974b39c83b426969bc287f Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 7 May 2026 17:45:33 +0200 Subject: [PATCH] Iter 14g: Term::If restored (revert of 14d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconsidered 14d's removal of Term::If. The decision was wrong. "No redundancies" requires judgment; reducibility (if -> match) is not redundancy in the strong sense. Term::If is a primitive control-flow shape; bool branching is the second most common shape after sequencing, and removing it cost 3x tokens on every branch site (`(if c a b)` 4 tokens vs the match-on-Bool form 12 tokens). Meta-pattern fixed: I had been treating user observations as directives. User said "if is a subset of match"; I jumped to remove it citing CLAUDE.md, with no independent conviction. The leak appeared in 14f's JOURNAL prose ("three lines for what if used to do in one"), which read as regret. Two feedback memories saved (memory/feedback_user_suggestions_not_directives, memory/feedback_no_nostalgia_for_removed_features) to head this off. Implementation: mechanical reverse-application of 14d's diff at every site (AST, check including the 14e tail-position arm, codegen 4 sites, surface parser/printer, pretty, CLI walker, e2e test mutation). Removed lower_bool_match helper — it existed only because 14d's migration shape needed codegen for non-ptr match scrutinees; with Term::If back, match-on-Bool returns to its pre-14d unsupported state. Three fixtures (sum, sort, max3) restored to pre-14d shape. gc_stress (added in 14f) also migrated back to (if ...) since it was authored under the wrong constraint. 14e (musttail) and 14f (GC_malloc) verified intact in IR. Hashes restored to pre-14d values: - sum.sum: db33f57cb329935e - sort.insert: 697fcb9f30f8633a - max3.max: 65c45d6a45dd0a72 - max3.max3: 624b14429bf302f5 All other defs across all 18 fixtures keep their post-14f hashes. Tests 80/80 green; cargo doc 0 warnings. LOC delta +265/-295 net -30. DESIGN.md Decision 7 preserved with a "Status: REVERTED" header for audit trail. Form-(A) `if-term` production restored. Plan: back to 15a (std_maybe stdlib module). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ail/src/main.rs | 5 + crates/ail/tests/e2e.rs | 12 +- crates/ailang-check/src/lib.rs | 42 +++--- crates/ailang-codegen/src/lib.rs | 216 ++++++++++++----------------- crates/ailang-core/src/ast.rs | 7 + crates/ailang-core/src/hash.rs | 7 +- crates/ailang-core/src/pretty.rs | 18 +++ crates/ailang-surface/src/parse.rs | 21 ++- crates/ailang-surface/src/print.rs | 9 ++ docs/DESIGN.md | 12 +- docs/JOURNAL.md | 93 +++++++++++++ examples/gc_stress.ail.json | 2 +- examples/gc_stress.ailx | 17 +-- examples/max3.ail.json | 94 +++++-------- examples/sort.ail.json | 64 ++++----- examples/std_maybe.ailx | 64 +++++++++ examples/sum.ail.json | 38 ++--- 17 files changed, 424 insertions(+), 297 deletions(-) create mode 100644 examples/std_maybe.ailx diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 77703d7..aca1bf0 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -878,6 +878,11 @@ fn walk_term( scope.remove(name); } } + Term::If { cond, then, else_ } => { + walk_term(cond, out, builtins, scope); + walk_term(then, out, builtins, scope); + walk_term(else_, out, builtins, scope); + } Term::Do { op, args, .. } => { // Mark effect ops as `effect:io/print_int` so they can be // separated from normal function calls. diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 6505b39..5cf85ae 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -249,11 +249,8 @@ fn diff_detects_changed_def() { let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src_a = workspace.join("examples").join("sum.ail.json"); - // Variant: load sum.ail.json, mutate the true-arm body of the - // top-level `match` in `sum` (literal 0 → literal 1). `main` stays - // bit-identical. After Iter 14d the body is `Match` (not `If`); the - // first arm is the `(lit-bool true)` arm, formerly the `then` - // branch. + // Variant: load sum.ail.json, mutate the `then` branch (1 instead of 0) + // in the `sum` definition. `main` stays bit-identical. let raw = std::fs::read(&src_a).expect("read sum.ail.json"); let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json"); { @@ -263,11 +260,12 @@ fn diff_detects_changed_def() { .expect("defs array"); for def in defs.iter_mut() { if def.get("name").and_then(|n| n.as_str()) == Some("sum") { - let new_body = serde_json::json!({ + // Replace the then branch literal 0 → literal 1. + let new_then = serde_json::json!({ "t": "lit", "lit": { "kind": "int", "value": 1 } }); - def["body"]["arms"][0]["body"] = new_body; + def["body"]["then"] = new_then; } } } diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 1a17a95..757f6da 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -962,6 +962,11 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> { verify_tail_positions(value, false)?; verify_tail_positions(body, is_tail) } + Term::If { cond, then, else_ } => { + verify_tail_positions(cond, false)?; + verify_tail_positions(then, is_tail)?; + verify_tail_positions(else_, is_tail) + } Term::Seq { lhs, rhs } => { verify_tail_positions(lhs, false)?; verify_tail_positions(rhs, is_tail) @@ -1118,6 +1123,14 @@ fn synth( } Ok(r) } + Term::If { cond, then, else_ } => { + let c = synth(cond, env, locals, effects, in_def, subst, counter)?; + unify(&Type::bool_(), &c, subst)?; + let t1 = synth(then, env, locals, effects, in_def, subst, counter)?; + let t2 = synth(else_, env, locals, effects, in_def, subst, counter)?; + unify(&t1, &t2, subst)?; + Ok(subst.apply(&t1)) + } Term::Do { op, args, .. } => { let sig = env .effect_ops @@ -1695,13 +1708,8 @@ mod tests { check(&m).expect("wildcard must satisfy exhaustiveness"); } - /// Iter 14d: `Term::If` was removed in favour of `Term::Match` on - /// `Bool`. Mismatched arm types in the migration shape (lit-bool - /// `true` arm + `wild` fallback arm) must still surface as a - /// type-mismatch diagnostic — the unification across arms is what - /// the old `if_branches_must_match` test guarded. #[test] - fn match_arms_must_unify() { + fn if_branches_must_match() { let m = Module { schema: SCHEMA.into(), name: "t".into(), @@ -1714,24 +1722,14 @@ mod tests { effects: vec![], }, vec![], - Term::Match { - scrutinee: Box::new(Term::Lit { + Term::If { + cond: Box::new(Term::Lit { lit: Literal::Bool { value: true }, }), - arms: vec![ - Arm { - pat: Pattern::Lit { - lit: Literal::Bool { value: true }, - }, - body: Term::Lit { - lit: Literal::Int { value: 1 }, - }, - }, - Arm { - pat: Pattern::Wild, - body: Term::Lit { lit: Literal::Unit }, - }, - ], + then: Box::new(Term::Lit { + lit: Literal::Int { value: 1 }, + }), + else_: Box::new(Term::Lit { lit: Literal::Unit }), }, )], }; diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 54574fa..3adba84 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -816,6 +816,87 @@ impl<'a> Emitter<'a> { self.locals.pop(); r } + Term::If { cond, then, else_ } => { + let (cond_v, cond_ty) = self.lower_term(cond)?; + if cond_ty != "i1" { + return Err(CodegenError::Internal(format!( + "if cond not i1: {cond_ty}" + ))); + } + let id = self.fresh_id(); + let then_lbl = format!("then.{id}"); + let else_lbl = format!("else.{id}"); + let join_lbl = format!("join.{id}"); + + self.body.push_str(&format!( + " br i1 {cond_v}, label %{then_lbl}, label %{else_lbl}\n" + )); + + self.start_block(&then_lbl); + let (then_v, then_ty) = self.lower_term(then)?; + // Iter 14e: a tail-call in this branch already terminated + // its block; skip its branch to join and exclude from phi. + let then_terminated = self.block_terminated; + let then_block_end = self.current_block.clone(); + if !then_terminated { + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } + + self.start_block(&else_lbl); + let (else_v, else_ty) = self.lower_term(else_)?; + let else_terminated = self.block_terminated; + let else_block_end = self.current_block.clone(); + if !then_terminated && !else_terminated && then_ty != else_ty { + return Err(CodegenError::Internal(format!( + "if branches type mismatch: {then_ty} vs {else_ty}" + ))); + } + if !else_terminated { + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } + + // Iter 14e: if both branches terminated, the whole `if` is + // terminated and no join is reachable. Mark and bail. + if then_terminated && else_terminated { + self.block_terminated = true; + return Ok(("0".into(), then_ty)); + } + // If exactly one branch terminated, the join receives only + // the other branch's value — no phi node is needed. + if then_terminated { + self.start_block(&join_lbl); + return Ok((else_v, else_ty)); + } + if else_terminated { + self.start_block(&join_lbl); + return Ok((then_v, then_ty)); + } + + self.start_block(&join_lbl); + let phi = self.fresh_ssa(); + self.body.push_str(&format!( + " {phi} = phi {ty} [ {tv}, %{tlbl} ], [ {ev}, %{elbl} ]\n", + ty = then_ty, + tv = then_v, + tlbl = then_block_end, + ev = else_v, + elbl = else_block_end, + )); + // Iter 7: if both branches yield the same fn-pointer sig, + // forward it to the phi SSA so subsequent indirect calls + // can resolve. + if then_ty == "ptr" { + if let (Some(ts), Some(es)) = + (self.ssa_fn_sigs.get(&then_v), self.ssa_fn_sigs.get(&else_v)) + { + if ts.params == es.params && ts.ret == es.ret { + let merged = ts.clone(); + self.ssa_fn_sigs.insert(phi.clone(), merged); + } + } + } + Ok((phi, then_ty)) + } Term::App { callee, args, tail } => { // Direct call when the callee is a `Var` referring to a // statically-known target (builtin, current-module fn, @@ -962,16 +1043,6 @@ impl<'a> Emitter<'a> { ) -> Result<(String, String)> { let s_ail = self.synth_arg_type(scrutinee)?; let (s_val, s_ty) = self.lower_term(scrutinee)?; - // Iter 14d: Bool scrutinee. `Term::If` was retired and migrated to - // a `Match` of shape `(lit-bool true) -> A | _ -> B`. Recognise - // exactly that two-arm pattern (the canonical migration target; - // see DESIGN.md Decision 7) and emit the conditional-branch IR - // the old `Term::If` arm used to emit. We do not generalise to - // arbitrary primitive matches — only the shape that replaces - // `If`. - if s_ty == "i1" { - return self.lower_bool_match(&s_val, arms); - } if s_ty != "ptr" { return Err(CodegenError::Internal(format!( "match on non-ADT scrutinee (got {s_ty}); MVP supports only ADTs" @@ -1184,120 +1255,6 @@ impl<'a> Emitter<'a> { Ok((phi, rt)) } - /// Iter 14d: lower a `Match` whose scrutinee is `Bool` (`i1`) and - /// whose arms are exactly the canonical migration shape from - /// retired `Term::If`: - /// - /// 1. `(pat-lit (lit-bool C)) -> A` - /// 2. `_ -> B` - /// - /// Emits the same IR the old `Term::If` arm of `lower_term` did: - /// `br i1` to a `then`/`else` pair joined by a phi. Both arms must - /// produce the same LLVM type (the typechecker has unified them - /// already; the assertion is a defence-in-depth check). - fn lower_bool_match( - &mut self, - cond_v: &str, - arms: &[Arm], - ) -> Result<(String, String)> { - if arms.len() != 2 { - return Err(CodegenError::Internal(format!( - "Bool-scrutinee match: expected exactly 2 arms (lit-bool + wild), got {}", - arms.len() - ))); - } - let (true_body, false_body) = match (&arms[0].pat, &arms[1].pat) { - ( - Pattern::Lit { lit: Literal::Bool { value: true } }, - Pattern::Wild, - ) => (&arms[0].body, &arms[1].body), - ( - Pattern::Lit { lit: Literal::Bool { value: false } }, - Pattern::Wild, - ) => (&arms[1].body, &arms[0].body), - _ => { - return Err(CodegenError::Internal( - "Bool-scrutinee match: arms must be `(lit-bool true) -> A | _ -> B` \ - or `(lit-bool false) -> A | _ -> B` (the Iter 14d migration shape \ - for retired `Term::If`)".into(), - )); - } - }; - - let id = self.fresh_id(); - let then_lbl = format!("then.{id}"); - let else_lbl = format!("else.{id}"); - let join_lbl = format!("join.{id}"); - - self.body.push_str(&format!( - " br i1 {cond_v}, label %{then_lbl}, label %{else_lbl}\n" - )); - - self.start_block(&then_lbl); - let (then_v, then_ty) = self.lower_term(true_body)?; - // Iter 14e: a tail-call in this arm already terminated its - // block; skip its branch to join and exclude from phi. - let then_terminated = self.block_terminated; - let then_block_end = self.current_block.clone(); - if !then_terminated { - self.body.push_str(&format!(" br label %{join_lbl}\n")); - } - - self.start_block(&else_lbl); - let (else_v, else_ty) = self.lower_term(false_body)?; - let else_terminated = self.block_terminated; - let else_block_end = self.current_block.clone(); - if !then_terminated && !else_terminated && then_ty != else_ty { - return Err(CodegenError::Internal(format!( - "Bool-match arms type mismatch: {then_ty} vs {else_ty}" - ))); - } - if !else_terminated { - self.body.push_str(&format!(" br label %{join_lbl}\n")); - } - - // Iter 14e: if both arms terminated, the whole match is - // terminated and no join is reachable. Mark and bail. - if then_terminated && else_terminated { - self.block_terminated = true; - return Ok(("0".into(), then_ty)); - } - // If exactly one arm terminated, the join receives only the - // other arm's value — no phi node is needed. - if then_terminated { - self.start_block(&join_lbl); - return Ok((else_v, else_ty)); - } - if else_terminated { - self.start_block(&join_lbl); - return Ok((then_v, then_ty)); - } - - self.start_block(&join_lbl); - let phi = self.fresh_ssa(); - self.body.push_str(&format!( - " {phi} = phi {ty} [ {tv}, %{tlbl} ], [ {ev}, %{elbl} ]\n", - ty = then_ty, - tv = then_v, - tlbl = then_block_end, - ev = else_v, - elbl = else_block_end, - )); - // If both branches yield the same fn-pointer sig, forward it - // to the phi SSA so subsequent indirect calls can resolve. - if then_ty == "ptr" { - if let (Some(ts), Some(es)) = - (self.ssa_fn_sigs.get(&then_v), self.ssa_fn_sigs.get(&else_v)) - { - if ts.params == es.params && ts.ret == es.ret { - let merged = ts.clone(); - self.ssa_fn_sigs.insert(phi.clone(), merged); - } - } - } - Ok((phi, then_ty)) - } - fn lower_app(&mut self, name: &str, args: &[Term], tail: bool) -> Result<(String, String)> { // Built-in arithmetic / comparison. if let Some((instr, ret_ty)) = builtin_binop(name) { @@ -1874,6 +1831,11 @@ impl<'a> Emitter<'a> { bound.remove(name); } } + Term::If { cond, then, else_ } => { + Self::collect_captures(cond, bound, captures, captures_set, builtins, top_level); + Self::collect_captures(then, bound, captures, captures_set, builtins, top_level); + Self::collect_captures(else_, bound, captures, captures_set, builtins, top_level); + } Term::Do { args, .. } => { for a in args { Self::collect_captures(a, bound, captures, captures_set, builtins, top_level); @@ -2214,6 +2176,7 @@ impl<'a> Emitter<'a> { new_extras.push((name.clone(), v_ail)); self.synth_with_extras(body, &new_extras) } + Term::If { then, .. } => self.synth_with_extras(then, extras), Term::Do { op, .. } => builtin_effect_op_ret(op).ok_or_else(|| { CodegenError::Internal(format!( "synth_arg_type: unknown effect op `{op}`" @@ -2520,6 +2483,11 @@ fn apply_subst_to_term(t: &Term, subst: &BTreeMap) -> Term { value: Box::new(apply_subst_to_term(value, subst)), body: Box::new(apply_subst_to_term(body, subst)), }, + Term::If { cond, then, else_ } => Term::If { + cond: Box::new(apply_subst_to_term(cond, subst)), + then: Box::new(apply_subst_to_term(then, subst)), + else_: Box::new(apply_subst_to_term(else_, subst)), + }, Term::Do { op, args, tail } => Term::Do { op: op.clone(), args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(), diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 734ce49..bcd3799 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -219,6 +219,13 @@ pub enum Term { value: Box, body: Box, }, + /// If-expression. Both branches must have the same type. + If { + cond: Box, + then: Box, + #[serde(rename = "else")] + else_: Box, + }, /// Effect operation invocation (e.g. `do print "hi"`). The `op` is /// resolved against the effect-handler table at link time. /// diff --git a/crates/ailang-core/src/hash.rs b/crates/ailang-core/src/hash.rs index df946c0..d851a7c 100644 --- a/crates/ailang-core/src/hash.rs +++ b/crates/ailang-core/src/hash.rs @@ -99,11 +99,6 @@ mod tests { /// `skip_serializing_if` is missing or wrong. We deserialise the /// real example modules from disk to avoid drift between the test /// and the source-of-truth JSON. - /// - /// Iter 14d note: the `sum` def was migrated from `Term::If` to - /// `Term::Match` on Bool. The hash for `sum.sum` therefore changed - /// intentionally (Decision 7). The pin updated below tracks the - /// new identity. `IntList` from `list.ail.json` did not change. #[test] fn iter13a_schema_extension_preserves_pre_13a_hashes() { let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -113,7 +108,7 @@ mod tests { .expect("examples/sum.ail.json present"); let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap(); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); - assert_eq!(def_hash(sum_def), "7f5fe7f72c63a9fd"); + assert_eq!(def_hash(sum_def), "db33f57cb329935e"); let list_src = std::fs::read(examples.join("list.ail.json")) .expect("examples/list.ail.json present"); diff --git a/crates/ailang-core/src/pretty.rs b/crates/ailang-core/src/pretty.rs index 3a7875d..55bbc07 100644 --- a/crates/ailang-core/src/pretty.rs +++ b/crates/ailang-core/src/pretty.rs @@ -193,6 +193,16 @@ fn term_block(t: &Term, indent: usize) -> String { s.push(')'); s } + Term::If { cond, then, else_ } => { + let mut s = format!("{pad}(if\n"); + s.push_str(&term_block(cond, indent + 2)); + s.push('\n'); + s.push_str(&term_block(then, indent + 2)); + s.push('\n'); + s.push_str(&term_block(else_, indent + 2)); + s.push(')'); + s + } Term::Do { op, args, .. } => { let mut s = format!("{pad}(do {op}"); for a in args { @@ -327,6 +337,14 @@ fn term_inline(t: &Term) -> String { term_inline(body) ) } + Term::If { cond, then, else_ } => { + format!( + "(if {} {} {})", + term_inline(cond), + term_inline(then), + term_inline(else_) + ) + } Term::Lam { params, .. } => { format!("(\\ {} ...)", params.join(" ")) } diff --git a/crates/ailang-surface/src/parse.rs b/crates/ailang-surface/src/parse.rs index fc310f7..1e1d808 100644 --- a/crates/ailang-surface/src/parse.rs +++ b/crates/ailang-surface/src/parse.rs @@ -35,7 +35,8 @@ //! //! 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 | let-term +//! | do-term | tail-do-term | seq-term | lam-term | if-term +//! | let-term //! var-ref ::= ident ; reserved: true/false → bool-lit //! int-lit ::= integer ; numeric atom //! str-lit ::= string ; string atom @@ -53,6 +54,7 @@ //! "(" "ret" type ")" //! effects-clause? body-attr ")" //! typed-param ::= "(" "typed" ident type ")" +//! if-term ::= "(" "if" term term term ")" //! let-term ::= "(" "let" ident term term ")" //! //! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild @@ -686,6 +688,7 @@ impl<'a> Parser<'a> { "tail-do" => self.parse_tail_do(), "seq" => self.parse_seq(), "lam" => self.parse_lam(), + "if" => self.parse_if(), "let" => self.parse_let(), other => { let pos = self.peek().map(|t| t.span.start).unwrap_or(0); @@ -693,7 +696,7 @@ impl<'a> Parser<'a> { production: "term", message: format!( "unknown term head `{other}`; expected one of \ - `app`, `tail-app`, `lam`, `let`, `match`, `do`, \ + `app`, `tail-app`, `lam`, `let`, `if`, `match`, `do`, \ `tail-do`, `seq`, `term-ctor`, `lit-unit`" ), pos, @@ -912,6 +915,20 @@ impl<'a> Parser<'a> { }) } + fn parse_if(&mut self) -> Result { + self.expect_lparen("if-term")?; + self.expect_keyword("if")?; + let cond = self.parse_term()?; + let then = self.parse_term()?; + let else_ = self.parse_term()?; + self.expect_rparen("if-term")?; + Ok(Term::If { + cond: Box::new(cond), + then: Box::new(then), + else_: Box::new(else_), + }) + } + fn parse_let(&mut self) -> Result { self.expect_lparen("let-term")?; self.expect_keyword("let")?; diff --git a/crates/ailang-surface/src/print.rs b/crates/ailang-surface/src/print.rs index ad45ae7..5d206be 100644 --- a/crates/ailang-surface/src/print.rs +++ b/crates/ailang-surface/src/print.rs @@ -245,6 +245,15 @@ fn write_term(out: &mut String, t: &Term, level: usize) { write_term(out, body, level); out.push(')'); } + Term::If { cond, then, else_ } => { + out.push_str("(if "); + write_term(out, cond, level); + out.push(' '); + write_term(out, then, level); + out.push(' '); + write_term(out, else_, level); + out.push(')'); + } Term::Do { op, args, tail } => { // Iter 14e: `tail-do` mirrors `tail-app` for effect ops. out.push_str(if *tail { "(tail-do " } else { "(do " }); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 040bcdc..846f913 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -446,6 +446,13 @@ JSON identical to their corresponding `.ail.json` files. ## Decision 7: redundancy removal — `Term::If` is not a primitive +**Status: REVERTED in Iter 14g.** This decision was made on shaky grounds — +applying CLAUDE.md's "no redundancies" rule to a case that turned out to +be primitive control flow, not redundancy. The post-removal match-on-Bool +form (3× the tokens, asymmetric `pat-wild` for the false case) was +worse for token economy and worse for the natural shape of the language. +`Term::If` is restored. The text below is preserved for the audit trail. + `Term::If { cond, then, else_ }` is semantically a subset of `Term::Match` on `Bool`. Per CLAUDE.md the language must contain no redundancies; two AST nodes for the same operation produces an @@ -645,6 +652,7 @@ hashes stay bit-identical. { "t": "var", "name": "" } { "t": "app", "fn": Term, "args": [Term...] } { "t": "let", "name": "", "value": Term, "body": Term } +{ "t": "if", "cond": Term, "then": Term, "else": Term } { "t": "do", "op": "/", "args": [Term...] } { "t": "ctor", "type": "", "ctor": "", "args": [Term...] } { "t": "match", "scrutinee": Term, "arms": [Arm...] } @@ -740,9 +748,7 @@ as iterations land; the JOURNAL records the exact iteration. What **is** supported (and used as the smoke test for the pipeline): - Int, Bool, Unit, **Str** as primitive types. -- `let`, function calls, recursion. Bool branching is expressed via - `match` on `Bool` with a `(lit-bool true)` arm and a wildcard - fallback (Decision 7); there is no separate `if` AST node. +- `if`, `let`, function calls, recursion. - Effects on function signatures, with `do op(args)` for direct effect ops (`io/print_int`, `io/print_bool`, `io/print_str`). - **ADTs + flat pattern matching** (Iter 3). Sub-patterns of a Ctor diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index f48e9d0..e8118f1 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -2098,6 +2098,99 @@ under `examples/std/` as `.ailx` source; tests load the generated `.ail.json`. `ailang-check` and `ailang-codegen` remain projection-agnostic. +## Iter 14g — `Term::If` restored (revert of 14d) + +Reconsidered 14d's removal of `Term::If`. The decision was wrong; +restored. + +**Why 14d was wrong.** "No redundancies" from CLAUDE.md is a real +rule but it requires judgment to apply. `Term::If` reduces to +`Term::Match` on Bool, but reducibility is not redundancy in a +strong sense — `1 + 1` reduces to `2`, you don't remove `+` from +the language because of it. `Term::If` is a primitive control- +flow shape that every programming language has for good reason: +bool branching is the second most common control flow shape after +sequencing. + +**Quantitative.** `(if c a b)` is 4 tokens. The post-14d +replacement `(match c (case (pat-lit true) a) (case (pat-wild) b))` +is 12. That's a 3× token-economy hit on every Bool branch — in +exactly the language whose authoring constraint was supposed to +be token-efficient. The match-on-Bool form is also asymmetric +(false case via `pat-wild` because the typechecker rejects +`pat-lit false` as exhaustive) and structurally lopsided. + +**Meta-pattern that produced the wrong call.** I had been treating +user observations as directives. The user said "if is a subset of +match" — which is a factual observation; I jumped to remove it, +citing CLAUDE.md as cover. There was no independent conviction +behind the change, only doctrinal hooking-up of a user remark. +The leak showed up in 14f's JOURNAL prose ("three lines for what +`if` used to do in one"), which the user correctly read as me +regretting the decision. + +Two feedback memories saved to head this off in future iters +(`/home/brummel/.claude/projects/-home-brummel-dev-ailang/memory/`): +- `feedback_user_suggestions_not_directives.md` — observations + are input, not output. Form an opinion before acting. +- `feedback_no_nostalgia_for_removed_features.md` — describe + canonical form on its own merits, not as compensation for + what was deleted. + +**Implementation (revert).** Mechanically reverse-applied 14d's +diff at every site (`ast.rs`, `check/lib.rs` (incl. the new +14e `verify_tail_positions` arm), `codegen/lib.rs` (4 sites), +`surface/{parse,print}.rs`, `core/pretty.rs`, `ail/main.rs`, +`e2e.rs` test mutation). Removed the `lower_bool_match` helper +that 14d had introduced — it existed only because the 14d +migration shape needed codegen for non-`ptr` match scrutinees; +with `Term::If` back, match-on-Bool returns to its pre-14d +unsupported state and the helper is dead weight. Three fixtures +(`sum`, `sort`, `max3`) restored to their pre-14d shape. + +**One additional fixture migration.** `gc_stress` was authored +in 14f using the 14d match-on-Bool migration shape (because +14f sat between 14d and this revert). After removing +`lower_bool_match` it would have failed to compile. Migrated +`gc_stress.{ail.json,ailx}` to use `(if ...)` directly. Output +unchanged: `1275`. + +**14e and 14f are intact.** Verified by spot-emit of +`list_map_poly`'s IR: `musttail call i8 @ail_list_map_poly_print_list` +and `call ptr @GC_malloc(i64 8)` both present. The revert is +strictly local to `Term::If`-related code paths. + +**Hash check.** All four pre-14d hashes returned: + +| def | restored hash | +|---|---| +| `sum.sum` | `db33f57cb329935e` | +| `sort.insert` | `697fcb9f30f8633a` | +| `max3.max` | `65c45d6a45dd0a72` | +| `max3.max3` | `624b14429bf302f5` | + +Untouched defs across all 18 fixtures (incl. the 14e print_list +hash deltas) keep their post-14f hashes. The revert's hash +movement is exactly the four 14d-migrated defs reverting plus +the one accidental 14f-victim (`gc_stress` defs, never previously +shipped under any other hash). + +**DESIGN.md.** Decision 7 is preserved with a `Status: REVERTED` +header. Audit trail matters; future reads should see the +decision and its reversal both. Form-(A) productions in +Decision 6's appendix have `if-term` restored. + +**Tests: 80/80 green.** Identical stdout for every existing +fixture. `cargo doc --no-deps` 0 warnings. + +**LOC delta.** +265/-295 net −30. Net cleanup: the `lower_bool_match` +helper was bigger than the restored `Term::If` codegen. + +**Plan.** Back to 15a — first stdlib module `std_maybe`. The +brief I had drafted included an "authoring note: post-14d +if-then-else" section that's now obsolete. Re-issue without +that, using `if` naturally where appropriate. + diff --git a/examples/gc_stress.ail.json b/examples/gc_stress.ail.json index fbeac57..57dca34 100644 --- a/examples/gc_stress.ail.json +++ b/examples/gc_stress.ail.json @@ -1 +1 @@ -{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"var","name":"a"},{"args":[{"k":"var","name":"a"}],"k":"con","name":"List"}],"name":"Cons"}],"doc":"Polymorphic singly-linked list (re-declared locally).","kind":"type","name":"List","vars":["a"]},{"body":{"arms":[{"body":{"args":[],"ctor":"Nil","t":"ctor","type":"List"},"pat":{"lit":{"kind":"bool","value":true},"p":"lit"}},{"body":{"args":[{"name":"n","t":"var"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build","t":"var"},"t":"app"}],"ctor":"Cons","t":"ctor","type":"List"},"pat":{"p":"wild"}}],"scrutinee":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"t":"match"},"doc":"Build [n, n-1, ..., 1] :: List Int via Cons recursion.","kind":"fn","name":"build","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Recursively sum a List Int.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":50},"t":"lit"}],"fn":{"name":"build","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build [50..1], sum, print 1275.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"gc_stress","schema":"ailang/v0"} \ No newline at end of file +{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"var","name":"a"},{"args":[{"k":"var","name":"a"}],"k":"con","name":"List"}],"name":"Cons"}],"doc":"Polymorphic singly-linked list (re-declared locally).","kind":"type","name":"List","vars":["a"]},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"name":"n","t":"var"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build","t":"var"},"t":"app"}],"ctor":"Cons","t":"ctor","type":"List"},"t":"if","then":{"args":[],"ctor":"Nil","t":"ctor","type":"List"}},"doc":"Build [n, n-1, ..., 1] :: List Int via Cons recursion.","kind":"fn","name":"build","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Recursively sum a List Int.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"args":[{"k":"con","name":"Int"}],"k":"con","name":"List"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":50},"t":"lit"}],"fn":{"name":"build","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build [50..1], sum, print 1275.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"gc_stress","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/gc_stress.ailx b/examples/gc_stress.ailx index 045c9af..517dede 100644 --- a/examples/gc_stress.ailx +++ b/examples/gc_stress.ailx @@ -2,9 +2,8 @@ ; Builds a List Int of length 50 by recursive Cons construction, ; sums it, prints the sum (1275 = 50*51/2). ; -; Match-on-Int isn't supported as a pattern shape; we drop into a -; match-on-Bool against (== n 0), mirroring the sort fixture's -; (<= y h) idiom. (pat-wild) catches the false case. +; Bool branching uses the canonical `if` form (Decision 7, restored +; in Iter 14g): `(if (== n 0) Nil (Cons n (build (- n 1))))`. (module gc_stress @@ -21,13 +20,11 @@ (ret (con List (con Int))))) (params n) (body - (match (app == n 0) - (case (pat-lit true) - (term-ctor List Nil)) - (case _ - (term-ctor List Cons - n - (app build (app - n 1))))))) + (if (app == n 0) + (term-ctor List Nil) + (term-ctor List Cons + n + (app build (app - n 1)))))) (fn sum_list (doc "Recursively sum a List Int.") diff --git a/examples/max3.ail.json b/examples/max3.ail.json index ca18561..b57964c 100644 --- a/examples/max3.ail.json +++ b/examples/max3.ail.json @@ -17,8 +17,8 @@ }, "params": ["a", "b"], "body": { - "t": "match", - "scrutinee": { + "t": "if", + "cond": { "t": "app", "fn": { "t": "var", "name": ">" }, "args": [ @@ -26,16 +26,8 @@ { "t": "var", "name": "b" } ] }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { "t": "var", "name": "a" } - }, - { - "pat": { "p": "wild" }, - "body": { "t": "var", "name": "b" } - } - ] + "then": { "t": "var", "name": "a" }, + "else": { "t": "var", "name": "b" } } }, { @@ -54,8 +46,8 @@ "params": ["a", "b", "c"], "doc": "Demonstriert verschachteltes if (statt max-call) zum Test des Block-Trackings.", "body": { - "t": "match", - "scrutinee": { + "t": "if", + "cond": { "t": "app", "fn": { "t": "var", "name": ">" }, "args": [ @@ -63,56 +55,32 @@ { "t": "var", "name": "b" } ] }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { - "t": "match", - "scrutinee": { - "t": "app", - "fn": { "t": "var", "name": ">" }, - "args": [ - { "t": "var", "name": "a" }, - { "t": "var", "name": "c" } - ] - }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { "t": "var", "name": "a" } - }, - { - "pat": { "p": "wild" }, - "body": { "t": "var", "name": "c" } - } - ] - } + "then": { + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": ">" }, + "args": [ + { "t": "var", "name": "a" }, + { "t": "var", "name": "c" } + ] }, - { - "pat": { "p": "wild" }, - "body": { - "t": "match", - "scrutinee": { - "t": "app", - "fn": { "t": "var", "name": ">" }, - "args": [ - { "t": "var", "name": "b" }, - { "t": "var", "name": "c" } - ] - }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { "t": "var", "name": "b" } - }, - { - "pat": { "p": "wild" }, - "body": { "t": "var", "name": "c" } - } - ] - } - } - ] + "then": { "t": "var", "name": "a" }, + "else": { "t": "var", "name": "c" } + }, + "else": { + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": ">" }, + "args": [ + { "t": "var", "name": "b" }, + { "t": "var", "name": "c" } + ] + }, + "then": { "t": "var", "name": "b" }, + "else": { "t": "var", "name": "c" } + } } }, { diff --git a/examples/sort.ail.json b/examples/sort.ail.json index 39a0c28..b5cff48 100644 --- a/examples/sort.ail.json +++ b/examples/sort.ail.json @@ -63,8 +63,8 @@ ] }, "body": { - "t": "match", - "scrutinee": { + "t": "if", + "cond": { "t": "app", "fn": { "t": "var", "name": "<=" }, "args": [ @@ -72,47 +72,39 @@ { "t": "var", "name": "h" } ] }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { - "t": "ctor", - "type": "IntList", - "ctor": "Cons", - "args": [ - { "t": "var", "name": "y" }, - { - "t": "ctor", - "type": "IntList", - "ctor": "Cons", - "args": [ - { "t": "var", "name": "h" }, - { "t": "var", "name": "t" } - ] - } - ] - } - }, - { - "pat": { "p": "wild" }, - "body": { + "then": { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "var", "name": "y" }, + { "t": "ctor", "type": "IntList", "ctor": "Cons", "args": [ { "t": "var", "name": "h" }, - { - "t": "app", - "fn": { "t": "var", "name": "insert" }, - "args": [ - { "t": "var", "name": "y" }, - { "t": "var", "name": "t" } - ] - } + { "t": "var", "name": "t" } ] } - } - ] + ] + }, + "else": { + "t": "ctor", + "type": "IntList", + "ctor": "Cons", + "args": [ + { "t": "var", "name": "h" }, + { + "t": "app", + "fn": { "t": "var", "name": "insert" }, + "args": [ + { "t": "var", "name": "y" }, + { "t": "var", "name": "t" } + ] + } + ] + } } } ] diff --git a/examples/std_maybe.ailx b/examples/std_maybe.ailx new file mode 100644 index 0000000..08879fb --- /dev/null +++ b/examples/std_maybe.ailx @@ -0,0 +1,64 @@ +; Iter 15a — first stdlib module: optional values. +; Polymorphic Maybe with four pure combinators. + +(module std_maybe + + (data Maybe (vars a) + (doc "Polymorphic optional value: either Just or Nothing.") + (ctor Nothing) + (ctor Just a)) + + (fn from_maybe + (doc "Project out of Maybe with a default for the Nothing case.") + (type + (forall (vars a) + (fn-type + (params a (con Maybe a)) + (ret a)))) + (params default m) + (body + (match m + (case (pat-ctor Just x) x) + (case (pat-ctor Nothing) default)))) + + (fn is_some + (doc "Returns true iff m is Just<_>.") + (type + (forall (vars a) + (fn-type + (params (con Maybe a)) + (ret (con Bool))))) + (params m) + (body + (match m + (case (pat-ctor Just _) true) + (case (pat-ctor Nothing) false)))) + + (fn is_none + (doc "Returns true iff m is Nothing.") + (type + (forall (vars a) + (fn-type + (params (con Maybe a)) + (ret (con Bool))))) + (params m) + (body + (match m + (case (pat-ctor Just _) false) + (case (pat-ctor Nothing) true)))) + + (fn map_maybe + (doc "Apply f to the wrapped value, or pass Nothing through.") + (type + (forall (vars a b) + (fn-type + (params (fn-type (params a) (ret b)) + (con Maybe a)) + (ret (con Maybe b))))) + (params f m) + (body + (match m + (case (pat-ctor Just x) + (term-ctor Maybe Just (app f x))) + (case (pat-ctor Nothing) + (term-ctor Maybe Nothing)))))) diff --git a/examples/sum.ail.json b/examples/sum.ail.json index 2e5308c..a480b8b 100644 --- a/examples/sum.ail.json +++ b/examples/sum.ail.json @@ -15,8 +15,8 @@ "params": ["n"], "doc": "rekursive Summe 0..=n", "body": { - "t": "match", - "scrutinee": { + "t": "if", + "cond": { "t": "app", "fn": { "t": "var", "name": "==" }, "args": [ @@ -24,36 +24,28 @@ { "t": "lit", "lit": { "kind": "int", "value": 0 } } ] }, - "arms": [ - { - "pat": { "p": "lit", "lit": { "kind": "bool", "value": true } }, - "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } } - }, - { - "pat": { "p": "wild" }, - "body": { + "then": { "t": "lit", "lit": { "kind": "int", "value": 0 } }, + "else": { + "t": "app", + "fn": { "t": "var", "name": "+" }, + "args": [ + { "t": "var", "name": "n" }, + { "t": "app", - "fn": { "t": "var", "name": "+" }, + "fn": { "t": "var", "name": "sum" }, "args": [ - { "t": "var", "name": "n" }, { "t": "app", - "fn": { "t": "var", "name": "sum" }, + "fn": { "t": "var", "name": "-" }, "args": [ - { - "t": "app", - "fn": { "t": "var", "name": "-" }, - "args": [ - { "t": "var", "name": "n" }, - { "t": "lit", "lit": { "kind": "int", "value": 1 } } - ] - } + { "t": "var", "name": "n" }, + { "t": "lit", "lit": { "kind": "int", "value": 1 } } ] } ] } - } - ] + ] + } } }, {