tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

README.md (13552B)


FizzBuzz, one tree at a time

If you have written FizzBuzz in Python, you already know the goal: count from 1 onward, replacing multiples of three with “Fizz,” multiples of five with “Buzz,” and multiples of both with “FizzBuzz.”

Here we will build that sequence using two repeating clocks. Along the way, you will learn how SCP matches trees, carries state, and produces a program that you can prepare once and run repeatedly. If you would like to try the examples now, skip ahead to preparing a program.

1. A language made of trees

tricu implements tree calculus, where both data and executable programs are trees. SCP gives us a way to write computations over those trees using patterns and rules. We can take a tree apart, name its pieces, and construct another tree from them. Because code has the same representation as data, programs can also inspect and assemble executable trees.

There are three tree shapes:

A pair (left, right) is a Fork. A list is a chain of Forks, each holding an item and the remaining list, ending in Leaf. For example:

[1, 2, 3] = (1, (2, (3, Leaf)))

Numbers use a compact tree encoding. Strings are lists of byte-number trees. Zero, an empty list, and an empty string all use Leaf. The program consuming a tree supplies its meaning: our FizzBuzz consumer expects a list as input and a pair of clocks as state.

Our finished FizzBuzz program will produce:

ok: [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]

The list is our answer. ok: tells us the invocation returned a successful Result; a failed computation displays error: followed by its detail. Let's build up the computation, beginning with two small consumers.

2. Give a tree a name

A consumer receives input and state. Its rules describe how to match those two values and what to produce. This tiny consumer returns its input:

!export identity
consumer identity
  value @ _ => value

Read the rule as: “Capture the input as value, accept any state, and return value.”

For example, identity returns [1, 2, 3] when given [1, 2, 3], and returns "hello" when given "hello".

3. Walk a list

We can distinguish an empty list from a list with a head and tail:

!export copyList
consumer copyList
  [] @ _ => []
  [n | rest] @ state => [n | recur rest @ state]

Rules are tried from top to bottom. [] matches Leaf and returns an empty list. [n | rest] matches a Fork: n names its head and rest names its tail. state captures the state.

The output [n | ...] constructs a list cell. To obtain its tail, recur rest @ state continues copyList on the remaining input, with the state explicitly supplied. Each step consumes one cell:

input [1, 2, 3] → keep 1, continue on [2, 3]
input [2, 3]    → keep 2, continue on [3]
input [3]       → keep 3, continue on []
input []        → finish with []

The result is a fresh list with the original values. FizzBuzz will use the same traversal, choosing a label for each head and updating the clocks for the next step.

4. Derive the two clocks

In Python, a common solution tests each number's divisibility. We can also track our position in a repeating cycle. Each clock counts how many advances remain until its next labelled position. Zero means “label this item.” We inspect the clocks before processing an item, then advance them.

The three-clock starts like this:

position 1: 2   keep the item
position 2: 1   keep the item
position 3: 0   say Fizz
position 4: 2   keep the item

Why start at 2? Position 3 is two advances away from position 1.

Why reset to 2? After labelling position 3, we advance to position 4. The next Fizz is at position 6, two advances away. The clock follows 2 → 1 → 0 → 2, repeating every three items.

The five-clock follows the same reasoning. Position 5 is four advances away from position 1, so it starts at 4. Its cycle is 4 → 3 → 2 → 1 → 0 → 4.

Together, the state is (three, five):

Position State before processing Output
1 (2, 4) 1
2 (1, 3) 2
3 (0, 2) Fizz
4 (2, 1) 4
5 (1, 0) Buzz
6 (0, 4) Fizz
10 (2, 0) Buzz
14 (1, 1) 14
15 (0, 0) FizzBuzz

These clocks follow positions in the input. A consecutive list starting at 1, with state (2, 4), gives conventional FizzBuzz. Feeding the same clocks [100, 200, 300] produces [100, 200, "Fizz"]: the third position receives the label. To begin a consecutive sequence elsewhere, choose clocks that align with its starting point. Starting at 15, for example, uses (0, 0).

5. Assemble FizzBuzz

Here is the complete computation from fizzbuzz.scp:

!export fizzbuzz
consumer fizzbuzz
  []         @ _             => []
  [n | rest] @ (three, five) => [
    match (three, five) of (
      (0, 0) => "FizzBuzz"
      | (0, _) => "Fizz"
      | (_, 0) => "Buzz"
      | _ => n)
    | recur rest @ (
      match three of (0 => 2 | 1 => 0 | 2 => 1),
      match five of (0 => 4 | 1 => 0 | 2 => 1 | 3 => 2 | 4 => 3))]

The outer shape is our list-copying consumer. The head now comes from a match, and recurrence receives updated state.

match value of (...) computes a value and tries its arms in order. For the head, we match the clock pair:

  1. Both zero: produce "FizzBuzz".
  2. Three-clock zero: produce "Fizz".
  3. Five-clock zero: produce "Buzz".
  4. Otherwise: keep n.

The combined case comes first because (0, 0) also matches (0, _) and (_, 0). Moving the Fizz arm first would make position 15 say only Fizz.

The two smaller matches implement the clock cycles as finite tables. For example, match three of (0 => 2 | 1 => 0 | 2 => 1) resets zero to two and advances the other phases. The pair of results becomes the state for rest.

Trace position 3: state (0, 2) chooses "Fizz"; the updates produce (2, 1); recurrence processes position 4 with those clocks. The empty rule ends the traversal and supplies the final Leaf of the output list.

6. Prepare code with its starting state

