Trilean (idea — not in core) GitHub issue

Status: 2026-05-17 — moved from documentation/caspian/built-in-classes/ to documentation/ideas/. Caspian's core primitive for two-valued logic is boolean, not trilean. Three-valued logic is an idea preserved here for possible future revisit but is not part of the language. The text below describes what trilean would be if introduced.


puck.uno/trilean is the home for three-valued logic in Caspian. It provides the standard operators (and, or, not, nand, nor, xor, xnor, implies, eq) under a model where any operand can be null ("unknown") in addition to true and false.

Default Caspian operators (&&, ||, not, ==) keep their strict-Boolean semantics — null is treated as falsey, just like in most languages. The trilean class is opt-in: code that wants three-valued logic explicitly calls into it.

Status GitHub issue

vibecode
{"vibecode": {
    "section": "status",
    "priority": "low",
    "target_release": "first_production",
    "implementation": "pure_caspian_in_stdlib"
}}

Low priority but slated for the first production release. Will be implemented in pure Caspian as part of the standard library — partly because three-valued logic is genuinely useful, partly because it makes a good real-world test case for the language runtime. Implementation will live at code/caspian/stdlib/trilean.casp.


The Model GitHub issue

vibecode
{"vibecode": {
    "section": "the_model",
    "name": "Kleene three-valued logic",
    "alias": "K3 strong Kleene",
    "used_by": "SQL WHERE clause evaluation",
    "key_concepts": ["null_means_unknown", "result_depends_on_operand_dominance"]
}}

The three values are true, false, and null. null represents "could be true, could be false, we don't know."

Trilean operators classify each operand by reading .object.bool on it (see object.md). The result is one of three categories: true for any truthy value (the boolean true, the number 1, a non-empty string, etc.), false for the boolean false value (and other strictly-falsy values like 0, empty strings, etc.), or null for null values.

$tri.or(1, null)         # true   (1 is truthy)
$tri.and(1, null)        # null   (1 is truthy AND null is unknown)
$tri.and(0, null)        # false  (0 is falsy; falsy dominates AND)

Operators always return one of the strict values true, false, or null, regardless of operand types.

The general rule for every operator: ask whether the result depends on what the unknown actually turns out to be. If the result is the same regardless, return that result. If the result depends on the unknown, return null.

Examples: - false && unknown → result is false regardless of the unknown → false - true && unknown → result depends on the unknown → null - true || unknown → result is true regardless of the unknown → true - false || unknown → result depends on the unknown → null - not unknown → result depends on the unknown → null

This matches SQL's WHERE-clause evaluation (the same model is sometimes called strong Kleene logic, K3, or SQL three-valued logic).


Operators GitHub issue

vibecode
{"vibecode": {
    "section": "operators",
    "role": "documents the full set of three-valued operators with their truth tables"
}}

All operators are static methods on the puck.uno/trilean class, called as %puck['trilean'].<op>(...).

not(a) GitHub issue

a result
true false
false true
null null

and(a, b) GitHub issue

a \ b true false null
true true false null
false false false false
null null false null

false dominates AND. null poisons unless false is present.

or(a, b) GitHub issue

a \ b true false null
true true true true
false true false null
null true null null

true dominates OR. null poisons unless true is present.

nand(a, b) = not(and(a, b)) GitHub issue

a \ b true false null
true false true null
false true true true
null null true null

nor(a, b) = not(or(a, b)) GitHub issue

a \ b true false null
true false false false
false false true null
null false null null

xor(a, b) (exclusive or) GitHub issue

a \ b true false null
true false true null
false true false null
null null null null

null always poisons XOR — there is no operand value that "dominates" XOR, so the result always depends on the unknown.

xnor(a, b) = not(xor(a, b)) (equivalence / iff) GitHub issue

a \ b true false null
true true false null
false false true null
null null null null

True when both operands are the same boolean. Same null-poisoning rule as XOR.

implies(a, b) (material conditional, "if a then b") GitHub issue

Defined as or(not(a), b).

a \ b true false null
true true false null
false true true true
null true null null

Note that false implies anything is always true — that's the standard material-conditional rule (vacuous truth). When the antecedent is null, the result depends on whether the antecedent turns out to be true or false.

prohibits(a, b) (material nonimplication, "a but not b") GitHub issue

Defined as and(a, not(b)), equivalently not(implies(a, b)). The opposite of implies — true only in the one case where an implication fails: a is true but b is false.

a \ b true false null
true false true null
false false false false
null false null null

Reads naturally as "a prohibits b" — a being true is incompatible with b being true. Useful for rule and constraint logic where you want to assert that some condition forbids another.

