A bool arm and a dyn arm of an if meet at dyn with the bool boxed, so (or false (box "s")) answers "s"

This commit is contained in:
Joseph Ferano 2026-09-25 15:31:12 +07:00
parent f6ba1e6c4d
commit 23af8a4dd3
5 changed files with 43 additions and 19 deletions

View File

@ -848,13 +848,6 @@ CLOSED: [2026-09-20]
typed conditions stay strict =bool=. =and= and =or= hand back the operand that typed conditions stay strict =bool=. =and= and =or= hand back the operand that
decided them, Clojure's rule, through a desugaring that evaluates each test once. decided them, Clojure's rule, through a desugaring that evaluates each test once.
** NEXT A bool arm and a dyn arm joining as dyn
Decided 2026-09-25: they join as =dyn=, the =bool= boxed — Clojure's rule, so =(or false (box "s"))= answers ="s"=.
With both arms of a desugared =and=/=or= holding real values, a non-bool =dyn= on
the losing side meets the strict =bool= boundary and traps —
=(or false (box "s"))= is the case. Whether a =bool= arm and a =dyn= arm should
join as =dyn= is the author's call and is not settled.
** NEXT A truthiness failure re-runs the whole failing subtree ** NEXT A truthiness failure re-runs the whole failing subtree
Decided 2026-09-25: fix it without changing any message — the retry reuses what the first pass settled for each subtree (memoised by node), so nested =not= is linear. Test with a deep nest that must fail fast and with the existing message tests unchanged. Decided 2026-09-25: fix it without changing any message — the retry reuses what the first pass settled for each subtree (memoised by node), so nested =not= is linear. Test with a deep nest that must fail fast and with the existing message tests unchanged.
The retry exists to keep a refused literal's message unchanged and re-runs the The retry exists to keep a refused literal's message unchanged and re-runs the

View File

@ -5998,6 +5998,34 @@ and check_if ctx ?(tail = false) ?want loc c t e =
want = None want = None
&& (match t.Tast.ty with Types.Slice _ | Types.Ptr _ -> true | _ -> false) && (match t.Tast.ty with Types.Slice _ | Types.Ptr _ -> true | _ -> false)
in in
(* A bool arm and a dyn arm meet at dyn, the bool boxed — Clojure's rule,
so (or false (box "s")) answers "s" rather than unboxing the string at
bool and trapping. The other order already met at dyn, the then arm
deciding. So after a bool then arm the else arm is checked on its own
terms first, since checking it at bool is what unboxes it, and kept
when it is a bool or a dyn. Anything else is abandoned and checked at
bool as before, for that path's messages. A chain whose arms all fit
is checked once; a refused one re-checks each level below the refusal
once more, the square of its depth. *)
let own_else =
if want = None && t.Tast.ty = Types.Bool then
match
trial ctx (fun () ->
let v = branch ctx (fun () -> in_tail (fun () -> check ctx e)) in
match v.Tast.ty with
| Types.Bool | Types.Dyn | Types.Never -> v
| _ -> raise (Loc.Error (Loc.diag v.Tast.loc "not bool or dyn")))
with
| Ok v -> Some v
| Error _ -> None
else None
in
let t =
match own_else with
| Some v when v.Tast.ty = Types.Dyn ->
expect ctx t.Tast.loc ~want:(Some Types.Dyn) t
| _ -> t
in
let ewant = let ewant =
match want with match want with
| Some _ -> want | Some _ -> want
@ -6019,6 +6047,9 @@ and check_if ctx ?(tail = false) ?want loc c t e =
location; and with an expectation in hand both arms are checked against location; and with an expectation in hand both arms are checked against
it rather than against each other, so nothing here runs. *) it rather than against each other, so nothing here runs. *)
let e = let e =
match own_else with
| Some v -> v
| None ->
match branch ctx (fun () -> in_tail (fun () -> check ctx ?want:ewant e)) with match branch ctx (fun () -> in_tail (fun () -> check ctx ?want:ewant e)) with
| v -> v | v -> v
| exception Loc.Error d | exception Loc.Error d

View File

@ -1182,17 +1182,10 @@ and cond f (args : Form.t list) : Ast.expr =
[f.loc] would blame the enclosing (and ...) for whichever operand is [f.loc] would blame the enclosing (and ...) for whichever operand is
actually wrong. actually wrong.
What answering the operand costs, for both forms alike: the two arms are The two arms are both real values, so mixing a dyn operand with a typed
now both real values, so mixing a dyn operand with a typed bool one makes bool one makes check_if unify them: a bool arm and a dyn arm meet at dyn
check_if unify them, and the then arm decides. A non-bool dyn value on with the bool boxed, whichever side each is on, so (or false (box "s"))
the losing side then meets the strict bool boundary at run time — answers "s" and (and (box nil) some-bool) answers nil.
(or false (box "s")) and (and (box nil) some-bool) both trap, verified on
this tree. Each form used to be safe in exactly one of those directions,
because the sentinel it answered was a bool literal that boxed to fit
whatever the real branch was; neither is now, and they are at least
symmetric about it. Making bool and dyn arms join as dyn is a check_if
question, noted in TODO.org, "A bool arm and a dyn arm joining as dyn",
and not decided here.
One known wart, measured rather than guessed, and left alone deliberately. One known wart, measured rather than guessed, and left alone deliberately.
In a want-free position — [(println (and true true (vec-new i32)))] — the In a want-free position — [(println (and true true (vec-new i32)))] — the

View File

@ -92,6 +92,13 @@
;; used to trap trying to unbox "x" as a strict bool. ;; used to trap trying to unbox "x" as a strict bool.
(println (or (box nil) (box "x"))) (println (or (box nil) (box "x")))
(println (or (box 5) (box "unreached"))) (println (or (box 5) (box "unreached")))
;; A typed bool operand beside a dyn one: the two meet at dyn, the bool
;; boxed, so the dyn one comes back whichever side of the if it lands on.
(println (or false (box "s")))
(println (or (= 1 2) (box nil)))
(println (and (box nil) (= 1 1)))
(println (and true (box "y")))
(println (or true (box "unreached")))
;; One operand is that operand, whatever it is -- no test, no sentinel. ;; One operand is that operand, whatever it is -- no test, no sentinel.
(println (and (box nil))) (println (and (box nil)))

View File

@ -1928,7 +1928,7 @@ let () =
let dyn_if_truthy_out = let dyn_if_truthy_out =
"falsey\nfalsey\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\n\ "falsey\nfalsey\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\n\
truthy\ntruthy\ntruthy\nwhen 0 ran\nwhen empty-string ran\nb\nb\n\ truthy\ntruthy\ntruthy\nwhen 0 ran\nwhen empty-string ran\nb\nb\n\
:kw\nfalse\nnil\n\n0\nfalse\nx\n5\n\ :kw\nfalse\nnil\n\n0\nfalse\nx\n5\ns\nnil\nnil\ny\ntrue\n\
nil\n\nnil\n0\ntrue\nfalse\n\ nil\n\nnil\n0\ntrue\nfalse\n\
nil\nand-reached\n2\n7\nor-reached\n1\n\ nil\nand-reached\n2\n7\nor-reached\n1\n\
and-decider\nnil\nor-decider\n9\n\ and-decider\nnil\nor-decider\n9\n\