PFCL — LLM Authoring Context
Load this document into your LLM's context window alongside the relevant catalog YAML entries before asking it to write PFCL. It encodes the translation subtleties that cause first-attempt failures even when the LLM knows ML-style syntax.
Start here
1. Get the catalog.
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. 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
Workflow
- Load the relevant catalog YAML namespaces into context — paste the YAML files for the namespaces you need (
list.*,math.*, etc.) - Describe what the function should do, its type signature, edge cases, and expected examples in plain language
- Ask the LLM to write the PFCL body using only names that appear in the loaded YAML files
- Check the output against the gotchas below before running
- Run
pfcl --evalor the REPL to test — it catches parse errors, unresolved names, type errors, and runtime errors in one pass. Loading genuinely runs the full gauntlet: parse, typecheck, resolve every name, in one pass —pfcl --eval 'int.add("hello", 3)'is rejected at load with a real type error and a position pointer, not silently evaluated toNothing. A separatepfcl --typecheck EXPRmode also exists if you want the inferred type without evaluating.
The catalog is finite and in-context. The LLM cannot hallucinate functions that don't appear in the YAML you have loaded. This structurally bounds the error space — the main remaining failure modes are the gotchas listed below.
1. Language model
PFCL is a strict, pure, ML-style functional language. Every function is a lambda. Every program is a composition of catalog entries. There are no statements, no mutation, no implicit effects.
Core forms — the complete syntax:
-- Lambda
\(x, y). body
-- Let binding
let x = expr in body
-- Match
match m with
| Just(x) -> ...
| Nothing -> ...
-- Record literal
{kind: "stdout", text: "hi"}
-- Tuple literal
(1, 2, 3)
-- Field access
record.field
-- Function application
f(x, y)
That is the entire language. No where, no guards, no list comprehensions, no do-notation, no type class syntax, no infix operators (a + b does not parse — everything is a named call, e.g. int.add(a, b)).
2. Critical translation gotchas
2.1 conditional is lazy inline, eager in let bindings
conditional(test, then_expr, else_expr) is lazy only when called directly — the evaluator special-cases it. If you bind an argument to a let first, the binding is evaluated eagerly before conditional sees it.
-- WRONG: expensive is evaluated even when test is true
let result = expensive_computation in
conditional(test, result, fallback)
-- CORRECT: inline for true laziness
conditional(test, expensive_computation, fallback)
Rule: inline expensive expressions directly inside conditional arguments. Never bind them to let first if you want short-circuit behavior.
2.2 record.get — use field access for static names, record.get for dynamic ones
For a static field name (known when you're writing the code), use direct field access — it's simpler and it's what you want almost every time:
-- CORRECT: returns the field's value directly
state.count
For a dynamic field name (the key is a runtime variable), use record.get, which comes in two forms. Both return the value directly — neither wraps a hit in Just(...):
-- 2-arg: (key, record) -> value, or Nothing on a miss
record.get(field_name, state)
-- 3-arg: (default, key, record) -> value, or default (not Nothing) on a miss
record.get(0, field_name, state)
Prefer the 3-arg form when you have a sensible default — it avoids needing a match at the call site. The 2-arg form is for when Nothing itself is the right "not found" signal.
2.3 fix is the only recursion mechanism
There is no let rec, no self-reference by name. All recursion goes through the fix combinator:
-- WRONG: self-reference doesn't work
\(xs). match xs with
| [] -> 0
| [h, t] -> int.add(h, self(t)) -- 'self' is not bound
-- ALSO WRONG: [h | t] head/tail cons-pattern syntax doesn't exist. List
-- patterns are fixed-length only ([], [x], [a,b], ...) -- there's no way
-- to match "first element, rest of the list" as a pattern. Use
-- list.match instead (list.match(default, handler, list) -- default is
-- returned for [], handler(head, tail) is called otherwise):
-- CORRECT: explicit fix + list.match
fix(\(self). \(xs). list.match(0, \(h, t). int.add(h, self(t)), xs))
The first argument to the lambda passed to fix is the recursive reference. Non-tail recursive calls work; fix is not tail-call-only.
Note — confirmed directly: if the recursive function's own argument is a tuple, destructure it with first/second, not match. match bracket with | (lo, hi) -> ... inside a fix body fails to typecheck (expected (Float, Float), found List<Float>), even though fix alone, tuple match alone, and tuple arguments via first/second each work fine independently — only the combination fails. The same rule applies to list.fold_left's accumulator/element lambda: match-destructuring a tuple-typed argument inside a higher-order function's own lambda body doesn't currently work, in either case.
-- WRONG: match-destructuring a tuple argument inside fix's own lambda
fix(\(self). \(bracket).
match bracket with
| (lo, hi) -> ...)
-- CORRECT: first/second instead
fix(\(self). \(bracket).
let lo = first(bracket) in
let hi = second(bracket) in
...)
fix takes exactly one argument — the self-referencing function — and returns a function you then call with the real arguments separately, as shown above (...)) then applied). A common mistake is calling it as fix(\(self, k). ..., n) with two arguments in one call — this fails to typecheck (fix's real signature is ((a) -> a) -> a, one argument, confirmed directly against the binary).
2.3a Prefer a structural primitive over fix when the recursion is structural
fix is general-purpose and unrestricted — nothing about its type guarantees termination, only that a step function of type a -> a produces a value of type a. If your recursion is walking a list to its end, or counting toward a fixed bound, it's structural — the recursion is guaranteed to terminate by the shape of what it's consuming, not by anything you have to reason about separately. For that case, use the primitive that already encodes it instead of hand-rolling it with fix:
- Walking a list —
list.fold_left,list.fold_right,list.map,list.filter,list.match - Counting between two bounds —
list.range
These terminate by construction — there's no separate termination argument to make, because the recursion is bounded by the list's own length or the range's own bounds, not by a property of your step function.
list.unfold — building a list from a seed until a stopping condition — is different in kind, worth not conflating with the two above. It isn't consuming an already-finite structure; it's building one, driven by a generator function that has to decide for itself when to stop (by returning Nothing). It's protected by a fuel limit (1,000,000 iterations) that turns a non-terminating generator into a clear error instead of a hang — a safety net, not a structural guarantee the way fold_left walking a real, already-finite list is.
fix is for recursion that doesn't fit any of these shapes.
fix loops have a real practical depth ceiling well below the 1,000,000-iteration fuel limit that protects structural primitives like list.unfold. A self-recursive fix loop scanning roughly 1,000 steps hit eval depth exceeded 5000 at runtime; the same logic over roughly 10 steps succeeded. For anything that might run more than a few hundred iterations, prefer list.fold_left/list.fold_right over list.range instead of open fix recursion.
2.3b If you do use fix, a termination_witness is worth writing down
Catalog entries using fix can declare a termination_witness block:
termination_witness:
kind: numeric
decreasing_arg: 'n - i strictly decreases each iteration; loop exits when i > n.'
verified: true
verified_at: '2026-07-20'
Be direct about what this is: a trusted, human-written attestation, not a machine-checked proof. Nothing currently verifies decreasing_arg's claim against the actual body — verified: true means a person checked it and signed off, the same trust level as a code-review approval, not a certificate.
2.4 FixTailCall is a tail-call-only sentinel
The FixTailCall optimization applies only to tail-recursive self-calls. If a fix body has a non-tail self(...) call (the result is used in a larger expression), it cannot use the tail-call sentinel — it materializes the full call. Don't confuse the two:
-- Tail call: self is the outermost call, result returned directly
fix(\(self). \(n, acc). conditional(int.equal(n, 0), acc, self(int.subtract(n, 1), int.add(acc, n))))
-- Non-tail call: self result is used in int.add — NOT a tail call
fix(\(self). \(n). conditional(int.equal(n, 0), 0, int.add(self(int.subtract(n, 1)), n)))
2.5 Hot loop projection — pre-project data out of nested lambdas
set.member and similar functions that take a predicate lambda are ~100× slower than expected due to evaluator overhead when the predicate closes over a record or map. Pre-project the data you need before the loop:
-- SLOW: record lookup inside hot lambda
list.filter(\(x). set.member(x, state.allowed_set), items)
-- FAST: project once before the loop
let allowed = state.allowed_set in
list.filter(\(x). set.member(x, allowed), items)
2.6 string.substring — causes silent wrong answers on a bad guess
string.substring(start: Int, len: Int, s: String) -> String
Getting this argument order wrong doesn't error — a type mismatch on this native falls through to returning Nothing, so code built on a wrong guess here just silently never matches, with no error anywhere. Confirmed directly: string.substring(haystack, i, needle_len) (wrong order) always returns Nothing; string.substring(i, needle_len, haystack) is correct.
2.7 Tuples are real syntax, and are lists at runtime
(1, 2, 3) is a genuine tuple literal, distinct from a list. () is the empty tuple. A single parenthesized expression with no comma, (x), is grouping, not a 1-tuple. Tuple patterns in match work the same way: (a, b) -> ....
At runtime, a tuple is represented as a list — first, second, and pair (all bare names, no namespace prefix) work directly on it:
pair(1, 2) -- constructs a 2-tuple
first(pair(1, 2)) -- 1
second(pair(1, 2)) -- 2
Three-element tuples use a separate, genuinely namespaced family instead: tuple3.first, tuple3.second, tuple3.third, tuple3.triple(a, b, c). tuple.first / tuple.second (with a plain tuple. prefix) do not resolve at all — confirmed directly against the binary.
2.8 Two parser constraints worth knowing before you hit them
_is valid as a match-pattern wildcard and as a standalone expression, but not as a lambda parameter name.\(_). bodyfails to parse — every parameter needs a real name.- A bare, unparenthesized
matchis rejected as the body of another match's arm:| p -> match ... with ...fails to parse. Wrap the inner match in parens:| p -> (match ... with ...).
2.9 Left/Right genuinely work as an ad hoc Either
There's no Result<a, E> catalog type, and Maybe<a> is the right choice for ordinary optionality — but Left(x)/Right(y) are real, working constructors, not placeholders. Any uppercase identifier applied with parens is a constructor: Left(5) evaluates to a tagged record ({tag: "Left", _0: 5}), and match ... with | Left(x) -> ... | Right(y) -> ... correctly destructures it. This is a general mechanism, not special-cased to Left/Right specifically — any constructor name works the same way (Foo(1, 2, 3) matched against Foo(a, b, c) binds all three; a bare Bar with no arguments works as a nullary constructor/pattern too).
Use this when you genuinely want two distinguishable cases carrying different payloads (a real either-this-or-that), not as a replacement for Maybe<a>'s narrower "value or nothing" role.
2.10 list.sort_by takes a key function, not a comparator
list.sort_by : ((a) -> b, List<a>) -> List<a>
A single-argument key function, applied to each element to get the value it sorts by — not a two-argument comparator like (a, b) -> Ordering, which is the more common convention in other languages and the natural first guess. Confirmed directly: list.sort_by(\(a, b). int.less_than(a, b), xs) fails with an arity mismatch (expects 1 argument(s), got 2); list.sort_by(\(x). x, xs) is the correct form for natural order, list.sort_by(\(x). x.some_field, xs) for a field. To sort descending, key by a negated/inverted value rather than supplying a reversed comparator.
2.11 just/nothing: lowercase to build, capitalized to match
just(x) and nothing (lowercase) construct values. Just(x) and Nothing (capitalized) are the only valid pattern forms — confirmed directly: a match arm written as | just(x) -> ... fails to parse.
let result = just(42) in
match result with
| Nothing -> 0
| Just(x) -> x
3. Catalog model
What the catalog is
A directory of YAML files. One file per function. The file is the function.
catalog/std/list/list_map.fn.yaml
catalog/std/math/math_mean.fn.yaml
How names resolve
At load time, names resolve to hashes. At evaluation time, hashes dispatch to implementations. The caller writes list.map; the evaluator dispatches by hash. There are no runtime name lookups.
How to find what exists
Two real options, depending on what you have access to.
If you can run the pfcl binary directly (an agentic setup with real tool access, not a plain chat completion): pfcl --find '<type signature>' searches the whole loaded catalog by type, not name — pfcl --find 'List<Int> -> Int' returns every function whose real, checked type matches, e.g. list.sum, list.length, list.product. Use it before guessing an argument order or assuming a function's exact shape. :find is the equivalent command inside the interactive REPL.
If you don't have that (a plain chat completion, no tool access): load the relevant catalog YAML into context instead. The functions available are exactly the functions in the loaded YAML files — no more, no less. Do not assume functions exist unless they appear in a YAML you have in context, or you've confirmed them via --find.
Breaking a problem into small functions — a manual process
Decomposition here is mechanical, not automatic: you do the type-diffing yourself, then use --find to check what you derived against what actually exists.
- Write down what you have and what you want — the input type and the output type of the thing you're building.
- Diff them. Each type constructor you need to introduce or eliminate along the way is a function-shaped hole. A
List<String> -> Intoverall goal, going through a natural intermediateList<Int>, gives you two holes:String -> IntandList<Int> -> Int. - For each hole, in order, check
--findbefore writing anything:
A real match means you're done for that hole. No match confirms a genuine gap: you now have a concrete, checked signature for something to write from more basic primitives.pfcl --find 'List<Int> -> Int'
Standard namespaces
| Namespace | Key functions |
|---|---|
list.* | map, fold_left, fold_right, filter, zip, take, drop, head, tail, length, is_empty, sort_by, group_by, flatten, cons, singleton, range, unfold |
string.* | concat, length, split, join, trim, substring, starts_with, ends_with, contains, to_upper, to_lower, char_at, from_chars |
math.* | sin, cos, tan, asin, acos, atan, atan2, exp, ln, log2, log10, pow, sqrt, pi, e |
int.* | add, subtract, multiply, divide, modulo, equal, less_than, greater_than, negate, to_float, from_string, to_string |
float.* | add, subtract, multiply, divide, equal, less_than, to_int, to_string, from_string |
maybe.* | is_just, is_nothing — the constructors are the bare, unnamespaced primitives just and nothing (like pair), not maybe.just/maybe.nothing |
map.* | empty, insert, lookup, lookup_default, delete, contains_key, keys, values, entries, merge, size, from_list, update |
set.* | empty, insert, member (not contains), delete, union, intersection, difference, size, to_list, from_list |
bool.* | and, or, not, xor, to_int |
char.* | is_whitespace, to_int, to_lower, to_upper — needed with string.to_chars |
bytes.* | concat, slice, length, from_list_int, sha256_digest, hmac_sha256, to_base64, from_base64, to_hex, from_hex |
convert.* | to_string only — int/float conversions live under int.*/float.* (note there's no float.from_string; parse via int.from_string then int.to_float) |
2-tuples have no namespace; 3-tuples do. Basic arithmetic lives under int.*/float.*, not math.* — math.* is transcendental/trig functions and constants only (math.pow is the real name for exponentiation; float.pow does not resolve). Pair construction and access are the bare, unnamespaced names pair, first, second — none of these have a catalog YAML entry (they're bootstrap primitives), so they won't appear in any loaded YAML file — and neither will the other bare names just, nothing, identity, compose, fix, conditional, nor the namespaced-but-native-only record.get. This document is their only reference.
4. Common first-attempt mistakes
- Writing
let rec— usefixinstead - Calling
record.getfor a static, known-ahead field name — user.fielddirectly; saverecord.getfor when the key is a runtime variable - Binding expensive args to
letbeforeconditional— inline them - Assuming a function exists without checking the catalog YAML — it might not, and for
first/second/pair/record.get, it never will - Using
maybe.mapormaybe.flat_map— not in catalog; usematchinstead - Expecting a
Result<a, E>catalog type — there isn't one; useMaybe<a>for ordinary optionality.Left/Rightgenuinely work as an ad hoc either-or if you need two distinguishable cases. - Using
|>pipe operator — not in language; use explicit function application - Forgetting that
list.headis partial — uselist.head(default, xs)instead - Guessing
string.substring's argument order — it fails silently, not loudly - Using
\(_). body— lambda parameters need real names;_is pattern-only - Writing
let (a, b) = pair in ...—letonly ever binds one plain identifier, never a pattern; usematch pair with | (a, b) -> ..., orfirst(pair)/second(pair)directly - Calling
fixwith two arguments in one call — it takes exactly one (the function), called separately:fix(\(self). \(x). ...)(x)
5. Effect model
Every program that does I/O returns List<Command>:
main : List<String> -> List<Command>
main = \(args).
[{kind: "stdout", text: "Hello, World!"},
{kind: "exit", code: 0}]
Commands are plain records. The executor reads the list and performs I/O. The function is pure. Standard command kinds: stdout, stderr, exit, stdin.readline, fs.read, fs.write.
Stateful programs use the (State, Event) -> (State, List<Command>) pattern:
step : (Int, {kind: String}) -> (Int, List<Command>)
step = \(state, event).
match event.kind with
| "tick" -> (int.add(state, 1), [{kind: "stdout", text: int.to_string(state)}])
| _ -> (state, [])