Pewarta Proxy

📋 Server Request & Proxy Logs

Loading active proxy settings...
Developed by Khairul Adha (adhexa.id)
Mengambil log server...

⚙️ Pengaturan Proxy Target

Skip to content

Fix Scalar infix expressions with a literal operand - #654

Open
eriknw wants to merge 4 commits into
47c-import-values-lengthfrom
52-scalar-infix-literal-operand
Open

eriknw wants to merge 4 commits into
47c-import-values-lengthfrom
52-scalar-infix-literal-operand

Conversation

@eriknw

@eriknw eriknw commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Stacked on #643. Four behavior fixes, each with before and after in its commit message. All
are on main: the first three since "Computation between scalars" (#358, first released in
2023.1.0), the fourth since #517.

A literal keeps its side of a Scalar's & and |. x & s and x | s, with a literal
x and a Scalar s, built the expression with s on the left, so the op applied to it ran with
its operands swapped. Every op that does not commute gave wrong values, silently:

s = Scalar.from_value(5)
binary.minus(1 & s)        # was 4, now -4
binary.truediv(1 | s)      # was 5.0, now 0.2
binary.first(1 & s)        # was 5, now 1
binary.lt(1 & s)           # was False, now True
binary.minus(1 & (2 & s))  # was 2, now 4
binary.minus(1 | e, left_default=0, right_default=10)  # e empty: was -1, now -9

The literal now stays where it was written, as it is, and the op applied to the expression
types it beside the Scalar with literals._literal_operand, as lit - s and op(lit, s)
type it: a Python number is weak, so binary.minus(2 & int8_s) is INT8 and
binary.minus(0.5 & int8_s) is FP64. It is not converted when & builds the expression,
because its type depends on the op: 300 beside INT8 is an OverflowError for minus and is
compared as it is by lt, and converting int8_s & 2 with the literal's own dtype would
make it INT64 under every op, or cut 0.5 to 0 in an integer UDT. op(x & y) now asks the
expression to call its left operand's method (InfixExprBase._call_left), so a Scalar
expression can pass a literal there.

Two more errors with a literal operand: (s & True).new() and (False | s).new() raised
AttributeError: 'bool' object has no attribute 'dtype' and now compute (or raise the
usual TypeError for a dtype other than BOOL), and s & object() raises TypeError at once,
as s + object() does, instead of giving an expression that failed only when an op was
applied.

lit - s and the like apply minus(lit, s), not rminus(s, lit). The reflected
operators computed the op's commutes_to with the Scalar first, which fails where no such
op exists:

1 - udt_s, 1 / udt_s, 1 // udt_s, (1, 2) - udt_s
    # KeyError: 'rminus does not work with ...', though udt_s - 1 works
7 % s     # TypeError for every dtype: numpy.mod has no reflected op
2 ** s    # TypeError without Numba: rpow is a Numba function

Now 1 - udt_s is [-4, -6] for udt_s = [5, 7], and 7 % int8_s is 2 for 5. lit ** s
now gives what binary.pow(lit, s) and lit ** vector give, which changes four kinds of
result where rpow differed: an integer power out of range saturates (3 ** int8_s for 5
was -13, now 127); False ** bool_s is BOOL, not INT8; 0 ** complex_s is 0, not nan
(other complex powers may differ in the last bit); and (2**64 - 1) ** bool_s raises
OverflowError, as bool_s ** (2**64 - 1) does. Nothing else changes: for
+ - * / // % ** ^, nine Scalars and eight literals, every result but those of ** is the
same, and lit OP s now equals binary.op(lit, s) in all 392 cases with a value (it
differed in 66).

The repr of a Scalar & or | expression. repr(s & 1), repr(1 | s),
repr(t & True) and their _repr_html_ raised AttributeError: 'int' object has no attribute 'name', and repr(s & s) for a non-BOOL s raised "Bad dtypes for x & y!" under
autocompute. A literal operand now shows its value, and its own type where a dtype goes,
since it has no dtype until an op types it:

gb.ScalarEwiseMultExpr  left_dtype  right_dtype
s_1 & 1                       INT8          int

The text repr computes the result only where the HTML repr and the Vector and Matrix infix
reprs do: for x @ y, and for & and | of BOOL operands. A chain such as (s & t) & u
still raises in a repr, as it does for Vector and Matrix.

DimensionMismatch, not TypeError, for a size mismatch beside an infix expression.
Building the error crashed when an operand was itself an infix expression:

