PFCL — LLM Authoring Context

Reference for language models and coding agents authoring PFCL, the Pure Functional Composition Language

Start here

1. Get the catalog. Everything callable lives here — nothing is built into the language beyond the core syntax below.

curl -LO https://codeberg.org/vickov/pfcl/releases/download/v0.1.6/pfcl-catalog-v0.1.6.zip
unzip pfcl-catalog-v0.1.6.zip

165 standard-library entries (list.*, string.*, math.*, etc.). One YAML file per function — the file is the function.

2. Get the binary. Prebuilt, no compiler needed — Linux x86_64 only (native Linux or WSL2; does not run on macOS or native Windows).

curl -LO https://codeberg.org/vickov/pfcl/releases/download/v0.1.6/pfcl
chmod +x pfcl

Building from source instead: cargo install pfcl-repl from crates.io (compiles locally, works on any platform Rust targets).

3. Verify.

./pfcl --eval 'int.add(2, 3)'
-- should print 5

If this works, everything downstream — writing PFCL, testing it against the real typechecker and evaluator, retrying against real compiler errors — works the same way.

Workflow

  1. Describe the function's purpose, type signature, edge cases, and expected examples in plain language
  2. Load only the catalog namespaces relevant to the task — paste the specific YAML into context rather than the whole download
  3. Write the PFCL body using only names confirmed present in the loaded catalog; check against the gotchas and common mistakes below before running
  4. Run against the real binary; the typechecker rejects structurally wrong output — iterate on the actual errors it returns, not a guess at what would work

The catalog is finite. A model cannot call a function that doesn't appear in the catalog it has loaded — this structurally bounds the error space to the gotchas listed below.


1. Grammar

The complete PFCL language specification. This is the authoritative syntax — if generated code doesn't parse against this grammar, the syntax is wrong, not the idea.

1.1 Overview

PFCL (Pure Functional Composition Language) is a strict, purely functional language with a content-addressed function catalog. The catalog loader rejects mutual recursion at load time (Tarjan SCC over the composed call graph), and general recursion is only available via the explicit fix combinator, which carries a 100k-iteration runtime guard.

Programs are pure. Effects are expressed as Command records returned from main and executed by the runtime — no side effects occur during evaluation.

1.2 Lexical structure

-- single line comment to end of line

No block comments.

ident  ::= alpha (alpha | digit | '_' | '.')*
alpha  ::= [a-zA-Z_]
digit  ::= [0-9]

Identifiers may contain dots for namespacing: list.map, int.add, string.to_chars. A dotted identifier is a single token — not a field access.

int_lit    ::= '-'? digit+
float_lit  ::= '-'? digit+ '.' digit+
string_lit ::= '"' char* '"'     -- no escape sequences yet
bool_lit   ::= 'true' | 'false'

Keywords: let, in, match, with.

1.3 Grammar

program     = expr

expr        = lambda
            | let_in
            | match_expr
            | pipe_expr

lambda      = '\' '(' params ')' '.' expr
params      = ident (',' ident)*

let_in      = 'let' ident '=' expr 'in' expr

match_expr  = 'match' expr 'with' ('|' pattern '->' expr)+

pipe_expr   = apply_expr ('|>' apply_expr)*       (* left-associative *)

apply_expr  = field_expr
            | field_expr '(' args ')'
args        = expr (',' expr)*

field_expr  = atom ('.' ident)*                   (* field access chain *)

atom        = ident
            | int_lit | float_lit | string_lit | bool_lit
            | 'nothing'
            | record_lit
            | list_lit
            | tuple_lit
            | '(' expr ')'

record_lit  = '{' (ident ':' expr (',' ident ':' expr)*)? '}'
list_lit    = '[' (expr (',' expr)*)? ']'
tuple_lit   = '(' expr ',' expr (',' expr)* ')'

1.4 Patterns

pattern     = '_'                               -- wildcard
            | ident                             -- variable binding
            | 'Just' '(' pattern ')'            -- Maybe constructor
            | 'Nothing'                         -- Maybe nothing
            | int_lit | string_lit | bool_lit   -- literal
            | '(' pattern (',' pattern)+ ')'   -- tuple
            | '[' (pattern (',' pattern)*)? ']' -- list (fixed length)

List patterns match by exact length. [x] matches a single-element list, [x, y] a two-element list. Wildcard _ matches any length in a separate arm.

1.5 Evaluation model

Strictness. PFCL is strict (call-by-value) — arguments are evaluated before function application. The single exception is conditional:

-- lazy at direct call sites (evaluator special-cases this form)
conditional(bool_expr, true_branch, false_branch)

conditional evaluates only the selected branch. This is syntactic — it only applies when conditional appears as a literal function call, not when it is bound to a name or passed as a higher-order argument.

Consequence — avoid pre-binding expensive branches via let:

-- WRONG: both branches evaluated before conditional is called
let t = expensive_computation in
let f = other_expensive in
conditional(b, t, f)

-- CORRECT: lazy because conditional sees the expressions directly
conditional(b, expensive_computation, other_expensive)

Name resolution. Names resolve to catalog hashes at compile time. At runtime, a name is an opaque reference to a compiled closure. Two names with identical typed bodies are identical at runtime.

Record field access. record.field is closure-aware. p.x evaluates p and extracts field x. Chains are left-associative: a.b.c = (a.b).c.

Tuples. Represented as fixed-length lists internally. (a, b) constructs a two-element tuple. tuple.first, tuple.second extract elements. tuple.pair(a, b) is equivalent to (a, b).

1.6 Type system

Hindley-Milner type inference with a catalog type environment. Types are not written in expressions — they appear only in catalog entry signatures.

