Setnex ISA · Tooling

tritlib
Balanced ternary
from scratch, in Python

A pure Python library that implements balanced ternary arithmetic and multi-valued logic from the individual trit up. No dependencies, no binary shortcuts. The software companion to the Setnex ISA.

Eric Tellier · Terias April 2026 · updated July 2026 Python ≥ 3.11 · v2.1

Before you can simulate a processor, you need a number system. tritlib exists because I needed to compute in balanced ternary without ever touching an integer underneath—or at least, not until the very end, when a human wants to see a decimal result.

The library is the arithmetic substrate for the Setnex ecosystem: the setnex-sim simulator and assembler are built on top of it. It is neither fast nor optimised; it is, however, correct, and written to read like a specification.

· · ·

Four layers

The library is organized bottom-up, each layer built strictly on the one below. No layer reaches past its immediate dependency.

Architecture

logic.* Five ternary logics (Kleene, Łukasiewicz, Heyting, RM3, B3) — all built on Trit
TritArray Fixed-width word (Trybble, Tryte, Word) — simulates hardware truncation and overflow
Trits Arbitrary-precision balanced ternary integer — full arithmetic
Trit Single trit (N, Z, P) — immutable, with half-adder and full-adder

Trit: the atom

A Trit is an immutable object holding −1, 0, or +1. Three singletons—N, Z, P—are the building blocks for everything else. The class implements the operations you'd find in a single-trit ALU slice: negation (free—just flip the sign), multiplication (the trit-by-trit product table), and crucially, half_add and full_add.

from tritlib.trit import N, Z, P

# Negation: swap P and N, Z stays
-P          # → N
-N          # → P
-Z          # → Z

# Half-adder: returns (sum, carry)
P.half_add(P)   # → (N, P)   because +1 + +1 = +2 = −1 + 3
N.half_add(N)   # → (P, N)   because −1 + −1 = −2 = +1 − 3
P.half_add(N)   # → (Z, Z)   because +1 + −1 = 0

# Full-adder: (sum, carry) with carry-in
P.full_add(P, P)  # → (Z, P)   +1 + +1 + +1 = +3

The adder logic is the balanced ternary addition table from the Setnex specification, implemented directly. When two trits are equal, the sum is the negation and the carry is the value itself: P + P = N with carry P. When they differ, the sum is their plain integer sum and the carry is zero. This is the rule that makes balanced ternary addition work without a separate carry-lookahead for the simple case.

Everything is immutable. Trit, Trits, TritArray—none of them have setters. You compute new values, you don't mutate existing ones. It's arithmetic, not state.

Trits: arbitrary precision

Trits represents a balanced ternary integer of arbitrary width. Internally, it's a tuple of Trit objects stored LST-first (least significant trit at index 0). Leading zeros are stripped. You construct it from an integer or a string:

from tritlib.trits import Trits

a = Trits(42)
str(a)           # → "+---0" (MST-first display)
int(a)           # → 42

b = Trits("+-0")
int(b)           # → 6   (1×9 + (−1)×3 + 0×1)

Arithmetic uses the adder chain from Trit.full_add. Addition ripples through the trit pairs with a carry, exactly like the hardware would. Subtraction negates the second operand (free: flip every trit) and adds. Multiplication shifts and accumulates partial products, skipping zero trits.

a = Trits(13)
b = Trits(7)

a + b     # → Trits("+-+-")  = 20
a - b     # → Trits("+-0")   = 6
a * b     # → Trits("+0+0+") = 91
a // b    # → Trits("+-")    = 2
a % b     # → Trits("-")     = −1

The quotient is 2, not 1, and the remainder is negative: division is symmetric Euclidean, so the quotient rounds to nearest and the remainder is the one of minimal magnitude—13 = 7 × 2 − 1 rather than 7 × 1 + 6. This is the convention the Setnex specification requires, and the one balanced ternary long division produces without a correction pass.