eq(a, b) (alias for xnor) GitHub issue

eq is a friendly alias for xnor. Both operators produce the same truth table in trilean — they are the same function under two names. See the xnor section above for the truth table and semantics.

The alias exists because "eq" reads more naturally at call sites where the intent is "are these the same trilean value." Note this is not the place for arbitrary value equality — use Caspian's regular == for that, with its standard truthy-falsy semantics.

The SQL trap applies: eq(null, null) is null, not true. To check "is this null?" use $x.object.null?, never eq(x, null).


Lazy Second Argument GitHub issue

vibecode
{"vibecode": {
    "section": "lazy_second_argument",
    "role": "documents the do-block form of binary trilean operators that defers evaluation of the second argument when the first short-circuits the result",
    "key_concepts": ["short_circuit_per_operator_family",
        "do_block_form_for_lazy_second_argument",
        "three_families_each_with_dominant_value"]
}}

Every binary trilean operator can short-circuit when its first argument has the operator's dominant value — the value that fully determines the result without needing to look at the second argument. The operators fall into three families:

Family Operators Dominant value of a Result on short-circuit
AND-family and, nand, prohibits false false, true, false
OR-family or, nor, implies true true, false, true
XOR-family xor, xnor, eq null null (always)

Only not (single argument) doesn't apply.

Both forms are supported. Eager — pass both values:

$result = $tri.and($a, $b)

Lazy — pass b as a do block, which is only invoked if a doesn't short-circuit:

$result = $tri.and($a) do
    &expensive_check
end

If $a is false, the block is never called — the result is false directly. Otherwise the block is called and its return value used as b.

The lazy form pays off when the second argument is expensive, has side effects, or shouldn't be evaluated at all in the short-circuit case (e.g., a database query that would fail or be wasteful when the result is already determined).

Worked examples for each family:

# AND-family: short-circuits on a == false
$tri.and($cheap_check) do
    &expensive_db_lookup
end

# OR-family: short-circuits on a == true
$tri.or($cached_result) do
    &fall_back_to_remote_query
end

# XOR-family: short-circuits on a == null
$tri.eq($maybe_null_value) do
    &compute_other_value_only_if_first_was_known
end

The block-form syntax is consistent with how other Caspian constructs accept deferred work (%utils.timeout, %forks.run, etc.). The block runs in the caller's chain frame, so %chain access and exception propagation behave normally.


Branching on a Trilean Result GitHub issue

A trilean operator always returns one of three strict values: true, false, or null. Branching on the result is straightforward — use direct comparisons or the universal .object.null? / .object.defined? helpers (which live on every value in Caspian, not just trileans):

$result = $tri.and($a, $b)

if $result == true
    # definitely true
elsif $result == false
    # definitely false
else
    # null — we don't know
end

Or equivalently:

if $result.object.null?
    # we don't know
elsif $result
    # definitely true (truthy)
else
    # definitely false
end

The second form takes advantage of Caspian's default truthy semantics, where null is treated as falsy in an if. After the null? check has handled the unknown case, ordinary if $result is safe.

The .object.null?, .object.defined?, .object.truthy?, and .object.bool methods are universal — they are not specific to trilean. They are general-purpose introspection available on any value. See object.md for the full set.


Usage GitHub issue

vibecode
{"vibecode": {
    "section": "usage",
    "role": "shows typical Caspian code calling into the trilean class"
}}

Constructing trilean expressions:

%vibecode <<~VIBECODE
{"class":"puck.uno/dogberry/page","method":"process","purpose":"shows trilean
operators in a representative scenario","args":["request","response"]}
VIBECODE

$tri = %puck['trilean']

$has_consent = %db.fetch_consent($user)        # true, false, or null (unknown)
$is_minor    = %db.fetch_is_minor($user)       # true, false, or null
$can_proceed = $tri.and($has_consent, $tri.not($is_minor))

if $can_proceed == true
    &grant_access
elsif $can_proceed == false
    &deny_access
else
    &request_more_information   # null — we don't know, ask the user
end

The default &&, ||, not operators continue to treat null as falsey, so code that doesn't care about the distinction (the common case) doesn't have to think about three-valued logic at all.


Why Pure Caspian GitHub issue

vibecode
{"vibecode": {
    "section": "why_pure_caspian",
    "role": "explains the choice to implement trilean in stdlib Caspian rather than the runtime"
}}

Two reasons:

The implementation lives at code/caspian/stdlib/trilean.casp.


Notes GitHub issue


© 2026 Puck.uno