Truly - Create a truth table to consolidate complex boolean conditional logic.

Hey all!

I found myself needing to do complex conditional logic based on boolean flags. I found myself drawing it up as a truth table and thought it’d be nice to have a way to translate that directly to the logic code, so I made this small 1-file library: Truly

Truly provides a convenient and human-readable way to store complex conditional logic trees.
You can immediately use Truly.evaluate/2 to evaluate the truth table or pass the truth table
around for repeat use.

You might find this useful for things like feature flags, where depending on the combination
of boolean flags you want different behaviors or paths.

This can also make the design much more self-documented, where the intent behind a large
logic ladder becomes quite clear.

EDIT: Replacing the original examples with these for 0.2.0

Usage

First, you must import Truly. Then you have the
~TRULY sigil available.

All column names and result values must be valid (and existing) atoms.

Table cells can only be boolean values.

You must provide an exhaustive truth table, meaning that you provide
each combination of column values.

Basic Example

import Truly
columns = [:flag_a, :flag_b, :flag_c]
categories = [:cat1, :cat2, :cat3]

{:ok, tt} = ~TRULY"""
| flag_a   |  flag_b | flag_c   |          |  
|----------|---------|----------|----------|
|   false  |   false |  false   |   cat1   |
|   false  |   false |  true    |   cat1   |
|   false  |   true  |  false   |   cat2   |
|   false  |   true  |  true    |   cat1   |
|   true   |   false |  false   |   cat3   |
|   true   |   false |  true    |   cat1   |
|   true   |   true  |  false   |   cat2   |
|   true   |   true  |  true    |   cat3   |
"""

Truly.evaluate!(tt,[flag_a: true, flag_b: true, flag_c: true])

flag_a = false
flag_b = true
flag_c = false

Truly.evaluate(tt,binding())

Practical Example

Imagine you’re writing the backend for your social media app
called PitterPatter. You want to allow users to direct message
each other, but you want to enforce certain rules around this.

You have the following struct representing your User:

defmodule User do
  defstruct [:dms_open, :locked]
end

You want to control when messages are allowed to be sent according to
the sender’s :locked account status, the receiver’s :dms_open setting,
as well as if the two are friends.

Different combinations of these result in different behavior.

We can define the truth table, and since the result column
can be any atom, we can directly pass the function that we want
to call:

defmodule PitterPatter do
  import Truly

  def are_friends(_user1, _user2), do: Enum.random([true,false]) |> IO.inspect(label: "Are friends?")

  # We must specify these atoms before the truth table since the atoms must exist already
  @flags  [:dms_open, :locked]

  # Have different functions for different behaviors. You could imagine there
  # can be any number of these <= # rows
  def send_message(_sender,_receiver,_message), do: "Message Sent!"
  def deny_message(_sender,_receiver,_message), do: "Sorry, you can't send that message!"


  # Specify our truth table
  # For the sake of simplicity we stick to 3 variables
  # Also notice that you can use any existing atom (in this case the boolean atoms)
  # in the cells
  @tt ~TRULY"""
  | dms_open |  are_friends |  locked  |                   |   
  |----------|--------------|----------|-------------------|
  |  false   |    false     |  false   |    deny_message   |
  |  false   |    false     |  true    |    deny_message   |
  |  false   |    true      |  false   |    send_message   |
  |  false   |    true      |  true    |    deny_message   |
  |  true    |    false     |  false   |    send_message   |
  |  true    |    false     |  true    |    deny_message   |
  |  true    |    true      |  false   |    send_message   |
  |  true    |    true      |  true    |    deny_message   |
  """r # <- Notice the `r` modifier after the table
       # This is effectively like a `!` function, that will
       # unpack the return tuple and raise on error
  def direct_message(sender, receiver, message) do
    table = @tt 
    flags = 
      [
        dms_open: receiver.dms_open, 
        are_friends: are_friends(sender,receiver),
        locked: sender.locked
      ]
    apply(__MODULE__,Truly.evaluate!(table,flags),[sender,receiver,message])
  end
end

And just like that, a call to Truly.evaluate! performs all of the various checks
needed as well as routes to the appropriate function depending on the state passed in.

Let’s see how we would use this Let’s set up some Users:

sender = %User{dms_open: true, locked: false}
receiver = %User{dms_open: true, locked: false}

And now you can run PitterPatter.direct_message, and you will see that
as the :are_friends status changes (since it’s determined randomly above),
the result changes according to the rows in the truth table.

PitterPatter.direct_message(sender, receiver, "Hey, can you talk?")

Modifiers

  • r - This is effectively like a ! function, that will unpack the return tuple and raise on error
  • s Skip validation – when this modifier is present, we will not check that the
    truth table is exhaustive (accounts for each possible combination based on present values).

Example With Modifiers