TypeDescription
Int64-bit signed integer
Float64-bit IEEE 754 double
Booltrue or false
StringUTF-8 string
Bytesraw byte sequence
Unitsingleton type ()
Parameterized typeDescription
List<A>immutable singly-linked list
Maybe<A>Just(A) or Nothing
Map<K,V>ordered key-value map (string-keyed)
Set<A>ordered set
Tuple<A,B>fixed-arity product (also (A,B))

Function types:

A -> B           -- single argument
(A, B) -> C      -- two arguments (curried: (A) -> (B) -> C internally)

Multi-argument functions are syntactically multi-arity — \(x, y). body takes two arguments in one application, not two curried applications.

Record types are structural: {kind: String, text: String}. Open records match any record with at least those fields.

Command type. Command is any record with a kind: String field. The runtime dispatches on kind to execute effects. The type is open — user-defined kinds are valid.

1.7 Totality

Composed functions terminate by construction in the common case; the loader rejects mutual recursion via Tarjan SCC over the composed call graph:

catalog error: cycle check: cycle in composed catalog: even -> odd

Remaining partial behaviors the loader does not currently rule out: a composed body calling a primitive on an out-of-domain input propagates that primitive's partiality; a non-exhaustive match is a runtime error, not a load-time error; a fix-using function may exceed the 100k-iteration guard.

Primitives are implemented in Rust. Most are total; genuinely partial ones are marked total: false, can_fail: true in the catalog:

CategoryFunctionStrategy on out-of-domain input
Divisionint.dividereturns Nothing
Divisionint.moduloreturns Nothing
Divisionfloat.dividereturns Nothing
Parsingint.from_stringreturns Nothing
Mathmath.sqrt, ln, log10, log2, asin, acosreturns IEEE NaN
Listlist.head, list.tailpanics on empty input
Bytesbytes.atpanics on out-of-range index
Stringstring.char_atpanics on out-of-range index

The fix combinator is the only way to write recursion, bounded to 100,000 iterations at runtime:

-- Fibonacci
\(n).
  fix(\(self, k).
    conditional(int.less_than(k, 2),
      k,
      int.add(self(int.subtract(k, 1)),
              self(int.subtract(k, 2)))),
    n)

Catalog entries using fix must include a termination_witness field stating the well-founded ordering on the decreasing argument.

1.8 Content addressing

The identity of a kind: composed catalog entry is the SHA-256 of its typed body string (<signature> : <body>). The name is an alias to this hash. Renaming a function does not change its hash; changing one character of the body creates a new hash; two functions with identical typed bodies are the same function.

1.9 I/O and effects

main : List<String> -> List<Command>
main = \(args).
  match args with
  | []     -> [{kind: "stdout", text: "usage: tool <input>"}, {kind: "exit", code: 1}]
  | [path] -> [{kind: "stdout", text: process(path)},         {kind: "exit", code: 0}]
  | _      -> [{kind: "stdout", text: "too many arguments"},  {kind: "exit", code: 1}]
KindRequired fieldsEffect
"stdout"text: Stringprint to stdout
"stderr"text: Stringprint to stderr
"exit"code: Intexit with code

The kind namespace is open. Community executors implement additional kinds.

1.10 File format

-- Type annotation (ignored by the runner, documents intent)
helper : Int -> Int
helper = \(x). int.multiply(x, 2)

main : List<String> -> List<Command>
main = \(args).
  [{kind: "stdout", text: int.to_string(helper(21))}, {kind: "exit", code: 0}]

The file runner extracts all name = body definitions, builds a nested let ... in expression, and calls main([]). The main function must exist.


2. Critical translation gotchas

The specific mistakes that cause first-attempt failures even when the syntax above is already known.

2.1 conditional is lazy inline, eager in let bindings

Covered in full in §1.5. Restated because it's the single most common first-attempt mistake: inline expensive expressions directly inside conditional arguments. Never bind them to let first if short-circuit behavior is wanted.

2.2 record.get returns Just(value) — use field access instead

-- WRONG: returns Maybe<Int>, requires match to use
record.get("count", state)

-- CORRECT: returns Int directly
state.count

Use record.get only when the field name is dynamic (a variable). For static field names, always use r.field syntax.

2.3 fix is the only recursion mechanism

-- WRONG: self-reference doesn't work
\(xs). match xs with
| []      -> 0
| [h, t]  -> int.add(h, self(t))   -- 'self' is not bound

-- CORRECT: explicit fix
fix(\(self). \(xs). match xs with
  | []        -> 0
  | [h | t]   -> int.add(h, self(t)))

2.4 Hot loop projection — pre-project data out of nested lambdas

set.contains and similar functions taking a predicate lambda are ~100× slower than expected when the predicate closes over a record or map. Pre-project the data before the loop:

-- SLOW: record lookup inside hot lambda
list.filter(\(x). set.contains(x, state.allowed_set), items)

-- FAST: project once before the loop
let allowed = state.allowed_set in
list.filter(\(x). set.contains(x, allowed), items)

3. Common first-attempt mistakes

  1. Writing let rec — use fix instead
  2. Using record.get for static fields — use r.field instead
  3. Binding expensive args to let before conditional — inline them
  4. Assuming a function exists without checking the catalog YAML — it might not
  5. Using maybe.map or maybe.flat_map — not in catalog; use match instead
  6. Writing Result<a, E> — not in catalog; use Maybe<a> for optionality
  7. Using |> pipe operator incorrectly — it exists (e |> f means f(e)) but is uncommon; explicit application is preferred
  8. Forgetting that list.head/list.tail panic on empty input (see §1.7's partiality table)