(v & v) & w    # v of size 3, w of size 4
# before: TypeError: _dummy() missing 1 required positional argument: 'obj_type'
# after:  DimensionMismatch: ...

The same held with the expression on the right, for | and @, and for Matrix operands.
_dummy never used its second parameter, and its only caller passes one argument. This
one-line fix is independent of the rest; it was found while testing main's coverage gaps
(#656).

Tests: test_scalar_infix_literal_keeps_its_side and its UDT twin and additions to
test_bad_ewise and test_bad_matmul in test_infix.py;
test_scalar_operators_literal_on_the_left and its UDT twin in test_scalar.py;
test_scalar_infix_expr_repr_literal and test_scalar_infix_expr_repr_auto in
test_formatting.py. Each fails on the commit before its fix.

Checked locally: every op with a literal on either side of & and |, and ewise_union
with defaults, against op(lit, s) and against the explicit Scalar ewise call: 47 ops
(every non-commutative one, and the commutative ones), 6 Scalar dtypes and 11 literals,
21822 cases, 6151 of them wrong before (in 30 ops) and none after; a UDT battery (record
and array UDTs, lifted ops, first, second and a user op; tuple, list, dict and number
literals) went from 156 wrong to none. The full suite passes with --runslow, without
Numba on both backends, and in CI's C-scalar pass; on Python 3.15, numpy 2.2 and
SuiteSparse:GraphBLAS 8.2 the only failures come from the local environments
(test_version, and pandas 3.1 in the Python 3.15 one, see #591).

Not changed here: a numpy scalar on the left (np.int16(2) - s) is still computed by numpy
through Scalar.__array__. #605, higher in the stack, un-gates the two new formatting tests
when pandas is absent.

@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.65%. Comparing base (cdfaec6) to head (ce63433).

Additional details and impacted files
@@                    Coverage Diff                     @@
##           47c-import-values-length     #654    +/-   ##
==========================================================
  Coverage                     98.65%   98.65%            
==========================================================
  Files                           105      105            
  Lines                         29510    29678   +168     
  Branches                       3452     3472    +20     
==========================================================
+ Hits                          29113    29280   +167     
+ Misses                          261      259     -2     
- Partials                        136      139     +3     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@eriknw
eriknw force-pushed the 52-scalar-infix-literal-operand branch from ea6fb88 to 2ff3579 Compare October 28, 2026 13:21
Behavior fix. Since 2023.1.0, `x & s` and `x | s`, with a literal x and
a Scalar s, built the expression with s on the left and x on the right,
so the op applied to it ran with its operands swapped, and every op that
does not commute gave wrong values, silently:

    s = Scalar.from_value(5)
    binary.minus(1 & s)      # was 4, now -4
    binary.truediv(1 | s)    # was 5.0, now 0.2
    binary.first(1 & s)      # was 5, now 1
    binary.lt(1 & s)         # was False, now True

So did rminus, cdiv, rdiv, pow, the other comparisons and
is-comparisons, and the rest of the ops that do not commute. So did the
defaults of an ewise_union, each of which went with the other operand:
with an empty Scalar e, `binary.minus(1 | e, left_default=0,
right_default=10)` was -1 and is now -9. And so did chains:
`binary.minus(1 & (2 & s))` was 2 and is now 4.

The expression now keeps the literal where it was written, as it is, and
the op applied to it types it: Scalar's ewise_mult, ewise_add and
ewise_union take a literal on the left as they take one on the right,
typed beside the Scalar by literals._literal_operand. A Python number is
weak, as beside `-`: `binary.minus(2 & int8_s)` is INT8 and
`binary.minus(0.5 & int8_s)` is FP64, as `2 - int8_s` and `0.5 - int8_s`
are. Beside a UDT, a lifted op such as minus types the literal as numpy
would and `first` makes it an element of the UDT.

How a literal is typed depends on the op (300 beside INT8 is an
OverflowError for `-` and is compared as it is by `<`), so the literal
is not converted when `&` or `|` builds the expression. Converting it
then, with its own dtype, would get the typing wrong: the 2 of
`int8_s & 2` would be INT64 under every op, where `int8_s - 2` keeps it
INT8, and `0.5 & int8_udt_s` would cut 0.5 to 0 in the UDT.

`op(x & y)` now asks the expression to call its left operand's method
(InfixExprBase._call_left), so a Scalar expression can pass a literal
there.

Two more errors with a literal operand are fixed:

- Computing a BOOL expression with a literal by itself, as
  `(s & True).new()` and `(False | s).new()` do, raised AttributeError:
  'bool' object has no attribute 'dtype'. It now types the literal for
  LAND or LOR and computes, or raises the usual TypeError for a dtype
  other than BOOL.
- `s & object()` gave an expression that failed only when an op was
  applied; it now raises TypeError at once, as `s + object()` does. The
  check types the literal as `==` would and drops it. Beside an infix
  expression, as in `(s & t) & object()`, it raises when the op is
  applied.
Behavior fix. Since 2023.1.0, a Scalar's reflected operators (`lit - s`,
`lit / s`, `lit ** s` and so on, with a literal on the left) computed
the op's commutes_to with the Scalar first, rminus(s, lit). That fails
where no such op exists:

    1 - udt_s, 1 / udt_s, 1 // udt_s, (1, 2) - udt_s
        # KeyError: 'rminus does not work with ...'; no reflected op
        # takes a UDT, though `udt_s - 1` and `1 - udt_vector` work
    7 % s     # TypeError for every dtype: numpy.mod has no reflected op
    2 ** s    # TypeError without Numba: rpow is a Numba function

Now the literal becomes a Scalar on the left, typed beside s as in
`binary.minus(1 & s)`, and the op itself is applied: `1 - udt_s` is
[-4, -6] for udt_s = [5, 7], and `7 % int8_s` is 2 for 5.

`lit ** s` now gives what `binary.pow(lit, s)` and `lit ** vector` give.
That changes four kinds of result, where rpow, a Numba function,
differed:

- an integer power out of range saturates, as GraphBLAS's pow does,
  where rpow wrapped: `3 ** int8_s` for 5 was -13 and is now 127, and
  `0 ** int8_s` for -3 was 0 and is now 127;
- `False ** bool_s` is BOOL, not INT8;
- `0 ** complex_s` is 0, not nan (for 1+2j), and other complex powers
  may differ in the last bit;
- a Python int beyond INT64 beside a BOOL Scalar, as in
  `(2**64 - 1) ** bool_s`, raises OverflowError, as
  `bool_s ** (2**64 - 1)` and `(2**64 - 1) ** bool_vector` do, where it
  gave a UINT64.

Nothing else changes: for `+ - * / // % ** ^`, nine Scalars (INT8,
INT64, UINT8, FP32, FP64, BOOL, empty) and eight literals, every result
the previous code gave is the same but those of `**`; and for the seven
that are not empty, `lit OP s` now matches `binary.op(lit, s)` for each
of `+ - * / // % **` (it did not in 66 of 392 cases).
Behavior fix. Since 2023.1.0, showing a Scalar `&` or `|` expression
raised in two cases:

    repr(s & 1), repr(1 | s), repr(t & True), and their _repr_html_
        # AttributeError: 'int' object has no attribute 'name'
    repr(s & s)   # s not BOOL, with autocompute on (the default)
        # TypeError: Bad dtypes for `x & y`!  Automatic computation ...

A literal operand now shows its value, as an unnamed Scalar does in an
expression, and its own type where a dtype goes, since it has no dtype
until an op types it:

    gb.ScalarEwiseMultExpr  left_dtype  right_dtype
    s_1 & 1                       INT8          int

The text repr now computes the expression to show its result only where
the HTML repr, and the reprs of Vector and Matrix infix expressions,
do: for `x @ y`, and for `x & y` and `x | y` of BOOL operands. A literal
counts as BOOL when it is a bool, so `t & True` shows its result.
A chain such as `(s & t) & u` still raises in a repr, as it does for
Vector and Matrix.
… infix expression

A behavior fix. When an operand of `&`, `|` or `@` was itself an infix
expression and the sizes did not match, building the error crashed in
_mismatched, which called _dummy(obj) while _dummy took a second
parameter, obj_type, that it never used (since #517). Before:

    >>> (v & v) & w    # v of size 3, w of size 4
    TypeError: _dummy() missing 1 required positional argument: 'obj_type'

After:

    >>> (v & v) & w
    DimensionMismatch: ...

The same held with the expression on the right (w & (v & v)), for `|`,
for Matrix operands ((A & A) & B), and for `@` ((A @ A) @ w). _dummy
now takes only obj. Tests cover Vector and Matrix with the expression
on either side, for `|`, `&` and `@`.
@eriknw
eriknw force-pushed the 52-scalar-infix-literal-operand branch from 2ff3579 to ce63433 Compare October 29, 2026 02:01
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.

1 participant