SCUA

News

SCUA 0.27.0 is out

September 17, 2026

A frame can now answer the questions you ask a spreadsheet. describe() summarises every numeric column, value_counts() counts a text one, and group_by() computes as many aggregates as you name in a single pass. A dozen new reducers came with them, and the exact ones stay exact, so the median of a column of prices is a price and not a float that nearly is. The maths module gained a base argument for log, along with exp2 and clamp01, and fmod now gives the answer C and Python give. Two changes are breaking, so read the upgrade section before you install.

#describe, value_counts and group_by

orders.describe() gives a frame back: one row per numeric column, with the count, mean, standard deviation, minimum, quartiles, median and maximum. Text columns are left out rather than filled with blanks, since a column of region names has no mean. orders.value_counts("region") is the question those columns do answer, and hands back each distinct value with how often it occurs, most frequent first.

group_by takes the key column and a table of the aggregates you want:

orders.group_by("region", {
  revenue = "sum:amount",
  orders  = "count",
  biggest = "max:amount",
  typical = "median:amount",
})

That reads one pass over the rows and produces one column per entry. Each value names a reducer and a column, or a bare "count" for the number of rows in the group. The reducers are sum, mean, median, min, max, std, variance, count, count_distinct, first, last, product and mode, and every one of them also works on a whole column: orders.median("amount"), orders.count_distinct("rep"). They are the same functions in both places, so a grouped median and a plain one agree by construction.

Money keeps its exactness through all of it. A decimal column is exact by default, with nothing to add and no numeric type to pick, and its total is a decimal. Its median is the exact midpoint of the two middle values when there is an even number of rows, at one more decimal place only if the midpoint needs one, so the midpoint of 20.00 and 35.00 is 27.50. The reducers that hand back a cell keep the column's type too: the maximum of a money column is money, and the mode of a text column is text. Standard deviation and variance are floats, because a deviation of money is not a money amount.

Four reshaping verbs came with them. distinct() drops duplicate rows and keeps the first of each in its original position, drop(names) is the complement of pick, rename leaves column order alone, and tail(n) is the other end of a sort from head. sort_by now takes several columns and a direction for each, so "by region, then by amount descending" is one call.

All of this runs with no import, no install step, no virtual environment and no capability grant. In Python the same job usually starts by setting up a virtual environment and installing a package. Here a script that reads a CSV and answers a question about it is one file you can send someone.

#Log with a base, and a corrected fmod

math.log(x, base) takes an optional second argument, so math.log(1024, 2) is 10. Bases 2 and 10 go through the hardware's own implementations, which means math.log(1000, 10) is 3 rather than 2.9999999999999996. Computing it by hand as log(x)/log(b) gives the longer answer, and so does Python.

math.exp2 is log2's inverse, which was missing while log2 was there. math.clamp01(x) limits a value to 0 through 1, the clamp that interpolation code reaches for. And list.min, list.max and list.mean now sit beside list.sum, which was the only one of the four that existed. An empty list gives nil from all three, because an empty list has no smallest element and saying zero would be a claim about data you do not have.

math.fmod changed its answer. It was a floored remainder, so math.fmod(-7, 3) gave 2, where C, Python and JavaScript all give -1. It now matches them. The floored answer is what the % operator gives and always did, so both conventions are available where before there was only one under a name promising the other.

#nan follows the same rule on every tier

nan is the float you get from a calculation with no answer, and by the IEEE rule it is not equal to itself. That is now true wherever your code runs. Before, the interpreter said a nan equalled itself and the compiler disagreed, so the answer depended on whether a function had been compiled yet, and scua test reported the interpreter's answer. Ordering comparisons were already correct and are unchanged. is(n, n) is still true, since is asks whether two things are the same value and one value is.

Arrays follow their elements now as well. [nan] == [nan] is false and [0.0] == [-0.0] is true, both matching what == says about the values inside.

#Enum payloads are checked

A record carried by an enum variant now gets the checks it gets anywhere else. Build Item.Text({ at = [72, 700], text = "hi" }) where TextItem has defaults and where refinements, and the defaults fill and the refinements run, which they did not before. A literal with a field the record does not declare is a compile error, the same one you get from let t: TextItem = { … }. Values arriving from JSON are checked the same way, so {"kind":"Text","value":[1,2]} is refused where it crosses instead of failing later somewhere else.

A parameter or let typed with an enum is checked too. fn f(p: Direction) used to accept any value at all.

#Upgrading

Two things to check before you install.

If you call math.fmod with operands of opposite signs, its answer has changed. Use the % operator for the old behaviour, which is unchanged.

If a program builds an enum variant from a value that does not satisfy the variant's declared payload type, or passes a non-variant where an enum type is declared, it now stops at that point with a located error rather than carrying the wrong value forward. The error names the record or enum it expected. Nothing that was already correct needs changing.

The full changelog has the rest, including a clearer message for a stray character and a build that tells you whether it was compiled for speed.