Division is the most interesting piece, and the one that took the longest to get right. It implements balanced ternary long division: the dividend is consumed trit by trit, and at each rank the algorithm picks the quotient trit—P, N, or Z—that leaves the smallest partial remainder, maintaining the invariant |r| ≤ |d| / 2. Choosing on the sign of the leading trit alone, as earlier versions did, lets the remainder double at every step; the correction loop then has to run an astronomical number of iterations, while still returning a correct answer. The identity a = b·q + r held throughout, which is exactly why the bug survived so long.

One case escapes the invariant: an even divisor can push the scaled remainder one unit past what a single quotient trit can absorb. The fix is to correct inside the loop rather than after it, which amounts to writing a quotient digit of ±2 at that rank—balanced ternary addition carries it back into the ranks already produced. Random fuzzing essentially never draws 2, 4 or 6 as a divisor, so this case had to be tested explicitly.

TritArray: fixed width

TritArray is what Trits becomes inside a processor: a fixed-width word that truncates on overflow. Where Trits grows to fit, TritArray silently drops the trits that don't fit—just like real hardware.

from tritlib import TritArray

# 5-trit word: range [−121, +121]
w = TritArray(42, 5)
str(w)        # → "+---0" (fixed 5 trits, MST-first)
int(w)        # → 42

# Overflow wraps silently
big = TritArray(100, 5) + TritArray(100, 5)
int(big)      # → −43  (wrapped past +121)

The distinction matters. Trits(100) + Trits(100) gives Trits(200)—correct, arbitrary-precision. TritArray(100, 5) + TritArray(100, 5) gives a wrapped result—correct for a 5-trit register. The constructor from an integer raises OverflowError if the value doesn't fit; the internal factory _from_trits truncates silently. The first path is for the programmer; the second simulates the ALU.

add_with_carry returns both the result and the outgoing carry trit—the value that would go into FLAGS.carry on Setnex:

a = TritArray(100, 5)
b = TritArray(100, 5)
result, carry = a.add_with_carry(b)
# carry = P (positive overflow)

Three width-locked subclasses name the units the ISA actually uses, and preserve their own type through arithmetic—adding two Words gives a Word, not a bare TritArray:

from tritlib import Trybble, Tryte, Word

Trybble(4)      # 3 trits (T3)  — register address, sub-opcode
Tryte(42)       # 9 trits (T9)  — the byte-analogue, [−9841, +9841]
Word(42)        # 27 trits (T27) — memory and register unit

Until v2.0, the arbitrary-width type was itself called Tryte and took a width argument. Aligning the vocabulary with the specification—where a tryte is nine trits and nothing else—meant renaming it, which is the one breaking change the library has made.

Logic: five truth tables

The logic subpackage implements five ternary logic systems as subclasses of an abstract TernaryLogic base. The base class provides NOT (negation), AND (min), OR (max), XOR, and equivalence. Each subclass overrides what it needs to—and the only method that every subclass must define is imply_op.

from tritlib.logic import Kleene, Lukasiewicz, B3, HT, RM3
from tritlib.trit import N, Z, P

k = Kleene()
l = Lukasiewicz()

k.imply_op(Z, Z)   # → Z  (indeterminate)
l.imply_op(Z, Z)   # → P  (holds vacuously)

# AND and OR are shared (Kleene = Łukasiewicz for these)
k.and_op(Z, P)     # → Z
l.and_op(Z, P)     # → Z

# B3 (Bochvar): Z is infectious
b3 = B3()
b3.and_op(Z, Z)    # → Z  (Z contaminates everything)

# Heyting overrides NOT too
ht = HT()
ht.not_op(Z)       # → N  (not provable → false)

This architecture mirrors the Setnex hardware design. LMODE selects a logic; the instructions (TAND, TOR, TNOT, TIMPL) dispatch to the selected truth table. In tritlib, you instantiate a logic object and call its methods. Same structure, different medium.

What each logic overrides

LogicNOTANDORIMPL
Kleenebasebasebasecustom
Łukasiewiczbasebasebasecustom
B3 (Bochvar)basecustomcustomcustom
Heytingcustombasebasecustom
RM3basebasebasecustom