Our computation accepts clocks on every step. Configuration chooses their initial values and combines them with the compiled entry to make a worker. That lets us reuse the same code with different starting points.

From the repository root, with Nix and GNU timeout available, after setting up SCP_BOOTSTRAP as described in the SCP guide:

nix build .#
result/bin/tricu scp prepare demos/consumers/fizzbuzz/fizzbuzz.scp-project \
  --bootstrap "$SCP_BOOTSTRAP" --store /tmp/fizzbuzz/cas --output /tmp/fizzbuzz/program.prepared --seconds 240
result/bin/tricu scp run /tmp/fizzbuzz/program.prepared \
  --input-tree-file demos/consumers/fizzbuzz/fizzbuzz-input.tree --seconds 60

--seconds sets the time allowed for each launched phase; increase it if a phase needs more time on your machine, up to 1200 seconds. To construct a project for identity or copyList, select its source file in the workspace and use that consumer's name for the project's binding and invocation. Both can use Leaf as initial state: {"leaf": true}. Let's follow how those selections work for FizzBuzz.

The configuration in fizzbuzz.scp-project says:

"configuration": {
  "bind": "fizzbuzz",
  "state": {"tree-file": "fizzbuzz-phases.tree"}
}

bind selects the compiled entry by name. state supplies the tree to use at the start of each invocation: our pair (2, 4). The shared configuration operation combines them into a worker and publishes it under the invocation name selected by the project. fizzbuzz-phases.tree contains the serialized pair (2, 4): the initial three-clock and five-clock. It is generated from the phases definition in fizzbuzz-data.tri using tricu tree write. fizzbuzz-input.tree similarly holds the runtime list 1 through 15.

There are two data choices to keep straight:

fizzbuzz-workspace.tri selects our source file. The project brings together that workspace, initial state, and invocation name. Its scp-invocation.result.v1 profile selects direct invocation returning a Result. For configurations that compute state or assemble several workers, projects can instead select an ordinary SCP configuration consumer; the SCP guide shows both forms.

When you run prepare, the workflow:

  1. Compiles and publishes the reusable entries.
  2. Binds the chosen initial state and publishes the worker and its Result-returning invocation.
  3. Checks the selected invocation name and writes the prepared record.

The published trees live in a content-addressed store, or CAS. Each object has a pin: a hash identifying its contents. A manifest maps exported names to their objects; receipts record the build and configuration inputs. The prepared record selects those artifacts and the entry to invoke.

run loads that selection and applies the supplied input. Each invocation starts with the configured clocks. To select a source edit or new initial state, prepare again; an existing record continues to select its original program. Runtime input can change on every invocation.

For deployment, move the record and CAS together, preserving their relative layout, and run them with tricu and the launcher. The build's source files can stay behind. The SCP guide also shows the separate phase commands for building code once and configuring it several ways.

7. Make the data your own

The .tree files hold serialized trees. To write new values, edit the two named definitions in fizzbuzz-data.tri:

phases = pair 2 4
input = [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15]

Select each definition to serialize it through the existing tree codec:

result/bin/tricu tree write demos/consumers/fizzbuzz/fizzbuzz-data.tri \
  --name phases --output demos/consumers/fizzbuzz/fizzbuzz-phases.tree --store /tmp/fizzbuzz/data-authoring
result/bin/tricu tree write demos/consumers/fizzbuzz/fizzbuzz-data.tri \
  --name input --output demos/consumers/fizzbuzz/fizzbuzz-input.tree --store /tmp/fizzbuzz/data-authoring

tree write evaluates the selected definition and writes its exact tree bytes. This is the explicit authoring step. During invocation, the argument loader decodes those bytes. The .tri list syntax separates items with spaces; SCP lists use commas.

Back up your files and try these experiments one at a time. Restore the original data and source between experiments.

Edit Next steps Expected outcome
Runtime list becomes [100 200 300] Regenerate data, run [100, 200, "Fizz"]
Runtime list becomes [] Regenerate data, run ok: []
Phases become pair 0 0 Regenerate data, prepare, run the original list Begins "FizzBuzz", 2, 3, "Fizz", 5, "Buzz"
Three-clock becomes 3 Regenerate data, prepare, run nonempty input Failure: no matching output arm
Runtime input becomes pair 7 (t t) Regenerate data, run Failure: no matching consumer rule
Move (0, _) before (0, 0) in source Prepare, run Position 15 says "Fizz"
Change "Fizz" to "Fizz!" in source Run the old record, then prepare and run The new record selects the changed label

The two failures explain the program's patterns. Three-clock 3 falls outside its update table. The pair pair 7 (t t) supplies a head, but its Stem tail misses both the empty-list and list-cell rules. In each case the invocation returns a failure Result with the message shown above.

The Result display presents the outcome and payload separately: ok: [] for an empty answer, or error: no matching output arm for an invalid phase. To examine the complete underlying tree, choose the tree view:

result/bin/tricu scp run /tmp/fizzbuzz/program.prepared \
  --input-tree-file demos/consumers/fizzbuzz/fizzbuzz-input.tree --format tree --seconds 60

For empty input it displays t (t t) (t t t): a Fork holding the success marker and a pair whose payload and remainder are both Leaf. The display choices preserve that same Result tree.

Continue exploring

You have now matched trees, traversed a list, derived explicit state, and combined compiled code with data for repeated invocation. The small lessons take the next step: inspecting executable trees and comparing their representations. The SCP guide covers reusable workers and larger applications; the language contract gives precise matching and control semantics.