import Truly
columns = [DMS, ARE_FRIENDS, LOCKED]
dms_open_enums = [:friends_only, :public, :closed]
results = [:deny_message, :send_message]
t2 = ~TRULY(
|   DMS          |  ARE_FRIENDS |                                                  |
|----------------|--------------|--------------------------------------------------|
|   friends_only |     false    | error, You Must Be Friends to Message This User  |
|   friends_only |     true     | send_message                                     |
|   public       |     false    | send_message                                     |
|   public       |     true     | send_message                                     |
|   closed       |     true     | error, This User Does Not Accept Direct Messages |
|   closed       |     false    | error, This User Does Not Accept Direct Messages |
)rs

Notice that you can use any existing atom in any cell, and even add error messages in the
result column.

If the last row of the previous table were removed, it would result in an error
due to having the s modifier present.

Here’s some examples of it in action:

https://x.com/ac_alejos/status/1777879977746206816

https://x.com/ac_alejos/status/1777897336624025603

https://x.com/ac_alejos/status/1778627293675409663

10 Likes

Thank you for publishing this.

Feedback: make a real-world example because flag_a and cat1 are not telling me anything that I can understand. You can sell it more convincingly that way.

5 Likes

Hey thanks for the feedback!

This actually spawned from something that came up during my day job so I had to change it to generic terms for the example, but you’re absolutely right that a more practical example would be better.

Fun little lib! Thanks for publishing. I like the idea of using the markdown truth table as the code.

I’m curious, why 0s and 1s instead of falses and trues? Conserving horizontal space?


EDIT: heads up, TruthTable.evaluate(tt,binding()) in the README doesn’t seem real anymore.


EDIT 2: Oh looks like false/true are supported! Might wanna highlight that as well.

1 Like

…or any atom (true and false are atoms too).

2 Likes

Good catch on the typo! I had originally called it TruthTable but that was actually already taken on Hex.

I’ll try to come up with some better examples overall when I get a chance, but yes, it accepts true, false, 0, 1 in the table cells and any existing atoms for column headers and in the final column.

A comment on the implementation: bit-stuffing like in evaluate

is mandatory in languages where not every type can be a map key.

Elixir isn’t one of those languages, so actions_map could just as well have keys like {true, false, true, false, etc}. There’s likely a memory-vs-performance tradeoff, although I can’t imagine humans maintaining a truth table with more than 8 inputs :man_shrugging:

2 Likes

Completely agree! While truth tables and bit shifting are one of the most the most efficient ways to keep data memory-wise, it also imposes a lot of reading overhead from people that are looking to solve a problem.

Very fair point!

This is definitely more of a personal preference thing for me, as well as a bit of a vestige of an earlier idea I had to consolidate rows that had the same output.

Like you said though, this will likely not matter in the long run, unless somebody had a heinously large truth table (maybe pulled from a CSV or something?)

Appreciate that you looked through the code!

I added another example in an edit to the post. It’s a bit contrived, but hopefully more demonstrative of possible use cases.

1 Like

Releasing a 0.2.0 based on some feedback I received here. Changes include:

  • Updated the implementation to allow for arbitrary (existing) atoms in the cells.
    • ONLY allows atoms, so previous use of 1 and 0 are no longer supported, but true and false still are
  • Added a new modifier (s) to toggle strict enforcement of exhaustive enumeration in the table.
  • Allows for an error message in the result column when you put error, message. This will be return as a tuple of {:error, message} from Truly.evaluate/2

Edited the main post to reflect these changes and added a link to a new video showcasing the changes off.

Thanks for all who’ve commented with feedback!

1 Like

could the results be any erlang term and evaluted with Code.eval_string?

Right now the results are atoms. You can always use Atom.to_string then evaluate. It wouldn’t be too hard to allow string results if thats what you want. PRs are always welcome!

I want a three element tuple, e.g {:a, :b, [:c, :d, :e]}.

That’s cool, but you could also just use the format-table directive of the Elixir formatter.

@format(:table)
[
  {:DMS,          :ARE_FRIENDS, :tag,          :msg                                       },
  {:friends_only, false,        :error,        "You Must Be Friends to Message This User" },
  {:friends_only, true,         :send_message, nil                                        },
  {:public,       false,        :send_message, nil                                        },
  {:public,       true,         :send_message, nil                                        },
  {:closed,       true,         :error,        "This User Does Not Accept Direct Messages"},
  {:closed,       false,        :error,        "This User Does Not Accept Direct Messages"},
]

Just kidding, the formatter can’t do that.
But wouldn’t it be cool?
Until then I’ll use your lib, though it is a little crazy.

Clang-Format Style Options — Clang 14.0.0 documentation (AlignArrayOfStructures)

2 Likes

@Sebb

Lol you baited me good there at first. It certainly would be nice!

There’s no reason I couldn’t add support for non-markdown “tables”.

I’m already surprised anyone showed interest in this so it’s already been a success in my eyes

2 Likes