From 4aae272b2d7795fa66d4b93192b14e9408a13688 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Fri, 17 Jul 2026 22:38:02 -0500 Subject: [PATCH] fix(tensor): relu/leaky_relu/softmax/log_softmax accept a scalar (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four returned a silent null — not a value, not an error — when handed a scalar, while every sibling in the same BUILTINS.md tables (sqrt, exp, log, negative, add) is scalar-clean. tensor_to_flat returns NULL for a 0-dim VAL_NUM, and the functions did `if (!flat) return make_null()`. The silent null is the real defect: it propagates until it hits an unrelated operator, so the error surfaces far from the cause — define layer(x) as: return relu of x layer of ([-3, 4]) # [0, 4] layer of -3 # null <- silently poisons (relu of -3) + 1 # Error line N: cannot apply '+' to null and num A generic layer that works on a batch quietly poisons on a single sample. Same shape as the #490-#512 silent-tolerance train. Fix: a scalar fast path at the top of each, the degenerate element-wise case, matching sqrt/exp/log: relu(x) = max(0, x) leaky_relu(x) = x < 0 ? 0.01x : x softmax(x) = 1.0 (one-element normalization) log_softmax(x) = 0.0 (log(1)) Tests: six asserts in test_flat_buffer_tensor.eigs (each fails on the pre-fix null), including that (relu of -3) + 1 no longer poisons downstream. BUILTINS.md notes the scalar behavior for all four. Suite 3078/3078 release, 3082/3082 ASan+UBSan (detect_leaks=1, tally 0). --- docs/BUILTINS.md | 8 ++++---- src/builtins_tensor.c | 14 ++++++++++++++ tests/test_flat_buffer_tensor.eigs | 10 ++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index ae10c556..b49912a2 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -397,10 +397,10 @@ automatically at exit. | `sqrt` | `sqrt of t` | Element-wise square root; negative input returns 0 | | `exp` | `exp of t` | Element-wise e^x; overflow saturates | | `log` | `log of t` | Element-wise natural log; input is floored at 1e-10 | -| `softmax` | `softmax of t` | Row-wise softmax normalization | -| `log_softmax` | `log_softmax of t` | Row-wise log(softmax) | -| `relu` | `relu of t` | Element-wise max(0, x) | -| `leaky_relu` | `leaky_relu of t` | Element-wise max(0.01x, x) | +| `softmax` | `softmax of t` | Row-wise softmax normalization (a scalar is the one-element case → `1.0`) | +| `log_softmax` | `log_softmax of t` | Row-wise log(softmax) (a scalar → `log(1)` = `0.0`) | +| `relu` | `relu of t` | Element-wise max(0, x) (accepts a scalar) | +| `leaky_relu` | `leaky_relu of t` | Element-wise max(0.01x, x) (accepts a scalar) | ### Linear Algebra diff --git a/src/builtins_tensor.c b/src/builtins_tensor.c index 705fe90f..d28f51f3 100644 --- a/src/builtins_tensor.c +++ b/src/builtins_tensor.c @@ -403,6 +403,8 @@ Value* builtin_tensor_matmul(Value *arg) { /* ==== BUILTIN: softmax ==== */ Value* builtin_tensor_softmax(Value *arg) { + /* #632: softmax of a single element normalizes to 1.0. */ + if (arg && arg->type == VAL_NUM) return make_num(1.0); int rows, cols; double *flat = tensor_to_flat(arg, &rows, &cols); if (!flat) return make_null(); @@ -424,6 +426,8 @@ Value* builtin_tensor_log_softmax(Value *arg) { Value *first = arg->data.list.items[0]; if (first->type == VAL_LIST) tensor = first; /* [tensor, dim] form */ } + /* #632: log(softmax(scalar)) = log(1) = 0. */ + if (tensor && tensor->type == VAL_NUM) return make_num(0.0); int rows, cols; double *flat = tensor_to_flat(tensor, &rows, &cols); if (!flat) return make_null(); @@ -442,6 +446,11 @@ Value* builtin_tensor_log_softmax(Value *arg) { /* ==== BUILTIN: relu ==== */ /* relu of tensor → element-wise max(0, x). Works on 1D or 2D. */ Value* builtin_tensor_relu(Value *arg) { + /* #632: a scalar is the degenerate element-wise case, like sqrt/exp/log. */ + if (arg && arg->type == VAL_NUM) { + double x = arg->data.num; + return make_num(x < 0.0 ? 0.0 : x); + } /* flat-buffer fast path: in-place clamp, shape preserved */ if (arg && arg->type == VAL_BUFFER) { Value *res = make_buffer_like(arg); @@ -468,6 +477,11 @@ Value* builtin_tensor_relu(Value *arg) { /* ==== BUILTIN: leaky_relu ==== */ /* leaky_relu of tensor → element-wise max(0.01*x, x). Works on 1D or 2D. */ Value* builtin_tensor_leaky_relu(Value *arg) { + /* #632: scalar is the degenerate element-wise case. */ + if (arg && arg->type == VAL_NUM) { + double x = arg->data.num; + return make_num(x < 0.0 ? 0.01 * x : x); + } int rows, cols; double *flat = tensor_to_flat(arg, &rows, &cols); if (!flat) return make_null(); diff --git a/tests/test_flat_buffer_tensor.eigs b/tests/test_flat_buffer_tensor.eigs index 8fd9bad9..3cd8710e 100644 --- a/tests/test_flat_buffer_tensor.eigs +++ b/tests/test_flat_buffer_tensor.eigs @@ -60,4 +60,14 @@ assert of [rsh[1] == 3, "reshape cols == 3"] assert of [rs[0] == 7.0, "reshape preserves data[0]"] assert of [rs[5] == 9.0, "reshape preserves data[5]"] +# #632: a scalar is the degenerate element-wise case. These four returned a +# silent null before the fix, which poisoned downstream arithmetic at the +# wrong line ((relu of -3) + 1 -> "cannot apply '+' to null and num"). +assert of [(relu of (0 - 3)) == 0.0, "#632 relu of negative scalar is 0"] +assert of [(relu of 5.0) == 5.0, "#632 relu of positive scalar is x"] +assert of [(leaky_relu of (0 - 10.0)) == (0 - 0.1), "#632 leaky_relu of negative scalar is 0.01x"] +assert of [(softmax of 1.0) == 1.0, "#632 softmax of a scalar normalizes to 1"] +assert of [(log_softmax of 1.0) == 0.0, "#632 log_softmax of a scalar is log(1) = 0"] +assert of [((relu of (0 - 3)) + 1) == 1.0, "#632 scalar relu no longer poisons downstream arithmetic"] + print of "PASS: flat-buffer tensors"