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 Python ≥ 3.10 · v1.2

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's not fast. It's not optimized. It's correct, and it's 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
Tryte Fixed-width 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, Tryte—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("+-+0")  = 20
a - b     # → Trits("0+-")   = 6
a * b     # → Trits("+0+0+") = 91
a // b    # → Trits("+")     = 1
a % b     # → Trits("0+-")   = 6

Division is the most interesting piece. It implements balanced ternary long division: at each step, the algorithm inspects the leading trit of the partial remainder against the divisor, produces a quotient trit of P, N, or Z, and adjusts the remainder accordingly. The sign is handled separately. This mirrors how a hardware divider would work in a Setnex implementation.

Tryte: fixed width

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

from tritlib.tryte import Tryte

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

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

The distinction matters. Trits(100) + Trits(100) gives Trits(200)—correct, arbitrary-precision. Tryte(100, 5) + Tryte(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 = Tryte(100, 5)
b = Tryte(100, 5)
result, carry = a.add_with_carry(b)
# carry = P (positive overflow)

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, inspecting the leading trit 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.

Tryte vs. Trits: the overflow boundary

This is the single most important design boundary in the library. Trits is mathematics: exact, unbounded, lossless. Tryte 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 Tryte(value, 27). When you verify the mathematical identity it's supposed to implement, you use Trits(value). If they disagree, you've found an overflow.

· · ·

Current state

tritlib v1.2 is stable and complete for the Setnex v0.3 ISA. All five logic modes are implemented and exposed. Arithmetic is correct: symmetric Euclidean division (round-to-nearest, minimal-magnitude remainder) matches the Setnex spec. The trit-parallel comparison primitives CONS, ACONS, and TCMP are available on Tryte. The setnex-sim simulator and assembler toolchain are built directly on top.

What's still in progress: native trit-level TFloat division. Addition and multiplication use native trit-level arithmetic following the Tekum balanced ternary tapered precision format (arXiv:2512.10964); division currently converts via Python float. Full native arithmetic is planned for v0.4 alongside the TFP extension in the Setnex ISA.

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.