Skip to content

fix: compute nonut (unsigned) comparison, division and modulo as unsigned (#371) - #391

Merged
leo-aa88 merged 4 commits into
mainfrom
fix/371-nonut-binary-ops
Oct 4, 2026
Merged

leo-aa88 merged 4 commits into
mainfrom
fix/371-nonut-binary-ops

Conversation

@leo-aa88

@leo-aa88 leo-aa88 commented Oct 4, 2026

Copy link
Copy Markdown
Member

Description

A nonut rizz above INT_MAX was compared, divided and taken modulo as a negative signed int:

expression (a = 3000000000) before after
a > 5 0 ("small") 1
a / 2 3647483648 1500000000
a % 7 0 4
a > t (thicc rizz t = 5) 0 1
a + t -1294967291 3000000005
a + 0.5 -1294967295.5 3000000000.5

Root cause. get_expression_type() reports nonut rizz as plain VAR_INT, and its value sits in the same int slot, so nothing told handle_binary_operation() that an operand's bits were unsigned. The one existing check, OP_MOD's node->modifiers.is_unsigned, read the parse-time modifiers of the operation node. Those are only set when the operation happens to be parsed inside a nonut declaration, so % was wrong too in any other position. The code comment in OP_DIVIDE already noted this.

Fix. Add expression_is_unsigned() in ast.c. It is the signedness counterpart of the existing expression_is_long(), resolved from the declared descriptor of each operand form (identifier, array element, *p, struct field, user-function return). On top of it, apply C's usual arithmetic conversions:

  • handle_binary_operation(): unsigned /, %, <, >, <= and >= when either int operand is unsigned and neither is 64-bit. +, -, *, == and != already produce the same bits either way (they wrap through unsigned since [Bug]: int arithmetic (+, -, *, unary -) is signed-overflow UB — interpreter relies on undefined behavior for wrapping #280). Their results now count as unsigned, though, so (a + 1) > 5 and (a * 1) / 2 are right too. The INT_MIN / -1 overflow guard now runs after the unsigned branch, as OP_MOD's already did. 0x80000000 / 0xFFFFFFFF is a well-defined unsigned division and now returns 0 instead of a "most negative rizz" diagnostic. The old dead node->modifiers.is_unsigned check is gone.
  • Float/double promotion: an unsigned int operand widens through unsigned int.
  • evaluate_expression_long(): a 32-bit unsigned value is zero-extended, as C converts unsigned int to long. That makes nonut mixed with thicc/giga compare and add correctly. The 32-bit arithmetic itself still wraps at 32 bits, so (z - 1) + t with z = 0 is 4294967300, as in C.

These follow C exactly, including the less obvious case: a > -1 is now false, because -1 converts to 4294967295.

Not changed, and out of scope:

  • Unsigned function return types. create_function() never records return-type modifiers; it zeroes them, as its comment notes for giga returns too. The NODE_FUNC_CALL branch of expression_is_unsigned() mirrors expression_is_long()'s, and will work once those modifiers are carried.
  • 64-bit unsigned (nonut giga) arithmetic in the long path.
  • Plain conversion of a nonut to chad/gigachad in an initializer or assignment. That is a conversion path, not a binary operation (cf. [Bug]: giga/thicc truncate to 32 bits when converted to chad/gigachad (float/double) #372).

Related Issue

Fixes #371

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Refactor

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have documented my changes in the code or documentation
  • I have added tests that prove my change works, at the lowest appropriate layer (test_cases/*.brainrot for language-visible behavior; a host-side C test wired into make test for internal APIs Brainrot source cannot reach). Required for every bug fix, feature, refactor, performance change, and breaking change — not optional, not "if applicable". See CONTRIBUTING.md ("TESTS ARE MANDATORY").
  • Those tests cover the happy path, the original bug (for fixes), error cases, edge cases, and adversarial cases. If the existing harness could not reach the behavior, I extended it or added a lower-level test.
  • If this PR cannot affect program behavior (docs/comments/license only), I explained that in the Description instead of skipping the items above in silence
  • I have run make format-check locally (or make format to fix)
  • I have run the unit tests locally
  • I have run the valgrind memory tests locally
  • All new and existing tests pass

test_cases/nonut_binary_ops.brainrot covers:

  • both reproductions from the issue;
  • % and all six comparisons;
  • a > -1;
  • nested +/*// results and unary -;
  • the 0x80000000 / 0xFFFFFFFF case;
  • an unsigned value from each source: a parameter, an array element, *p, and a lit nonut rizz struct field and array field;
  • widening into thicc and into float;
  • a goon loop condition;
  • a signed control.

It uses thicc, not giga, for the 64-bit lines, because giga is 32-bit on wasm32 (#177).

make test: 543 passed. make format-check: clean. make valgrind: exit 0, ERROR SUMMARY: 0 and no definite leaks on every fixture.

🤖 Generated with Claude Code

…gned (#371)

A nonut rizz above INT_MAX was compared, divided and taken modulo as a
negative signed int: `3000000000 > 5` was false, `3000000000 / 2` gave
3647483648. Nothing told handle_binary_operation() an operand was
unsigned -- get_expression_type() reports nonut rizz as plain VAR_INT, and
the only check (OP_MOD's node->modifiers.is_unsigned) read the
parse-time modifiers of the operation node, which are almost never set.

Add expression_is_unsigned(), the signedness companion of
expression_is_long(), and apply C's usual arithmetic conversions with it:

- handle_binary_operation(): unsigned division, modulo and <, >, <=, >=
  when either int operand is unsigned (and neither is 64-bit). The
  INT_MIN / -1 overflow guard now sits after the unsigned branch, as
  OP_MOD's already did.
- An unsigned operand widens to float/double through unsigned int.
- evaluate_expression_long() zero-extends a 32-bit unsigned value, so
  mixing a nonut with a thicc/giga compares and adds correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@leo-aa88
leo-aa88 requested a review from SIGMazer as a code owner October 4, 2026 08:43

@leo-aa88 leo-aa88 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST CHANGES

The arithmetic itself is right. It applies C's usual arithmetic conversions as written, including the unintuitive a > -1 → false. The INT_MIN / -1 guard is now ordered after the unsigned branch, which is exactly what the old comment demanded. Zero-extension in evaluate_expression_long() and the unsigned→double widening are correct. Deleting the dead node->modifiers.is_unsigned check is good: it only ever fired when an operation happened to be parsed inside a nonut declaration.

The problem is how signedness is found. expression_is_unsigned() is a fifth partial, per-evaluation, recursive type walker, alongside get_expression_type, get_expression_pointer_level, expression_is_long and the static mirrors. Each covers a different subset of node shapes. That causes one correctness hole and one hot-path regression.

1. *(p + i) on a nonut rizz * is still computed as signed: the issue's exact bug, through the standard pointer-read form

BLOCKING. CONFIRMED.

The description says signedness is "resolved from the declared descriptor of each operand form (identifier, array element, *p, …)". The OP_DEREFERENCE arm handles only a bare identifier operand:

ASTNode *operand = node->data.unary.operand;
if (!operand || operand->type != NODE_IDENTIFIER)
    return false;

So:

nonut rizz xs[2] = {1, 3000000000};
nonut rizz *p = &xs[0];
nonut rizz *q = &xs[1];
yapping("%d", *q > 5);           🚽 1   (fixed)
yapping("%d", *(p + 1) > 5);     🚽 0   (still wrong)
yapping("%u", *(p + 1) / 2);     🚽 3647483648 (the issue's exact wrong output)

*(p + i) is how this language reads through a pointer: it's the form the #389 issue text cites as "what works today". **pp (pointer_level == 1 is required) and anything that isn't a bare name fall through the same way. The same object reads as unsigned or signed depending on how the address was spelled. ಠ_ಠ

The fix isn't another special case in this arm. A dereference yields the pointee of its operand's pointer type, so resolve the descriptor of the pointer expression (through +/- with an integer, as get_expression_pointer_level() already does for the level) and test the pointee. Add *(p + 1) and a **pp case to the fixture. The current fixture only covers *p, the one shape that's special-cased.

2. Every int operation now pays for a recursive signedness walk, about 1.5× slower on plain rizz arithmetic

MAJOR. Measured.

handle_binary_operation() computes is_unsigned for every VAR_INT operation, including +, -, *, == and !=, where your own comment says the bits are identical either way. That is two expression_is_long() calls plus up to two expression_is_unsigned() calls. Both recurse through the whole operand subtree with a get_variable() lookup per identifier, and they run again at every nested operation, so cost grows with depth² per evaluation. evaluate_expression_long() now also runs expression_is_long(node) && expression_is_unsigned(node) on entry, at every recursion level.

A loop of signed rizz arithmetic only (no nonut anywhere), 20k iterations, user time over 5 interleaved runs, same sanitized build flags:

main   0.55 0.60 0.63 0.65 1.01   (median 0.63 s)
#391   0.81 0.92 0.93 0.97 1.31   (median 0.93 s)

That is about +48% on the interpreter's hottest path, paid entirely by programs that never mention nonut. The signedness of an expression is a static property of declared descriptors: it can't change between iterations, yet it is recomputed on each one.

At minimum, compute is_unsigned lazily, only in the arms where it changes the result (/, %, <, >, <=, >=, and float widening). The better fix is to resolve the expression's descriptor once (semantic analysis or first evaluation) and cache it on the node. That would also remove the walker-coverage drift behind finding 1.

3. Minor

  • The NODE_FUNC_CALL arm can never fire today, because create_function() zeroes return modifiers. The PR says so in its description, but nothing in the code does: a reader of expression_is_unsigned() will assume nonut rizz f() works. Put that caveat in a comment in the code.
  • OP_MOD changed behavior in a second way: nonut rizz r = x % 3; with a signed negative x used to compute an unsigned modulo (because of the parse-time node modifier) and now computes a signed one, then converts it. The new behavior is the C-correct one (-7 % 3 == -1 → 4294967295), but it is an observable change no fixture locks in. Add one.

VERDICT

The arithmetic is right; the inference that decides when to use it is incomplete and expensive. The root cause is architectural: there is no single "static descriptor of this expression" function. Each property (type, pointer level, long, now unsigned) gets its own hand-rolled recursive walker over a slightly different subset of node shapes, evaluated again on every execution. Finding 1 is that drift producing a wrong answer, and finding 2 is the same design producing a ~1.5× slowdown on code that doesn't use the feature.

The tests only check that the implementation agrees with itself. Every operand form in the fixture is one the walker special-cases, so a walker that missed *(p + i) passes. Fix #1 with a descriptor-based dereference rule plus fixtures, and at least make is_unsigned lazy for #2. Not mergeable as is.

Comment thread ast.c Outdated
case OP_POST_INC:
case OP_POST_DEC:
return expression_is_unsigned(node->data.unary.operand);
case OP_DEREFERENCE:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING. Only *name is recognized. *(p + 1) > 5 on a nonut rizz *p pointing at 3000000000 still gives 0, and *(p + 1) / 2 gives 3647483648, the issue's exact wrong output. **pp also falls through. Resolve the pointee from the operand's pointer descriptor (through +/- with an integer) instead of special-casing the identifier shape. (Finding 1.)

Comment thread ast.c Outdated
either way; division, modulo and the ordering comparisons do not, so
those arms below branch on this. A 64-bit operand would make the
operation a signed long instead, which is not computed here. */
bool is_unsigned = promoted_type == VAR_INT &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR. This runs for every int op, including +/-/*/==/!=, where the comment above says the result is bit-identical. Two expression_is_long() plus up to two expression_is_unsigned() full-subtree recursions on every evaluation, at every nesting level. Measured about +48% user time on a loop of plain signed rizz arithmetic. Compute this only in the /, %, ordering and float-widening arms, or better, resolve it once and cache it on the node. (Finding 2.)

Comment thread ast.c Outdated
by zero-extension, as C converts unsigned int to long (#371). Its
own arithmetic stays 32-bit, so `u - 1` on 0 is 4294967295 here,
not -1. */
if (!expression_is_long(node) && expression_is_unsigned(node))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR. Two more recursive walks on entry to every evaluate_expression_long() call, and this function recurses, so a nested 64-bit expression pays O(depth²) walks per evaluation. Same fix as in handle_binary_operation(). (Finding 2.)

…ess (#371)

Review of #391:

- `*(p + i)`, `**pp`, `*ptrs[i]` on a `nonut rizz *` were still computed
  as signed: the dereference arm only recognized a bare `*name`. Replace
  the per-shape cases with static_value_descriptor(), which reads the
  declared descriptor of any value-denoting expression -- `*E` is E's
  descriptor one pointer level down, `p ± i` keeps p's -- and test the
  pointee from that.
- Every int operation re-walked both operand subtrees for signedness on
  every evaluation (~2x slower on plain signed arithmetic). Signedness
  follows from declarations, so memoize it on the node
  (ASTNode.signedness_memo), as check_and_mark_identifier() does for its
  symbol check. Back to parity with main on a signed-only loop.
- Note in the code that a call never reads as unsigned yet, since
  create_function() records no return modifiers.
- Fixture: the pointer-expression shapes, and a signed `%` stored into a
  nonut (-7 % 3 -> 4294967295; the old node-modifier check computed an
  unsigned modulo of the signed operand and gave 0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@leo-aa88 leo-aa88 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST CHANGES

Re-review of cc6d106. Both previous findings are fixed properly, at their root causes:

  • *(p + i) signedness — fixed. static_value_descriptor() treats a dereference as "the operand's descriptor one level down", so *(p + 1), *(0 + q), **pp, *ptrs[1] and *(&x) all resolve. My earlier repro now gives 1 / 1500000000, and the fixture covers the shapes the old special case missed.
  • Hot-path cost — fixed. Signedness is memoized on the node. On the same signed-only rizz loop, the gap from main is now inside run-to-run noise: the fastest PR run (0.94 s) beat the fastest main run (1.03 s), where before it was +48%.

The signed-%-into-nonut fixture I asked for is there too.

The memo makes one thing much more important than before: operation_is_unsigned() is now the single authority on whether an operation is done in unsigned int, and it answers wrong in two cases.

1. operation_is_unsigned() trusts literal modifiers it itself calls parser state, so the same expression wraps or doesn't depending on where it's written

BLOCKING. CONFIRMED.

nonut rizz a = 3000000000;
thicc rizz t2 = a * 2;      🚽 6000000000   ← no 32-bit wrap
thicc rizz t3 = 0;
t3 = a * 2;                 🚽 1705032704   ← wraps, as C requires

The expression and target type are identical, but the results differ. In C, a * 2 is unsigned int arithmetic, which wraps (well-defined) to 1705032704 before converting to long. The PR description promises exactly this: "the 32-bit arithmetic itself still wraps at 32 bits ... as in C".

Root cause, from gdb in operation_is_unsigned() for the declaration's a * 2:

node->data.op.right->type      = NODE_INT   (the literal 2)
node->data.op.right->modifiers = { ..., is_long_long = true, ... }

The parser stamped the enclosing thicc declaration's modifiers onto the literal. operation_is_unsigned() asks !expression_is_long(right), expression_is_long()'s NODE_INT arm reads exactly those modifiers, and so the operation is classified as "signed 64-bit" and skips the wrap. The new expression_is_unsigned() comment states the problem itself: "the modifiers create_int_node() stamps on it are the parser's state at the time, not the literal's type". The new function then makes its decision through a helper that reads those same modifiers. ಠ_ಠ

Under the memo, the wrong answer is also permanent for that node.

The fix belongs in the literal's type, not in another special case. A decimal literal is int if its value fits and long/long long otherwise (C11 6.4.4.1). Derive that from the value, in expression_is_long()'s NODE_INT arm or at create_int_node(), so no evaluator ever sees parser state as a type. Add the declaration-initializer form to the fixture: every 64-bit line in it today is inside yapping(...), where the literal is unstamped, so a thicc rizz x = <nonut arithmetic>; initializer is the case it misses.

2. A double-valued operation is classified as unsigned int, which silences an existing diagnostic and returns garbage

MAJOR. CONFIRMED.

nonut rizz a = 3000000000;
thicc rizz t = a + 0.5;
yapping("%lld", t);
  • main: Error: Cannot use double in integer context, then -1294967296.
  • this head: no diagnostic, 2147483648.

operation_is_unsigned() checks pointer levels and 64-bit-ness but never that both operands are integers, so a + 0.5 (computed in double) is classified as "carried out in unsigned int". evaluate_expression_long()'s new early return then routes the double expression through evaluate_expression_int(), an out-of-range double→int conversion, and zero-extends the result. The function's own contract ("whether the binary operation ... is carried out in unsigned int") is false for any float/double operand, and the bad answer is memoized.

Require both operands to be integer-typed (get_expression_type() in the int family) before classifying the operation as unsigned. The correct result, 3000000000, then comes from the long path's existing double handling, or at worst you get back the old diagnostic, instead of a silent wrong value.

VERDICT

The architecture is now right: one declaration-driven descriptor walk, with dereference as a single rule, memoized because the answer is static. Both remaining defects come from the same place. operation_is_unsigned() decides "is this unsigned int arithmetic?" without establishing that both operands are integer-typed ints. It takes expression_is_long()'s parser-state view of literals as truth, and it never checks for floating operands. Because the answer is now memoized and is the only authority, a wrong classification silently yields a wrong value on every evaluation (implementation, resting on a pre-existing literal-typing flaw this PR newly depends on).

The tests check the implementation against itself. No fixture puts nonut arithmetic in a 64-bit declaration initializer or mixes a nonut operand with a floating literal, and those are exactly the two places it breaks. Fix the literal typing and the operand-type guard, add both cases to nonut_binary_ops, and this is close to mergeable. Not mergeable as is.

Comment thread ast.c
(is_int_arithmetic_op(op) || is_int_comparison_op(op)) &&
get_expression_pointer_level(node->data.op.left) == 0 &&
get_expression_pointer_level(node->data.op.right) == 0 &&
!expression_is_long(node->data.op.left) &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING. expression_is_long()'s NODE_INT arm reads the literal's parser-stamped modifiers, which expression_is_unsigned()'s own comment says are parser state, not the literal's type. Inside thicc rizz t2 = a * 2; the literal 2 carries is_long_long = true (checked in gdb), so this classifies the op as signed-64, skips the 32-bit wrap, and memoizes that: t2 = 6000000000, while the identical t3 = a * 2; gives the C-correct 1705032704. Type literals by value (C11 6.4.4.1). (Finding 1.)

Comment thread ast.c
Memoized on the node like check_and_mark_identifier()'s symbol check:
signedness follows from declarations, so it cannot change between
evaluations, and every int operation asks. */
static bool operation_is_unsigned(ASTNode *node)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR. Nothing here requires both operands to be integers, so a + 0.5 (a double operation) is memoized as "carried out in unsigned int". thicc rizz t = a + 0.5; then silently yields 2147483648, where main at least reported Cannot use double in integer context. Gate on both operands being int-family typed. (Finding 2.)

Comment thread ast.c
by zero-extension, as C converts unsigned int to long (#371). Its
own arithmetic stays 32-bit, so `u - 1` on 0 is 4294967295 here,
not -1. */
if (expression_is_unsigned(node))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR. With finding 2's misclassification, this early return sends a double-valued expression through evaluate_expression_int(), an out-of-range double→int conversion, and zero-extends the result. Its correctness depends entirely on operation_is_unsigned() never saying yes to a floating operation, which it currently does. (Finding 2.)

…d int (#371)

Second review of #391:

- create_int_node()/create_long_node() stamped the enclosing
  declaration's parser modifiers onto the literal, so in
  `thicc rizz t = a * 2;` the `2` read as 64-bit, expression_is_long()
  classified `a * 2` as a signed long and skipped the unsigned 32-bit wrap
  (6000000000), while the same `t = a * 2;` wrapped (1705032704). A
  literal's width and signedness now come from its value (C11 6.4.4.1):
  int if it fits, long long otherwise, never unsigned.
- operation_is_unsigned() never required integer operands, so `a + 0.5`
  was memoized as unsigned int arithmetic and evaluate_expression_long()
  sent the double through evaluate_expression_int(). Require both
  operands to be int-family typed.
- evaluate_expression_long() now converts a floating operation's double
  value (as C does) instead of re-evaluating its operands as integers,
  which reported "Cannot use double in integer context" for any
  `thicc rizz t = x + 0.5;` and dropped the double operand.
- Fixture: nonut arithmetic in a thicc initializer vs an assignment, and
  a nonut + double operation converted to thicc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@leo-aa88 leo-aa88 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@leo-aa88
leo-aa88 merged commit 4988c99 into main Oct 4, 2026
11 checks passed
@leo-aa88
leo-aa88 deleted the fix/371-nonut-binary-ops branch October 4, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: nonut (unsigned) comparison, division, +, -, * are computed as signed int

1 participant