"base" = inherited from TernaryLogic: NOT = negation, AND = min, OR = max.

RM3 is the paraconsistent logic of Routley and Meyer. All five logics in tritlib are now exposed in the Setnex ISA via LMODE and STATUS.lx.

· · ·

Design choices

Immutability everywhere

Every type uses __slots__ and overrides __setattr__ to raise AttributeError. You cannot mutate a trit, a balanced ternary number, or a tryte after creation. This isn't a stylistic preference; it's a modeling choice. Hardware registers don't mutate—they produce new values on output wires. tritlib's types behave the same way.

No binary fast path

It would be trivial to implement Trits.__add__ as "convert to int, add, convert back." But that would defeat the purpose. The addition in tritlib ripple-carries through Trit.full_add at each position, exactly as a hardware adder would. The division uses balanced ternary long division, comparing magnitudes at each rank to decide the quotient digit. The code is slow, but it's a faithful model of what the circuits would do.

Constructors do the hard work

Trits(42) converts the integer 42 to balanced ternary using the standard algorithm: divide by 3, if the remainder is 2, increment the quotient and record N. Trits("+-0") parses the string. int(Trits("+-0")) converts back via Horner's method. The conversions are the bridge between the human world (decimal) and the machine world (ternary); everything in between stays in trit-space.

TritArray vs. Trits: the overflow boundary

This is the single most important design boundary in the library. Trits is mathematics: exact, unbounded, lossless. TritArray is hardware: fixed-width, truncating, lossy. The same arithmetic expression gives different results depending on which type you use. That's not a bug—it's the point. When you test a Setnex instruction, you use Word(value). When you verify the mathematical identity it's supposed to implement, you use Trits(value). If they disagree, you've found an overflow.

The boundary also has to be crossed carefully inside the library itself. TritArray.__divmod__ used to reimplement long division at fixed width, and the intermediate partial remainder reaches 1.5 × |divisor|—which wrapped silently near the top of the range, returning an unreduced remainder for roughly a third of random operand pairs at width 9. Division now delegates to Trits and runs at arbitrary precision, then comes back to the fixed width. Arithmetic that a register cannot hold does not belong in a register.

· · ·

Current state

tritlib is stable and complete for the scalar Setnex ISA. All five logic modes are implemented and exposed, and the arithmetic is correct: symmetric Euclidean division (round-to-nearest, minimal-magnitude remainder) matches the specification. The trit-parallel consensus primitives CONS and ACONS are available on TritArray and its subclasses. The setnex-sim simulator and assembler toolchain are built directly on top.

TFloat arithmetic is now fully native. Addition, multiplication, and division all operate at the trit level, following the Tekum tapered-precision format (arXiv:2512.10964); none of them falls back on Python float. This matches the T26F floating-point extension introduced in the Setnex ISA at v0.4. The vector extension added in v0.8 is a separate, trit-parallel datapath and is not yet modelled here.

The special values follow Tekum rather than IEEE, which is a genuine difference and not a shortcut. There is a single unsigned infinity, so −∞ = ∞ and ∞ + ∞ resolves to NaR, not to ∞: the IEEE rule depends on the sign being carried by the value, and here it is not. Ordering is total—NaR sorts below every finite value and ∞ above them—so the special values take part in comparisons instead of poisoning them the way NaN does. Setnex’s FCMP makes the same choice, for the same reason.

Version 2.0 renamed the arbitrary-width type to TritArray and added the width-locked Trybble, Tryte and Word. Version 2.1 is corrections, mostly to division and to comparison across widths—bugs that preserved a = b·q + r or only fired on operand shapes fuzzing rarely draws, and therefore survived a full test pass. They are documented in the changelog with the reasoning, since the reasoning is the part that generalises.

tritlib is slow, deliberate, and correct. It models what hardware would do, in a language where you can inspect every intermediate trit. That's the whole point.