PTC-Lisp is a bounded Clojure-like language for data processing and tool calling through explicitly granted capabilities.
Related docs:
- Clojure Conformance Gaps — tracked deviations from Clojure (bugs, missing features, intentional divergences)
- Function Reference — supported PTC-Lisp functions and special forms
1. Overview
PTC-Lisp is a small, bounded Clojure-like language designed for Programmatic
Tool Calling. Programs are expressions that transform data and may invoke
explicitly granted tools. Multiple top-level expressions are supported with
implicit do semantics.
Execution Model
A PTC-Lisp evaluation consumes (memory, context, tools, limits) and produces a
result plus staged continuation state and diagnostics:
- Input: Persistent definitions and turn history, current request context, explicitly granted tools, and resource limits
- Output: An ordinary value, explicit
return/failoutcome, candidate definitions, prints, and tool-call diagnostics - Commit semantics: Definition and history changes are committed transactionally by a stateful Kernel host; failed evaluation publishes neither
Pure transformations are deterministic for the same inputs. Tool calls,
System/currentTimeMillis, and the zero-argument (java.util.Date.)
constructor are explicit sources of external or time-dependent behavior.
Design Goals
- LLM-friendly: Easy for language models to generate correctly
- Safe: Resource-limited execution with no ambient filesystem, network, or process access; external effects require granted tools
- Compact: Minimal syntax, high information density
- Verifiable: Can be validated against real Clojure for correctness
- Expressive: Sufficient for common data transformation tasks
- Transactional: All-or-nothing memory updates, safe for retry loops
Design Philosophy
Clojure compatibility is the default — but it is not the top-level goal. The top-level goal is deterministic, bounded, recoverable data transformation inside an agent loop. When the two conflict, four rules decide:
- No
try/catch. Raising terminates the program and there is no recovery path. So Clojure-named helpers prefer signal values (nil,"",false, an empty collection) when external input is bad and the caller can reasonably continue:(parse-long "abc")returnsnil, not aNumberFormatException. - Eager, not lazy. Inputs must be finite and bounded; programs run under wall-clock and memory caps.
- Java-named methods follow Java-compatible conventions where those
conventions are meaningful in PTC-Lisp. The dot prefix signals
"Java idiom expected": familiar names, arities, not-found values
(
.indexOfreturns-1), and bounds errors for invalid indexes (.substringraises on out-of-range;.length/.indexOfraise on non-string-like receivers). They do not preserve Java object/type distinctions PTC-Lisp intentionally does not model — e.g.Charactervs one-characterString, so(.length \a)returns1rather than raising (see DIV-40/DIV-41). Java is a compatibility heuristic, not the design owner.subs,parse-long,getfollow the safer-for-sandbox signal pattern. - Properties of input data may signal; properties of the program
raise.
(parse-long "abc")signals (bad data);(+ 1 nil), wrong arity, or unknown symbols raise (bad program — fix it).
Tracked Clojure divergences live in
Clojure Conformance Gaps under
DIV-* entries.
Classifying a Divergence
A divergence is filed by area — GAP-S* (semantics), GAP-F* (special form),
GAP-C* (core fn), GAP-J* (Java method), or as a DIV-* entry when it is
intentional — and given a disposition, recorded in
Clojure Conformance Gaps:
- BUG — PTC-Lisp implements the form but is wrong: a silent wrong result, or
an error/rejection on a supported, finite form that Clojure (and the rules)
accept. Gets fixed; a
GAP-*later judged intentional is renumberedDIV-*. - DIV — an intentional, rule-justified difference, kept as a
DIV-*entry: signal values where Clojure raises on recoverable data (rules 1, 4); eager-only evaluation / no lazy or infinite seqs (rule 2); the Java type model (rule 3); and PTC's value-model, determinism, and no-macros/evalchoices. - UNSUPPORTED — a Clojure feature PTC has not implemented (an audit
candidate, e.g.
clojure.string/capitalize); the conformance runner skips it rather than running it wrong. - UNKNOWN — observed, not yet triaged.
Correctly raising on a genuine program fault ((+ 1 nil), wrong arity) is not a
divergence at all — it is the intended behavior (rule 4).
Non-Goals
- General-purpose programming
- Full Clojure compatibility
Relationship to Clojure
PTC-Lisp combines a supported subset of Clojure with host-specific names and
intentional semantic divergences. A form can use valid Clojure reader syntax
without being defined by clojure.core, and a familiar Clojure name can still
have bounded PTC semantics.
| Category | Examples |
|---|---|
| Standard Clojure syntax or core vocabulary | Implicit do bodies in fn, let, when, and when-let; doseq; min-key/max-key; re-pattern and #"..."; float/double/int |
| Clojure syntax with PTC host meaning | data/path and tool/name use namespaced-symbol syntax but resolve through the host; *1/*2/*3 use familiar REPL names for Kernel turn history |
| PTC syntax/arity extensions | Multiple body expressions in for; :asc/:desc comparator keywords for sort and sort-by |
| PTC-provided helpers | sum, avg, the *-by helpers, unqualified rounding helpers, JSON helpers, and bounded Java compatibility namespaces |
| PTC control forms | return and fail communicate explicit outcomes to a Kernel host (§5.19, §5.20) |
Compatibility is tracked per feature rather than claimed for the language as a whole. The opt-in Babashka suites exercise supported cases, while intentional differences and open gaps are recorded in Clojure Conformance Gaps.
2. Lexical Structure
2.1 Whitespace
Whitespace separates tokens. The following are whitespace:
- Space (
) - Tab (
\t) - Newline (
\n,\r\n) - Comma (
,) — treated as whitespace for readability
{:a 1, :b 2} ; comma is optional
{:a 1 :b 2} ; equivalent
[1, 2, 3] ; comma is optional
[1 2 3] ; equivalent2.2 Comments
Single-line comments start with ; and extend to end of line:
; This is a comment
(+ 1 2) ; inline comment2.3 Delimiters
Lists, vectors, maps, sets, and short functions require matching closing
delimiters. A surplus ), ], or } between top-level forms or after the
last form is a :parse_error; it is never silently discarded. Diagnostics
identify the first surplus closer by line and column. When the reader recovers
past surplus closers and then encounters another hard syntax error, that later
error remains the primary diagnostic.
2.4 Identifiers (Symbols)
Symbols are names that refer to values or functions:
symbol = symbol-first symbol-rest*
symbol-first = letter | special-initial
symbol-rest = letter | digit | special-rest
letter = a-z | A-Z
digit = 0-9
special-initial = + | - | * | / | < | > | = | ? | ! | _ | % | . | &
special-rest = special-initial | 'Notes:
/appears in bothspecial-initial(for the division operator) andspecial-rest(for namespaced symbols likedata/barortool/search).enables Clojure-style multi-level namespaces (e.g.,clojure.string/join)%supports parameter placeholders in#()short function syntax (%1,%&, etc.)&supports rest parameter destructuring ([a & rest])_is allowed (e.g., ignored bindings)'is allowed only after the first character, for Clojure prime-notation names likeinc',+'
Valid symbols: filter, map, sort-by, empty?, +, ->>, high-paid, data/bar, tool/search, clojure.string/join
Reserved symbols (cannot be redefined): nil, true, false
2.5 Keywords
Keywords are symbolic identifiers that evaluate to themselves:
keyword = ":" keyword-char+
keyword-char = letter | digit | + | - | * | < | > | = | ? | ! | _Keywords use a stricter character set than symbols — /, ., %, and & are not allowed (this is why namespaced keywords are unsupported).
Examples: :name, :user-id, :total, :else
Keywords with namespaces are not supported: (see DIV-13):foo/bar
3. Data Types
3.1 Nil
The absence of a value:
nil3.2 Booleans
true
false3.3 Numbers
Integers — arbitrary-precision arithmetic (literals are limited to 100 digits):
0
42
-17
1000000Floats — double precision:
3.14
-0.5
1.0
2.5e10
1.23e-4Special Values (IEEE 754) — literals and namespaced constants:
| Literal | Constant | Description |
|---|---|---|
##Inf | Double/POSITIVE_INFINITY | Positive infinity |
##-Inf | Double/NEGATIVE_INFINITY | Negative infinity |
##NaN | Double/NaN | Not a Number |
Special values are returned by operations like division by zero ((/ 1.0 0.0)) or indeterminate forms ((/ 0.0 0.0)). They are formatted using Clojure's reader syntax (##Inf, ##-Inf, ##NaN) and are supported as both input literals and output format.
Not supported: Ratios (1/3), BigDecimals (1.0M), octal/hex literals
3.4 Strings
Double-quoted, with escape sequences:
"hello"
"hello world"
""
"line1\nline2"
"tab\there"
"quote: \""
"backslash: \\"Recognized escapes \\, \", \n, \t, and \r decode to their
corresponding characters. A backslash before any other non-newline character is
preserved verbatim (for example, "a\qb" contains the two characters \q).
Multi-line strings: Strings may contain literal newline characters (like Clojure). Escape sequences (\n, \r) also work.
Regex literals: #"..." is shorthand for (re-pattern "..."). Both forms produce compiled regex values.
String operations: Strings support count, empty?, seq, str, pr-str, subs, join, split, trim, replace, index-of, last-index-of, format, name, re-find, and re-matches. The seq function converts a string to a sequence of characters (graphemes), enabling character iteration. See Section 8.1 and 8.3 for details.
String as sequence: Strings can be used as sequences in many collection
operations. Functions like filter, map, first, last, take, drop,
reverse, sort, and others work directly on strings, treating them as
sequences of characters (graphemes). Sequence-producing operations return
vectors of single-character strings:
(first "hello") ; => "h"
(filter #(= \e %) "hello") ; => ["e"]
(map identity "abc") ; => ["a" "b" "c"]
(take 2 "hello") ; => ["h" "e"]
(count (filter #(= \r %) "raspberry")) ; => 33.5 Character Literals
Character literals provide a concise syntax for single-character strings, using Clojure's backslash notation:
\a ; => "a"
\Z ; => "Z"
\5 ; => "5"
\λ ; => "λ" (Unicode supported)Special characters use named escapes:
| Literal | Value | Description |
|---|---|---|
\newline | "\n" | Newline |
\space | " " | Space |
\tab | "\t" | Tab |
\return | "\r" | Carriage return |
\backspace | "\b" | Backspace |
\formfeed | "\f" | Form feed |
Important: Character literals are represented as single-character strings internally. This means \r produces the string "r", while \return produces "\r" (carriage return). Character equality with strings works naturally:
(= \a "a") ; => true
(= \newline "\n") ; => true
(char? \a) ; => true
(char? "ab") ; => falseUse case: Character literals are particularly useful with collection operations on strings:
;; Count occurrences of 'r' in a string
(count (filter #(= \r %) "raspberry")) ; => 3
;; Find vowels
(filter #(contains? #{\a \e \i \o \u} %) "hello") ; => ["e" "o"]3.6 Keywords
Self-evaluating symbolic identifiers:
:name
:user-id
:category
:elseKeywords can be called as functions to access map values:
(:name {:name "Alice" :age 30}) ; => "Alice"
(:missing {:name "Alice"}) ; => nil
(:missing {:name "Alice"} "default") ; => "default"Maps can also be called as functions with a keyword to access values:
({:name "Alice" :age 30} :name) ; => "Alice"
({:name "Alice"} :missing) ; => nil
({:name "Alice"} :missing "default") ; => "default"Keywords also work as predicates in higher-order functions, checking if the field is truthy:
;; As predicate in filter/remove/find (checks field truthiness)
(filter :active [{:active true} {:active false}]) ; => [{:active true}]
(remove :deleted [{:deleted true} {:deleted nil}]) ; => [{:deleted nil}]
;; As accessor in map (extracts field value)
(map :name [{:name "Alice"} {:name "Bob"}]) ; => ["Alice" "Bob"]3.7 Vectors
Ordered, indexed collections:
[]
[1 2 3]
["a" "b" "c"]
[1 "mixed" :types true nil]
[[1 2] [3 4]] ; nested3.8 Maps
Key-value associations:
{}
{:name "Alice"}
{:name "Alice" :age 30}
{:user {:name "Bob" :email "bob@example.com"}} ; nested
{"string-key" 42} ; string keys allowedMap keys: Keywords and strings are the standard map key types — keywords are preferred for their readability and self-documenting nature. Other key types (numbers, vectors) evaluate without error inside a program, but you should not rely on them for outputs because the serialization boundaries treat them inconsistently:
- Direct
PtcRunner.Lisp.run/2results: preserve numeric and vector map-key types. Source keyword keys use the bounded public keyword representation (a known atom or otherwise a string). PtcRunner.Kernel.run/2results: project every keyword key and value to its name string before either native or JSON result handling. The Kernel rejects projection collisions rather than silently choosing between a keyword and string with the same name. Native projection can retain other non-JSON values; JSON projection rejects them.ptc runalways selects JSON projection, whether or not it publishes a result artifact.- Tool-call arguments: keys are recursively normalized to strings (e.g.
1→"1",[:a :b]→ its inspected form). A projection collision or an invalid native Java value is rejected rather than silently losing data. json/generate-string: keyword keys encode as their name verbatim ({:max-turns 3}→{"max-turns":3}) and integer keys are stringified ({1 "a"}→{"1":"a"}); vector and float keys, and any two keys that would encode to the same JSON key, are refused with atype_errornaming the position (DIV-24).json/parse-lines: line-delimited JSON helper; skips blank lines and parses each remaining line withjson/parse-string.
{:name "Alice"} ; OK - keyword key
{"name" "Alice"} ; OK - string key
{1 "one"} ; evaluates fine; serialization depends on the boundary (see above)
{[:a :b] "nested"} ; evaluates fine; preserved directly, stringified for tool argsFor predictable behavior, use keyword or string keys for any map you intend to return or pass to a tool.
json/parse-lines is intentionally one-arity and shares json/parse-string's recoverable failure signal. A malformed line and a valid JSON literal null line both yield nil:
(json/parse-lines "{\"a\":1}\n\nnull\nnot json")
; => [{"a" 1} nil nil]Maps as functions: Maps can be invoked as functions to look up values by key:
| Expression | Result | Description |
|---|---|---|
({:a 1 :b 2} :a) | 1 | Keyword key lookup |
({:a 1} :missing) | nil | Missing key returns nil |
({:a 1} :missing "default") | "default" | Missing key with default |
({"name" "Alice"} "name") | "Alice" | String key lookup |
Maps are callable directly, but higher-order collection functions currently reject a map in their callable argument position. Wrap the lookup in a closure:
(let [lookup {:a 1 :b 2}]
(mapv #(lookup %) [:a :b])) ; => [1 2]3.9 Sets
Unordered collections of unique values:
#{} ; empty set
#{1 2 3} ; set with 3 elements
#{1 1 2} ; duplicates silently removed: equivalent to #{1 2}
#{:a :b :c} ; keyword setSets are unordered - iteration order is not guaranteed.
Set operations:
| Function | Signature | Description |
|---|---|---|
set? | (set? x) | Returns true if x is a set |
set | (set coll) | Convert collection to set |
vec | (vec coll) | Convert collection to vector |
vector | (vector & args) | Create vector from arguments |
count | (count #{1 2}) | Returns element count |
empty? | (empty? #{}) | Returns true if empty |
contains? | (contains? #{1 2} 1) | Membership test (O(1)) |
intersection | (clojure.set/intersection & sets) | Returns the intersection of one or more sets |
union | (clojure.set/union & sets) | Returns the union of zero or more sets |
difference | (clojure.set/difference & sets) | Returns the difference of one or more sets |
Sets as predicates: Sets can be invoked as functions to check membership:
| Expression | Result | Description |
|---|---|---|
(#{1 2 3} 2) | 2 | Element found, returns it |
(#{1 2 3} 4) | nil | Not found, returns nil |
(filter #{:a :b} [:a :c :b]) | [:a :b] | Filter using set membership |
(some #{"x"} ["a" "x"]) | "x" | Find first matching element |
Not supported for sets: first, last, nth, sort (sets are unordered). Note that sort-by is supported on sets — it iterates the elements and returns a sorted vector.
No separate list type: Quoted list data such as '() is unsupported.
The compatibility function (list ...) returns a vector, the same as
(vector ...).
3.10 Var-like Display Values
def, defonce, and defn return an inert value displayed as #'name, matching
Clojure's presentation of a Var. PTC-Lisp does not otherwise expose
first-class, dereferenceable Var objects.
For reader compatibility, #'name in source resolves the binding's value just
like the plain symbol name; it does not construct a Var value:
(def x 42) ; => #'x
(do (def x 42) #'x) ; => 42
(do (def x 42) [#'x x]) ; => [42 42]The #'name value returned by a definition may appear in a result or
collection, but evaluating the source spelling later is still ordinary binding
lookup.
3.11 Quoted Symbol References
PTC-Lisp supports inert symbol references using either 'name or
(quote name). Only symbols may be quoted; quoted collections and general
quoted data are unsupported.
'github/search-repos
(quote github/search-repos)For ordinary symbol spellings accepted by both forms, they produce the same
inert, displayable reference. The reader shorthand consumes the raw
symbol-shaped token after ', so reserved and numeric-looking spellings such
as 'nil, '-1, and '*1 are also references. Their corresponding
(quote nil), (quote -1), and (quote *1) forms are rejected because those
arguments parse as a literal or turn-history expression rather than a symbol.
References do not resolve the named binding and are useful where a host-facing
API explicitly expects a symbolic reference. This is partial quote support,
not Clojure's general data quotation. The current symbol? predicate still
returns false for these references, and type reports :unknown. Public
results and ordinary tool-call arguments expose an inert 'name display
wrapper; the Kernel rejects map-key collisions between that wrapper and an
equivalent string representation. The dedicated tool/call symbolic target
form consumes the reference before invoking its callback.
4. Truthiness
Only nil and false are falsy. Everything else is truthy:
| Value | Truthy? |
|---|---|
nil | No |
false | No |
true | Yes |
0 | Yes |
"" (empty string) | Yes |
[] (empty vector) | Yes |
{} (empty map) | Yes |
| Any other value | Yes |
(if nil "truthy" "falsy") ; => "falsy"
(if false "truthy" "falsy") ; => "falsy"
(if true "truthy" "falsy") ; => "truthy"
(if 0 "truthy" "falsy") ; => "truthy"
(if "" "truthy" "falsy") ; => "truthy"
(if [] "truthy" "falsy") ; => "truthy"
(if {} "truthy" "falsy") ; => "truthy"5. Special Forms
Special forms are fundamental constructs with special evaluation rules.
5.1 let — Local Bindings
Binds names to values for use in the body expression:
(let [name value]
body)
(let [name1 value1
name2 value2]
body)Semantics:
- Bindings are evaluated left-to-right
- Later bindings can reference earlier ones
- Bindings are scoped to the body
- Inner
letcan shadow outer bindings
(let [x 10] x) ; => 10
(let [x 10] (+ x 5)) ; => 15
(let [x 1 y 2] (+ x y)) ; => 3
(let [x 1 y (+ x 1)] y) ; => 2(let [x 10
y (+ x 5)] ; y can use x
(* x y)) ; => 150
(let [x 1]
(let [x 2] ; shadows outer x
x)) ; => 2Implicit do
As in Clojure, multiple body expressions are supported without an explicit
do:
;; Multiple expressions - last value is returned
(let [x 10]
(def saved x) ; stage a continuation binding
(* x 2)) ; => 20, saved = 10
;; Equivalent to explicit do
(let [x 10]
(do
(def saved x)
(* x 2)))Destructuring
Destructuring allows you to bind names to values within collections.
Sequential (Vector) Destructuring: Extract values from vectors by position.
; Basic sequential destructuring
(let [[a b] [1 2]]
(+ a b)) ; => 3
; Use _ to skip elements
(let [[_ b] [1 2]]
b) ; => 2
; Nested sequential destructuring
(let [[a [b c]] [1 [2 3]]]
(+ a b c)) ; => 6
; Rest pattern: bind remaining elements to a variable
(let [[x & rest] [1 2 3 4]]
rest) ; => [2 3 4]
; Rest pattern with multiple leading elements
(let [[a b & rest] [1 2 3 4 5]]
[a b rest]) ; => [1 2 [3 4 5]]
; Bind the entire remaining sequence (no leading elements)
(let [[& all] [1 2 3]]
all) ; => [1 2 3]Map Destructuring: Extract values from maps by key. Supports both keyword and string keys.
; Basic map destructuring
(let [{:keys [name age]} {:name "Alice" :age 30}]
name) ; => "Alice"
; With defaults
(let [{:keys [name age] :or {age 0}} {:name "Bob"}]
age) ; => 0
; Renaming bindings
(let [{the-name :name} {:name "Carol"}]
the-name) ; => "Carol"
; Binding the whole map with :as
(let [{:keys [id] :as user} {:id 123 :name "Alice"}]
(:name user)) ; => "Alice"
; String key destructuring (useful for JSON-like data)
(let [{:strs [name age]} {"name" "Alice" "age" 30}]
name) ; => "Alice"Supported destructuring forms:
[a b]— sequential (vector)[a & rest]— rest pattern (bind remaining elements){:keys [a b]}— map keyword keys{:strs [a b]}— map string keys{:keys [a] :or {a default}}— map with defaults{new-name :old-key}— map renaming{:keys [a] :as symbol}(or another supported map pattern plus:as) — bind the whole map in addition to its selected fields
5.2 if — Conditional
Conditional (else is optional):
(if condition
then-expression
else-expression)(if true "yes" "no") ; => "yes"
(if false "yes" "no") ; => "no"
(if (> 5 3) "bigger" "smaller") ; => "bigger"
(if (< 5 3) "bigger" "smaller") ; => "smaller"
(if (empty? []) "empty" "full") ; => "empty"
(if (empty? [1]) "empty" "full") ; => "full"Single-branch if is allowed and returns nil if the condition is false. However, when is often more idiomatic for side effects.
5.3 if-not — Negative Conditional
Swapped branch conditional. Evaluates else if condition is truthy, otherwise evaluates then.
(if-not condition
then-expression
else-expression?)Semantics:
- Desugars at analysis time to
if:(if-not cond then else)→(if cond else then)(if-not cond then)→(if cond nil then)
(if-not true "yes" "no") ; => "no"
(if-not false "yes" "no") ; => "yes"
(if-not (> 3 5) "smaller" "bigger") ; => "smaller"
(if-not true "yes") ; => nil
(if-not false "yes") ; => "yes"5.4 when — Single-branch Conditional
Returns body if condition is truthy, otherwise nil:
(when condition
body)(when true "yes") ; => "yes"
(when false "yes") ; => nil
(when (> 5 3) "bigger") ; => "bigger"
(when (< 5 3) "smaller") ; => nilImplicit do: As in Clojure, multiple body expressions are supported:
(when (> x 0)
(def positive x) ; stage a continuation binding
(* x 2)) ; return value5.5 when-not — Negative Single-branch Conditional
Returns body if condition is falsy, otherwise nil:
(when-not condition
body)Semantics:
- Desugars at analysis time to
if:(when-not cond body ...)→(if cond nil (do body ...)) - Supports implicit
dofor multiple body expressions.
(when-not false "yes") ; => "yes"
(when-not true "yes") ; => nil
(when-not (> x 0) (log "neg")) ; => result of log, or nil5.6 cond — Multi-way Conditional
Tests conditions in order, returns first matching result:
(cond
condition1 result1
condition2 result2
:else default-result)(cond
(> total 1000) "high"
(> total 100) "medium"
:else "low")Semantics:
- Conditions are evaluated in order
- First truthy condition's result is returned
:elseis conventional for default (it's truthy)- Returns
nilif no condition matches and no:else
(cond true "first" :else "default") ; => "first"
(cond false "first" :else "default") ; => "default"
(cond false "a" false "b" :else "c") ; => "c"
(cond (> 5 3) "yes" :else "no") ; => "yes"
(cond (< 5 3) "yes" :else "no") ; => "no"
(cond false "only") ; => nil5.7 case — Value Dispatch
Dispatches on an expression's value against compile-time constants:
(case expr
value1 result1
value2 result2
(:val3 :val4) result3 ; grouped match
default-result) ; optional trailing default- Test values must be compile-time constants (keywords, strings, numbers, booleans, nil)
- Grouped values
(:val1 :val2)match any value in the group - Returns
nilif no match and no default (diverges from Clojure which throws) - Expression evaluated exactly once
(case :a :a 1 :b 2) ; => 1
(case :z :a 1 :b 2 99) ; => 99
(case :c (:a :b) 1 (:c :d) 2) ; => 2
(case :z :a 1) ; => nil
(case nil nil "matched" :a "nope") ; => "matched"5.8 condp — Predicate Dispatch
Dispatches using a predicate function called as (pred test-val expr):
(condp pred expr
test1 result1
test2 result2
default-result) ; optional trailing default- Calls
(pred test-val expr)for each clause - Both
predandexprevaluated exactly once - Returns
nilif no match and no default (diverges from Clojure which throws) - The
:>>form is not supported
(condp = :a :a 1 :b 2) ; => 1
(condp > 5 10 "big" 3 "small") ; => "big" (because (> 10 5) is true)
(condp = :z :a 1 "default") ; => "default"
(condp = :z :a 1 :b 2) ; => nil5.9 if-let and when-let — Conditional Binding
Binds a value from an expression and evaluates the body only if the value is truthy.
if-let syntax:
(if-let [name condition-expr]
then-expr
else-expr)when-let syntax:
(when-let [name condition-expr]
body-expr)Semantics:
if-letevaluatescondition-expr, binds result toname, then evaluatesthen-exprif truthy, otherwiseelse-exprwhen-letis likeif-letbut returnsnilinstead of an else branch- Both only support single symbol bindings, no destructuring (see DIV-14)
- Desugars at analysis time:
(if-let [x expr] then else)→(let [x expr] (if x then else))
Examples:
(if-let [user (get-user 123)]
(str "Hello " user)
"User not found") ; => ...
(when-let [result (compute)]
(process result)) ; => result of process, or nil
(if-let [x 0]
"truthy"
"falsy") ; => "truthy" (0 is truthy in Lisp)
(if-let [x nil]
"yes"
"no") ; => "no"
(when-let [x false]
(+ x 1)) ; => nil (x is falsy, body not evaluated)Implicit do: As in Clojure, when-let supports multiple body
expressions:
(when-let [x (find-value)]
(def found x) ; stage a continuation binding
(* x 2)) ; return valueLimitations:
- Only single bindings are supported (no sequential bindings like Clojure)
- Binding names must be symbols (no destructuring patterns)
5.10 if-some and when-some — Nil-safe Conditional Binding
Like if-let/when-let but tests only for nil, not falsiness. false binds successfully.
if-some syntax:
(if-some [name expr]
then-expr
else-expr)when-some syntax:
(when-some [name expr]
body-expr ...)Semantics:
if-someevaluatesexpr, binds result toname, then evaluatesthen-exprif the value is notnil, otherwiseelse-exprwhen-somereturnsnilwhen the value isnil, otherwise evaluates the body- The key difference from
if-let/when-let:falseis treated as a valid (non-nil) value - Desugars at analysis time:
(if-some [x expr] then else)→(let [x expr] (if (nil? x) else then))
Examples:
(if-some [x 42] x :nope) ; => 42
(if-some [x nil] x :nope) ; => :nope
(if-some [x false] x :nope) ; => false (false is NOT nil)
(when-some [x 42] (inc x)) ; => 43
(when-some [x nil] (inc x)) ; => nil
(when-some [x false] x) ; => falseImplicit do: when-some supports multiple body expressions:
(when-some [x (find-value)]
(def found x)
(* x 2))5.11 when-first — First Element Binding
Binds the first element of a collection and evaluates the body only if the collection is non-empty.
Syntax:
(when-first [name coll-expr]
body-expr ...)Semantics:
- Evaluates
coll-expronce, callsseqon it - If the result is
nil(empty or nil collection), returnsnil - Otherwise, binds the first element to
nameand evaluates the body - Single-evaluation: the collection expression is only evaluated once
Examples:
(when-first [x [1 2 3]] x) ; => 1
(when-first [x []] x) ; => nil
(when-first [x nil] x) ; => nil
(when-first [x [10]]
(def a x)
(* a 2)) ; => 205.12 do — Sequential Evaluation
Evaluates expressions in order, returning the value of the last expression:
(do expr1 expr2 ... exprN)Semantics:
- All expressions are evaluated left-to-right
- The value of the last expression is returned
(do)with no expressions returnsnil- Unlike
and/or, there is no short-circuiting
1 2 3 ; => 3 (not needed at top level)
(tool/log {:msg "hi"}) ; => result of log call
(do) ; => nil5.13 def — User Namespace Binding
Binds a name to a value in the user namespace, persisting across turns:
(def name value)
(def name docstring value) ; docstring is optional; preserved in function metadata, otherwise ignoredSemantics:
- Returns the var (
#'name), not the value (like Clojure) - Creates or overwrites the binding in user namespace
- Value is evaluated before binding
- Binding is returned in candidate memory and persists when the host supplies that memory to a later evaluation
- Shadows builtin names while the binding remains in continuation memory
- Can shadow data names, but
data/prefix still works
(def x 42) ; => #'x (x = 42)
(def threshold 5000) ; => #'threshold
(def results (tool/search {})) ; => ...
; Redefinition
(def x 1) ; x = 1
(def x 2) ; x = 2 (overwrites)
; Define and return (using implicit multi-expression)
(def x 10) x ; => 10
; Reference previous defs (single evaluation)
(def a 1) (def b (+ a 1)) b ; => 2
; Shadowing builtins (like Clojure — user binding takes precedence)
(def map {}) ; => #'map (builtin map no longer accessible)Differences from Clojure:
- No
^:dynamic,^:private, or other metadata - No destructuring in def (use
letthendef) - Docstrings allowed; preserved in function metadata (via
defn), otherwise ignored
5.14 defonce — Idempotent Initialization
Binds a name to a value only if not already defined. Safe for multi-turn use:
(defonce name value)
(defonce name docstring value)Semantics:
- If name is already bound in user namespace: no-op, returns the var
- If name is not bound: evaluates value and binds it (same as
def) - Value expression is NOT evaluated if name is already bound
- Shadows builtin names (same as
def)
(defonce total-episodes 0) ; turn 1 → binds 0, turn 2+ → no-op
(def total-episodes (inc total-episodes)) ; safe to use after defonce5.15 defn — Named Function Definition
Syntactic sugar for defining named functions in the user namespace:
(defn name [params] body)
(defn name docstring [params] body) ; docstring is optional and retained in function metadataDesugars to: (def name (fn [params] body))
Semantics:
- Returns the var (
#'name), not the function - Creates or overwrites the function binding in user namespace
- Functions persist across turns via user namespace
- Can reference other user-defined symbols and functions
- Can access
data/data and calltool/tools - Shadows builtin names (same as
def)
; Note: using `twice` not `double` since `double` is a builtin (§8.4)
(defn twice [x] (* x 2)) ; => #'twice
(defn greet [name] (str "Hello, " name)) ; => #'greet
; Use defined function (single evaluation with implicit do)
(defn twice [x] (* x 2)) (twice 21) ; => 42
; Reference data/ data
(defn expensive? [e] (> (:amount e) data/threshold))
; Reference other defs (single evaluation)
(def rate 0.1) (defn apply-rate [x] (* x rate)) (apply-rate 100) ; => 10.0
; With higher-order functions
(defn expensive? [e] (> (:amount e) 5000))
(filter expensive? data/expenses) ; => filtered vectorMultiple body expressions (implicit do):
(defn with-logging [x]
(def last-input x) ; stage a continuation binding
(* x 2)) ; return valueMulti-turn persistence:
; Turn 1: Define function
(defn expensive? [e] (> (:amount e) 5000))
; Turn 2: Use function (passed via memory)
(filter expensive? data/expenses)Destructuring in parameters:
defn supports the same destructuring patterns as fn and let:
; Vector destructuring (single evaluation)
(defn first-name [[first last]] first) (first-name ["Alice" "Smith"]) ; => "Alice"
; Map destructuring (single evaluation)
(defn greet [{:keys [name]}] (str "Hello " name)) (greet {:name "World"}) ; => "Hello World"
; Nested destructuring (single evaluation)
(defn process [[id {:keys [status]}]] (str id ":" status)) (process [42 {:status "ok"}]) ; => "42:ok"Not supported: Multi-arity defn (DIV-15), pre/post conditions (DIV-16).
5.16 loop and recur — Tail Recursion
loop establishes a recursion point, and recur transfers control back to that point with new values.
loop syntax:
(loop [bindings] body)recur syntax:
(recur expr1 expr2 ...)Semantics:
loopestablishes bindings just likelet.recurcan only appear in a tail position of alooporfn.- When
recuris evaluated, it re-binds the arguments and jumps back to the start of thelooporfnbody. - Evaluation is stack-safe (no stack growth).
- An iteration check is enforced to prevent infinite loops (default limit: 1000 iterations).
Examples:
;; Summing numbers 0 to 4
(loop [i 0 acc 0]
(if (< i 5)
(recur (inc i) (+ acc i))
acc))
; => 10
;; Factorial with recur in fn
((fn [n acc]
(if (> n 0)
(recur (dec n) (* acc n))
acc))
5 1)
; => 120
;; Process a vector with rest-pattern destructuring
(loop [[head & tail] [1 2 3 4]
sum 0]
(if head
(recur tail (+ sum head))
sum))
; => 10Safety Mechanism:
PTC-Lisp enforces an iteration limit on loop/recur jumps. If a loop or tail-recursive function using recur exceeds the allowed number of iterations (default 1000), execution is terminated with a loop_limit_exceeded error. Ordinary non-tail function recursion is not counted by this limit; it remains bounded by the sandbox timeout and memory limit.
5.17 for — Eager Comprehension
for produces a vector by evaluating a body expression for each element of one or more collections. (Unlike Clojure's lazy sequence, PTC-Lisp's for returns an eager vector, displayed as [...].)
Single binding:
(for [x [1 2 3]] (* x 2))
; => [2 4 6]Multiple bindings (cartesian product):
(for [x [1 2] y ["a" "b"]] [x y])
; => [[1 "a"] [1 "b"] [2 "a"] [2 "b"]]Destructuring:
(for [[k v] {:a 1 :b 2}] (str k "=" v))
; => [":a=1" ":b=2"] (order may vary)Multi-expression body (implicit do, last value collected):
(for [x [1 2 3]]
(println x)
(* x 10))
; prints 1, 2, 3
; => [10 20 30]Comparison with map: for supports multiple bindings (cartesian product) and destructuring in bindings. For simple single-collection transforms, map is equivalent:
(map inc [1 2 3]) ; => [2 3 4]
(for [x [1 2 3]] (inc x)) ; => [2 3 4]Modifiers: :when, :let, and :while follow a binding pair and modify iteration:
;; :when — filter elements (skip on false, continue iterating)
(for [x [1 2 3 4 5] :when (odd? x)] x)
; => [1 3 5]
;; :let — introduce local bindings
(for [x [1 2 3] :let [y (* x 10)]] y)
; => [10 20 30]
;; :while — stop iterating at this level when false
(for [x [1 2 3 4 5] :while (< x 4)] x)
; => [1 2 3]
;; Combined: modifiers apply in declaration order
(for [x [1 2 3 4] :when (odd? x) :let [y (* x 10)]] y)
; => [10 30]
;; :let visible to subsequent :when
(for [x [1 2 3] :let [y (* x 2)] :when (> y 3)] y)
; => [4 6]
;; :while on inner binding only stops inner loop
(for [x [1 2] y [10 20 30] :while (< y 25)] [x y])
; => [[1 10] [1 20] [2 10] [2 20]]Multiple :when clauses act as AND (all must pass). :let supports destructuring.
5.18 doseq — Effect-oriented Iteration
doseq iterates over collections for side effects (like for, but returns nil instead of collecting results). Desugars to loop/recur at analysis time.
Syntax:
(doseq [binding coll] body)
(doseq [b1 coll1 b2 coll2] body) ; nested loopsSemantics:
- Iterates over each element, executing the body for side effects
- Supports multiple bindings (nested loops, cartesian product)
- Supports destructuring in bindings
- Supports
:when,:let,:whilemodifiers (same asfor) - Always returns
nil
(doseq [x [1 2 3]] (println x))
; prints 1, 2, 3
; => nil
(doseq [x [1 2] y ["a" "b"]] (println x y))
; prints "1 a", "1 b", "2 a", "2 b"
; => nil
(doseq [[a b] [[1 2] [3 4]]] (println (+ a b)))
; prints 3, 7
; => nil5.19 return — Signal Successful Completion
return immediately terminates the current evaluation with an explicit
successful outcome. A Kernel host uses that outcome to complete a multi-turn
run. Direct PtcRunner.Lisp.run/2 reports the outcome as
{:__ptc_return__, value} in Result.return.
Syntax:
(return value)Semantics:
- Immediately terminates the current program execution
- The outer call to
PtcRunner.Lisp.run/2succeeds andResult.returncontains{:__ptc_return__, value} - Cannot be used inside
pmaporpcalls(raises an error)
;; Signal completion in a multi-turn loop
(if (>= (count results) target)
(return {:status "complete" :results results})
(tool/fetch-more {}))5.20 fail — Signal Failure
fail immediately terminates the current evaluation with an explicit
language-level failure outcome. A Kernel host uses that outcome to abort and
roll back a multi-turn run. Direct PtcRunner.Lisp.run/2 reports it as a
successful evaluation whose Result.return is {:__ptc_fail__, value}; this
is distinct from an evaluator error returned under the outer :error tag.
Syntax:
(fail error)Semantics:
- Immediately terminates the current program execution
- The outer call to
PtcRunner.Lisp.run/2succeeds andResult.returncontains{:__ptc_fail__, value} - Cannot be used inside
pmaporpcalls(raises an error)
;; Signal failure when a required condition isn't met.
;; This tests whether the granted value is `nil`, which is a different question
;; from whether the name was granted at all. Under the Kernel -- a workflow
;; entry, a mission run, or either REPL -- an ungranted `data/<name>` is a
;; runtime error, not `nil`, so `nil?` cannot be used to probe for one.
;; `PtcRunner.Lisp.run/2` stays permissive and answers `nil` for both.
(if (nil? data/input)
(fail "No input data provided")
(process data/input))6. Threading Macros
Threading macros transform nested function calls into linear pipelines.
6.1 ->> — Thread Last
Threads the value as the last argument to each form:
(->> value
(fn1 arg1)
(fn2 arg2)
(fn3))Equivalent to:
(fn3 (fn2 arg2 (fn1 arg1 value)))Primary use: Collection pipelines where data is the last argument.
(->> [1 2 3] (map inc)) ; => [2 3 4]
(->> [1 2 3 4] (filter odd?)) ; => [1 3]
(->> [3 1 2] (sort)) ; => [1 2 3]
(->> [1 2 3] (map inc) (filter even?)) ; => [2 4]
(->> [1 2 3 4 5] (filter odd?) (take 2)) ; => [1 3]6.2 -> — Thread First
Threads the value as the first argument to each form:
(-> value
(fn1 arg1)
(fn2 arg2))Equivalent to:
(fn2 (fn1 value arg1) arg2)Primary use: Map transformations where data is the first argument.
(-> {:a 1} (assoc :b 2)) ; => {:a 1 :b 2}
(-> {:a 1 :b 2} (dissoc :b)) ; => {:a 1}
(-> {:a 1} (assoc :b 2) (assoc :c 3)) ; => {:a 1 :b 2 :c 3}
(-> {:a {:b 1}} (get-in [:a :b])) ; => 1
(-> {:a 1} (update :a inc)) ; => {:a 2}6.3 as-> — Named Thread
Binds the threaded value to a name, making it available in any argument position:
(as-> expr name
form1
form2)The name is rebound at each step to the result of the previous form. With zero forms, returns the expr directly.
(as-> 1 x (+ x 1) (* x 2)) ; => 4
(as-> 42 x) ; => 42
(as-> [1 2 3] x (count x)) ; => 36.4 cond-> and cond->> — Conditional Threading
Threads through forms only where the corresponding test is true:
(cond-> expr
test1 form1
test2 form2)cond->threads as the first argument (like->)cond->>threads as the last argument (like->>)- Requires even number of test/form pairs after the initial expression
- With zero clauses, returns expr unchanged
(cond-> 1 true inc false dec) ; => 2
(cond-> 42 false inc false dec) ; => 42
(cond-> 42) ; => 42
(cond-> 10 true (- 5)) ; => 5 (thread-first)
(cond->> 10 true (- 5)) ; => -5 (thread-last)6.5 some-> and some->> — Nil-safe Threading
Threads through forms, short-circuiting to nil if any intermediate result is nil:
(some-> expr form1 form2)some->threads as the first argumentsome->>threads as the last argumentfalseis NOT nil — threading continues throughfalsevalues- With no pipeline steps —
(some-> expr)— returnsexprdirectly
(some-> 1 inc) ; => 2
(some-> nil inc) ; => nil (short-circuits)
(some-> {:a nil} (:a) inc) ; => nil (mid-chain nil)
(some-> false not) ; => true (false is not nil)
(some->> [1 2 3] (map inc)) ; => [2 3 4]
(some->> nil (map inc)) ; => nil7. Filtering Predicates
Filtering operations (filter, remove, some, every?, not-any?, not-every?, take-while, drop-while) accept any callable as a predicate. The three common shapes are:
| Shape | Example | When to use |
|---|---|---|
| Keyword accessor | (filter :active users) | Truthy check on a single field |
Anonymous fn / #() | (filter (fn [u] (> (:age u) 18)) users) | Field comparisons, multi-field logic |
| Named function | (filter even? xs) | Standard or user-defined predicate |
(count (filter :active [{:active 1} {:active nil} {:active false}])) ; => 1
(count (filter (fn [m] (> (:x m) 1)) [{:x 1} {:x 2} {:x 3}])) ; => 2
(count (filter #(= (:status %) "active") [{:status "active"} {:status "x"}])) ; => 17.1 Combining Predicates with and / or / not
To check several conditions, build the boolean inside a single fn:
;; All conditions must hold
(filter (fn [u] (and (= (:status u) "active") (>= (:age u) 18))) users)
;; At least one condition must hold
(filter (fn [u] (or (= (:role u) "admin") (= (:role u) "moderator"))) users)
;; Negation
(remove (fn [i] (:deleted i)) items)(count (filter (fn [m] (and (= (:a m) 1) (= (:b m) 2)))
[{:a 1 :b 2} {:a 1 :b 3}])) ; => 1
(count (filter (fn [m] (or (= (:a m) 1) (= (:a m) 2)))
[{:a 1} {:a 2} {:a 3}])) ; => 27.2 Membership Testing
Use contains? against a set or vector:
;; Pick orders whose status is "active" or "pending"
(filter (fn [o] (contains? #{"active" "pending"} (:status o))) orders)For a variable membership set built from the data itself:
(let [premium-ids (->> users
(filter (fn [u] (= (:tier u) "premium")))
(map :id))
premium-set (set premium-ids)]
(filter (fn [o] (contains? premium-set (:user-id o))) orders))7.3 Nested Field Access in Predicates
Use get-in (or sequential (:b (:a item))) for nested fields:
(filter (fn [u] (= (get-in u [:profile :verified]) true)) users)
(filter (fn [i]
(let [age (get-in i [:user :age])]
(and (number? age) (> age 18))))
items)7.4 Nil Handling in Predicates
Ordering comparisons (>, <, >=, <=) are numeric predicates, matching
Clojure. With two or more arguments, every pair that is reached must contain
numbers; nil, strings, keywords, maps, and Java temporal values signal a
structured :type_error. Validate dynamic fields explicitly when malformed
input should be skipped rather than fail the evaluation:
;; Safe: compare only numeric ages
(filter (fn [u] (and (number? (:age u)) (> (:age u) 18))) users)For equality checks nil is well-defined:
(filter (fn [m] (= (:field m) nil)) items) ; explicitly match nil
(filter (fn [m] (some? (:field m))) items) ; field exists and is not nil7.5 Flexible Key Access — String and Atom Keys
Keyword accessors ((:status m), (get m :status), (get-in m [:a :b])) and the key-based aggregators (sort-by, sum-by, avg-by, min-by, max-by, distinct-by, group-by) support bidirectional key matching:
- Atom keys in code (
:status) match both atom and string keys in data - String keys in code (
"status") match both string and atom keys in data - When both exist on the same map, the exact key type matching the accessor takes precedence (an atom accessor wins the atom key; a string accessor wins the string key)
- As a final fallback, hyphens in the key name are normalized to underscores and retried (so
:turn-summariesmatches a:turn_summariesor"turn_summaries"key)
;; Atom keys (host-map style)
(filter (fn [u] (= (:status u) "active")) users)
;; Works with string-keyed data from JSON APIs
(filter (fn [u] (= (:status u) "active")) data)
;; A %{"status" => "active"} entry matches.
;; String key parameter also works (useful for LLM-generated code)
(sort-by "price" products)
(sum-by "amount" expenses)
;; Nested access with mixed key types
(filter (fn [i] (= (get-in i [:user :email]) "alice@example.com")) items)
;; Matches both %{user: %{"email" => ...}} and %{"user" => %{email: ...}}.How it works:
- When looking up a field, the accessor tries the exact key type first.
- If not found, it falls back to the alternative type (atom ↔ string).
- If still not found, the key name's hyphens are normalized to underscores and both atom and string forms of the normalized name are tried.
- When both exist on the same map, the exact key type takes precedence.
- This applies to nested fields independently at each level.
- Missing fields at any level still return
nil.
This eliminates the need to manually convert JSON responses to atom-keyed maps before filtering and gives some resilience against LLM-generated code that uses strings instead of keywords.
8. Core Functions
Complete function list: See Function Reference for all supported functions with signatures, generated from the canonical registries at
priv/functions.exsandpriv/java_interop.exs. This section covers semantics, edge cases, and examples.
8.1 Collection Operations
Filtering
| Function | Signature | Description |
|---|---|---|
filter | (filter pred coll) | Keep items where pred is truthy |
filterv | (filterv pred coll) | Same as filter (vectors are the default) |
remove | (remove pred coll) | Remove items where pred is truthy |
keep | (keep f coll) | Non-nil results of (f item). false is kept. |
keep-indexed | (keep-indexed f coll) | Non-nil results of (f index item). false is kept. |
dedupe | (dedupe coll) | Remove consecutive duplicates |
;; Using a keyword directly (concise, checks truthiness)
(filter :active users)
(remove :deleted items)
;; Using an anonymous fn for richer comparisons
(filter (fn [u] (> (:age u) 18)) users)
;; "First match" is (first (filter ...)) — find is map/vector lookup, not search
(first (filter (fn [u] (= (:id u) 42)) users))find is not a predicate search: it is associative lookup. (find coll key) returns the [key value] entry for key in a map, or the [index value] entry for a non-negative integer index in a vector, or nil when
absent — mirroring Clojure. It distinguishes a present nil value from a
missing key: (find {:a nil} :a) => [:a nil] while (find {:a 1} :b) =>
nil. Non-associative inputs (sets, strings) return a :type_error signal
(DIV-48).
(find {:a 1 :b 2} :b) ;=> [:b 2]
(find {:a 1} :z) ;=> nil
(find [10 20 30] 2) ;=> [2 30]
(find [10 20] 5) ;=> nilMap support: filter and remove accept maps as input, treating each entry
as a [key value] pair passed to the predicate. They return a vector of
[key value] pairs (not a map):
;; Filter map entries by value
(filter (fn [[k v]] (> v 100)) {:food 50 :travel 200 :office 150})
;; => [[:travel 200] [:office 150]]
;; Remove entries where value is nil
(remove (fn [[k v]] (nil? v)) {:a 1 :b nil :c 3})
;; => [[:a 1] [:c 3]]keep applies a function and returns non-nil results (hybrid of map + filter). Unlike filter, it returns f's results, not the original items. Unlike map, it drops nil results. false is kept:
;; keep only odd numbers, returning them
(keep (fn [x] (when (odd? x) x)) (range 10))
;; => [1 3 5 7 9]
;; identity keeps false but drops nil
(keep identity [false nil 1 2 nil 3])
;; => [false 1 2 3]
;; transform and filter in one step
(keep (fn [x] (when (> x 2) (* x x))) [1 2 3 4 5])
;; => [9 16 25]
;; keep over map entries (sorted - map iteration order varies)
(sort (keep (fn [[k v]] (when (> v 1) k)) {:a 1 :b 2 :c 3}))
;; => [:b :c]Transforming
| Function | Signature | Description |
|---|---|---|
map | (map f coll) | Apply f to each item |
map | (map f c1 c2) | Apply f to pairs from c1, c2 |
map | (map f c1 c2 c3) | Apply f to triples |
mapcat | (mapcat f coll) | Apply f to each item, concatenate results |
pmap | (pmap f coll) | Apply f to each item in parallel |
pmap | (pmap f c1 c2 ...) | Apply f to zipped items from multiple collections in parallel |
pcalls | (pcalls f1 f2 ...) | Execute thunks in parallel |
mapv | (mapv f coll) | Like map, returns vector |
mapv | (mapv f c1 c2) | Like map with two collections |
mapv | (mapv f c1 c2 c3) | Like map with three collections |
map-indexed | (map-indexed f coll) | Apply f to index and item |
select-keys | (select-keys map keys) | Pick specific keys |
(map :name users) ; extract :name from each item
(mapcat (fn [x] [x (* x 2)]) [1 2 3]) ; => [1 2 2 4 3 6] (map + flatten)
(pmap :name users) ; same, but parallel execution
(pcalls #(tool/get-user) #(tool/get-stats)) ; parallel heterogeneous calls
(mapv :name users) ; same, ensures vector
(map-indexed (fn [i x] [i x]) ["a" "b"]) ; => [[0 "a"] [1 "b"]]
(select-keys user [:name :email]) ; pick keys from map
;; Multi-arity map - parallel iteration over collections
(map + [1 2 3] [10 20 30]) ; => [11 22 33]
(map (fn [a b] [a b]) [1 2] [:a :b]) ; => [[1 :a] [2 :b]]
(map + [1 2 3 4] [10 20]) ; => [11 22] (stops at shortest)
;; 3-collection map requires explicit closure for variadic ops
(map (fn [a b c] (+ a b c)) [1 2] [10 20] [100 200]) ; => [111 222]
;; mapcat - apply function and concatenate results (flat_map)
(mapcat (fn [x] (range 0 x)) [2 3 1]) ; => [0 1 0 1 2 0]
(mapcat identity [[1 2] [3 4] [5]]) ; => [1 2 3 4 5] (flatten one level)
(mapcat (fn [x] (if (> x 0) [x] [])) [-1 2 -3 4]) ; => [2 4] (filter + flatten)Limitation: Variadic builtins (+, *, str) don't work directly with 3-collection map—use explicit closures. See #668.
Note: Since PTC-Lisp has no lazy sequences (see §13), map and mapv are functionally identical—both return vectors. mapv is provided for Clojure compatibility and to make intent explicit.
Parallel Map (pmap): Executes the function for each element concurrently using BEAM processes. Useful when the mapping function involves I/O-bound operations (like tool calls) that can benefit from parallelism:
;; Process multiple items in parallel - much faster for I/O-bound tasks
(pmap #(tool/fetch-data {:id %}) item-ids)
;; Closures work - captures outer scope at evaluation time
(let [factor 10]
(pmap #(* % factor) [1 2 3])) ; => [10 20 30]
;; pmap is also a callable value
(apply pmap [inc [1 2 3]]) ; => [2 3 4]
((partial pmap inc) [1 2 3]) ; => [2 3 4]
(map pmap [inc dec] [[1 2] [3 4]]) ; => [[2 3] [2 3]]pmap resolves in value position like an ordinary function. It may be stored,
passed to higher-order functions, composed with partial or fnil, and
invoked with apply. A direct (pmap ...) call retains the analyzer-optimized
path; direct and indirect invocation share the same evaluator, limits,
effects, and failure semantics.
pmap semantics:
- Order is preserved - results match input order
- Shares
map's finite seqable contract:(pmap inc nil)→[], strings map over graphemes ((pmap str "ab")→["a" "b"]), and multiple collections zip element-wise, truncating to the shortest ((pmap + [1 2 3] [10 20])→[11 22]) - Each parallel branch gets a read-only snapshot of the user namespace
- Writes within branches (via
def) are isolated and discarded - Errors in any branch propagate to the caller
- Effects completed before the selected expected branch failure remain available in the result's existing diagnostic fields when that worker returns an error context; concurrently returned secondary failures are not retained
- The local scheduling window defaults to
2 × CPU cores; the program-wide worker cap in §15.2 is the hard aggregate bound - Kernel-owned evaluations set that worker cap to the run's effective
live_provider_tasksceiling. A wider collection is processed in bounded batches, so parallel tool calls do not race for a smaller provider-task budget - The whole operation, including nested parallel calls, shares one default 5-second deadline
Parallel Calls (pcalls): Executes multiple zero-arity functions (thunks) concurrently and returns their results as a vector. Unlike pmap which applies one function to many items, pcalls runs multiple different functions in parallel:
;; Fetch multiple pieces of data in parallel
(let [[user stats config] (pcalls
#(tool/get-user {:id data/user-id})
#(tool/get-stats {:id data/user-id})
#(tool/get-config {}))]
{:user user :stats stats :config config})
;; Simple parallel computations
(pcalls #(+ 1 1) #(* 2 3) #(- 10 5)) ; => [2 6 5]
;; pcalls is also a variadic callable value
(apply pcalls [#(+ 1 1) #(* 2 3)]) ; => [2 6]pcalls may likewise be stored, passed to higher-order functions, composed,
and invoked with apply. It accepts zero thunks, so using pcalls itself as a
pcalls thunk is valid; pmap is rejected during zero-arity preflight before
any worker starts. Saved parallel callables resolve evaluator authority and
limits from the evaluation that invokes them, not the evaluation that stored
them.
pcalls semantics:
- Order is preserved - results match argument order
- All functions must be zero-arity thunks (use
#()syntax) - If any function fails, entire
pcallsexpression fails (atomic) - Errors include the failed function index and error details
- Each parallel branch gets a read-only snapshot of the user namespace
- Effects completed before the selected expected thunk failure remain available in the result's existing diagnostic fields when that worker returns an error context; concurrently returned secondary failures are not retained
- The local scheduling window defaults to
2 × CPU cores; the program-wide worker cap in §15.2 is the hard aggregate bound - The whole operation, including nested parallel calls, shares one default 5-second deadline
Ordering
| Function | Signature | Description |
|---|---|---|
sort | (sort coll) | Sort by natural order |
sort | (sort comparator coll) | Sort with comparator (:asc, :desc, a 2-arg fn, or a boolean comparator) |
sort-by | (sort-by keyfn coll) | Sort by extracted key |
sort-by | (sort-by keyfn comp coll) | Sort with comparator |
reverse | (reverse coll) | Reverse order |
Sortable types: Default sort and sort-by use the same comparison contract
as Clojure's compare where PTC has the same value types: nil sorts first;
numbers use numeric order; strings,
keywords, booleans, and vectors use their natural order; and same-class
validated Java temporal values use their Java natural order. Incompatible
types, maps used as comparison values, cross-class temporal values, and
malformed Java wrappers signal structured errors. Direct maps remain accepted
as collections and are sorted as [key value] entry vectors. PTC character
literals are one-character strings, so they compare and sort with strings;
Clojure instead has a distinct Character type and rejects mixed
Character/String ordering (GAP-S120).
(sort [3 1 2]) ; => [1 2 3]
(sort ["b" "a" "c"]) ; => ["a" "b" "c"]
(sort :desc [1 3 2]) ; => [3 2 1] (Clojure extension)
(sort :asc [3 1 2]) ; => [1 2 3] (Clojure extension)
(sort-by :price products) ; ascending by price
(sort-by :price > products) ; descending by price (boolean comparator)
(sort-by :price :desc products) ; descending by price (simplified keyword)
(sort-by :price (fn [a b] (compare b a)) products) ; descending by price (Clojure-style)
(sort-by :name products) ; alphabetical by name
(sort-by first [["b" 2] ["a" 1] ["c" 3]]) ; => [["a" 1] ["b" 2] ["c" 3]]
(sort-by (fn [x] (nth x 1)) > [["a" 2] ["b" 1] ["c" 3]]) ; descending by second element
(reverse [1 2 3]) ; => [3 2 1]Heterogeneous values without a shared natural order fail rather than inheriting an implementation-specific type precedence. Normalize the data or pass an explicit domain comparator when mixed representations are intentional.
(sort [1 "a"]) ; => TYPE ERRORMap support: The one-argument sort form and both sort-by forms accept
maps, treating each entry as a [key value] pair. They return a vector of
[key value] pairs (not a map) to preserve sort order. The two-argument
(sort comparator coll) form does not accept maps.
;; Sort map entries by key/value pair order
(sort {:b 2 :a 1})
;; => [[:a 1] [:b 2]]
;; Sort map by values (descending)
(sort-by second > {:food 100 :travel 500 :office 200})
;; => [[:travel 500] [:office 200] [:food 100]]
;; Sort map by keys
(sort-by first {:z 1 :a 2 :m 3})
;; => [[:a 2] [:m 3] [:z 1]]Subsetting
| Function | Signature | Description |
|---|---|---|
first | (first coll) | First item or nil |
second | (second coll) | Second item or nil |
last | (last coll) | Last item or nil |
nth | (nth coll idx) | Item at index or nil |
rest | (rest coll) | All but first (empty vector if none) |
butlast | (butlast coll) | All but last (nil if none) |
next | (next coll) | All but first (nil if none) |
ffirst | (ffirst coll) | First of first |
fnext | (fnext coll) | First of next |
nfirst | (nfirst coll) | Next of first |
nnext | (nnext coll) | Next of next |
take | (take n coll) | First n items |
drop | (drop n coll) | Skip first n items |
nthrest | (nthrest coll n) | Drop first n items (alias for drop with swapped args) |
nthnext | (nthnext coll n) | Drop first n items, returning a seq or nil if empty |
take-last | (take-last n coll) | Last n items |
drop-last | (drop-last coll) (drop-last n coll) | All but last n items (default n=1) |
take-while | (take-while pred coll) | Take while pred is true |
drop-while | (drop-while pred coll) | Drop while pred is true |
distinct | (distinct coll) | Remove duplicates |
split-at | (split-at n coll) | Split into [(take n coll) (drop n coll)] |
split-with | (split-with pred coll) | Split into [(take-while pred coll) (drop-while pred coll)] |
partition | (partition n coll) | Chunk into groups of n (incomplete groups discarded) |
partition | (partition n step coll) | Sliding window chunks (incomplete discarded) |
partition | (partition n step pad coll) | Sliding window with pad collection for incomplete groups |
partition-all | (partition-all n coll) | Chunk into groups of n (incomplete groups included) |
partition-all | (partition-all n step coll) | Sliding window chunks (incomplete included) |
partition-by | (partition-by f coll) | Partition when f's return value changes |
(first [1 2 3]) ; => 1
(first []) ; => nil
(second [1 2 3]) ; => 2
(last [1 2 3]) ; => 3
(nth [1 2 3] 1) ; => 2
(nth [1 2 3] 10) ; => nil (out of bounds)
(rest [1 2 3]) ; => [2 3]
(rest []) ; => []
(butlast [1 2 3 4]) ; => [1 2 3]
(butlast [1]) ; => nil
(butlast []) ; => nil
(next [1 2 3]) ; => [2 3]
(next []) ; => nil
(next [1]) ; => nil
(ffirst [[1 2] [3]]) ; => 1
(fnext [1 2 3]) ; => 2
(nfirst [[1 2] [3]]) ; => [2]
(nnext [1 2 3 4]) ; => [3 4]
(take 2 [1 2 3 4]) ; => [1 2]
(drop 2 [1 2 3 4]) ; => [3 4]
(distinct [1 2 1 3]) ; => [1 2 3]
;; partition - chunk collection into groups (incomplete discarded)
(partition 2 [1 2 3 4 5 6]) ; => [[1 2] [3 4] [5 6]]
(partition 3 [1 2 3 4 5]) ; => [[1 2 3]] (incomplete discarded)
(partition 2 1 [1 2 3 4]) ; => [[1 2] [2 3] [3 4]] (sliding window)
(partition 3 3 [0] [1 2 3 4 5]) ; => [[1 2 3] [4 5 0]] (pad incomplete with [0])
;; partition-all - like partition but includes incomplete groups
(partition-all 2 [1 2 3 4 5]) ; => [[1 2] [3 4] [5]]
(partition-all 3 [1 2 3 4 5]) ; => [[1 2 3] [4 5]]
;; split-at - split at index
(split-at 2 [1 2 3 4 5]) ; => [[1 2] [3 4 5]]
(split-at 0 [1 2 3]) ; => [[] [1 2 3]]
(split-at -1 [1 2 3]) ; => [[] [1 2 3]] (negative clamps to 0)
;; split-with - split by predicate (takes while true, then rest)
(split-with pos? [1 2 -1 3]) ; => [[1 2] [-1 3]]
(split-with even? [2 4 5 6]) ; => [[2 4] [5 6]]
;; partition-by - partition when function value changes
(partition-by odd? [1 1 2 2 3]) ; => [[1 1] [2 2] [3]]
(partition-by identity [1 1 2 3 3]) ; => [[1 1] [2] [3 3]]
;; dedupe - remove consecutive duplicates
(dedupe [1 1 2 3 3 2]) ; => [1 2 3 2]
(dedupe "aabcc") ; => ["a" "b" "c"]
;; keep-indexed - keep non-nil results of (f index item)
(keep-indexed (fn [i v] (if (odd? i) v)) [:a :b :c :d]) ; => [:b :d]
(keep-indexed (fn [i v] (when (even? i) v)) [10 20 30]) ; => [10 30]take-while and drop-while with keywords:
;; Using keyword directly (checks field truthiness)
(take-while :active [{:active true} {:active true} {:active false}])
;; => [{:active true} {:active true}]
(drop-while :pending [{:pending true} {:pending true} {:pending false}])
;; => [{:pending false}]Combining
| Function | Signature | Description |
|---|---|---|
cons | (cons x seq) | Prepend item to sequence |
conj | (conj coll x ...) | Add elements to collection |
concat | (concat coll1 coll2 ...) | Join collections |
into | (into to from) | Pour from into to |
flatten | (flatten coll) | Flatten nested collections |
interleave | (interleave c1 c2 ...) | Interleave collections |
interpose | (interpose sep coll) | Insert separator between elements |
zip | (zip c1 c2) | Combine into pairs |
zipmap | (zipmap keys vals) | Create map from keys and values seqs |
hash-set | (hash-set & items) | Create a set from the given items |
empty | (empty coll) | Return empty collection of same type |
peek | (peek coll) | Return last element without removing |
pop | (pop coll) | Return collection without last element |
subvec | (subvec v start) (subvec v start end) | Return subvector (clamps indices) |
disj | (disj set x ...) | Remove elements from set |
(cons 0 [1 2 3]) ; => [0 1 2 3]
(cons 0 nil) ; => [0]
(conj [1 2] 3) ; => [1 2 3]
(conj #{1 2} 3) ; => #{1 2 3}
(conj {:a 1} [:b 2]) ; => {:a 1 :b 2}
(concat [1 2] [3 4]) ; => [1 2 3 4]
(into [] [1 2 3]) ; => [1 2 3]
(into [] {:a 1 :b 2}) ; => [[:a 1] [:b 2]]
(into #{} [1 2 2 3]) ; => #{1 2 3}
(into {} [[:a 1] [:b 2]]) ; => {:a 1 :b 2}
(into #{} {:a 1}) ; => #{[:a 1]}
(into {} #{[:a 1]}) ; => {:a 1}
(flatten [[1 2] [3 [4]]]) ; => [1 2 3 4]
(interpose ", " ["a" "b" "c"]) ; => ["a" ", " "b" ", " "c"]
(zip [1 2] [:a :b]) ; => [[1 :a] [2 :b]]
(zipmap [:a :b :c] [1 2 3]) ; => {:a 1 :b 2 :c 3}
(zipmap [:a :b] [1 2 3]) ; => {:a 1 :b 2} (truncates to shorter)
(empty [1 2 3]) ; => []
(empty {:a 1}) ; => {}
(empty nil) ; => nil
(peek [1 2 3]) ; => 3
(peek []) ; => nil
(pop [1 2 3]) ; => [1 2]
(pop []) ; => nil
(subvec [0 1 2 3 4] 1 3) ; => [1 2]
(subvec [0 1 2 3 4] 2) ; => [2 3 4]
(disj #{1 2 3} 2) ; => #{1 3}
(disj #{1 2 3} 2 3) ; => #{1}Combinatorial
| Function | Signature | Description |
|---|---|---|
combinations | (combinations coll n) | Generate all n-combinations |
(combinations [1 2 3] 2) ; => [[1 2] [1 3] [2 3]]
(combinations [1 2 3 4] 3) ; => [[1 2 3] [1 2 4] [1 3 4] [2 3 4]]
(combinations [1 2 3] 0) ; => [[]] (empty subset)
(combinations [1 2] 3) ; => [] (n > length)
;; Practical: find pairs that sum to target
(->> (combinations [1 2 3 4 5] 2)
(filter (fn [[a b]] (= (+ a b) 6))))
;; => [[1 5] [2 4]]Tree Traversal
| Function | Signature | Description |
|---|---|---|
walk | (walk inner outer form) | Generic tree walker - applies inner to children, outer to result |
prewalk | (prewalk f form) | Transform tree top-down (pre-order traversal) |
postwalk | (postwalk f form) | Transform tree bottom-up (post-order traversal) |
tree-seq | (tree-seq branch? children root) | Flatten tree to depth-first sequence |
These functions work on all collection types: vectors, maps, and sets.
;; walk: apply inner to each element, then outer to the result
(walk inc #(apply + %) [1 2 3]) ; => 9 (inc each, then sum)
(walk identity identity [1 2 3]) ; => [1 2 3]
(walk inc identity #{1 2 3}) ; => #{2 3 4} (sets are preserved)
;; prewalk: transform nodes top-down
(prewalk #(if (number? %) (inc %) %) [1 [2 3]])
; => [2 [3 4]]
;; postwalk: transform nodes bottom-up
(postwalk #(if (number? %) (inc %) %) [1 [2 3]])
; => [2 [3 4]]
;; postwalk can aggregate after children are processed
(postwalk #(if (vector? %) (apply + %) %) [[1 2] [3 4]])
; => 10 (inner vectors sum first: [3 7], then outer sums: 10)
;; tree-seq: flatten hierarchical data
(let [tree {:id 1 :children [{:id 2 :children []} {:id 3 :children []}]}]
(map :id (tree-seq :children :children tree)))
; => [1 2 3]
;; Practical: find all nodes matching criteria in a tree
(let [tree {:name "root" :value 10 :children [
{:name "a" :value 5 :children []}
{:name "b" :value 15 :children []}]}]
(->> (tree-seq :children :children tree)
(filter #(> (:value %) 10))
(map :name)))
; => ["b"]
;; Transform all values in a nested structure
(prewalk #(if (and (map? %) (:value %))
(update % :value inc)
%)
{:value 1 :nested {:value 2}})
; => {:value 2 :nested {:value 3}}Conversion
| Function | Signature | Description |
|---|---|---|
seq | (seq coll) | Convert to sequence (nil if empty) |
The seq function converts a value to PTC-Lisp's eager sequence representation:
- Vectors: Returns the vector unchanged, or nil if empty
- Strings: Returns a vector of characters (graphemes), or nil if empty
- Sets: Returns a vector of elements, or nil if empty
- Maps: Returns a vector of
[key value]pairs, or nil if empty - nil: Returns nil
(seq [1 2 3]) ; => [1 2 3]
(seq []) ; => nil
(seq "hello") ; => ["h" "e" "l" "l" "o"]
(seq "") ; => nil
(seq #{1 2 3}) ; => [1 2 3] or another order (sets are unordered)
(seq {}) ; => nil
(seq {:a 1 :b 2}) ; => [[:a 1] [:b 2]]
(count (seq "abc")) ; => 3 (iterate over characters)Aggregation
| Function | Signature | Description |
|---|---|---|
count | (count coll) | Number of items |
reduce | (reduce f coll) | Fold using the first element as the initial value |
reduce | (reduce f init coll) | Fold collection with an explicit initial value |
sum | (sum coll) | Sum of numbers |
avg | (avg coll) | Average of numbers |
sum-by | (sum-by key coll) | Sum field values |
avg-by | (avg-by key coll) | Average field values |
min-by | (min-by f coll) | Item with minimum f-value in coll |
min-by | (min-by f x y & more) | Minimum item among individual args |
max-by | (max-by f coll) | Item with maximum f-value in coll |
max-by | (max-by f x y & more) | Maximum item among individual args |
distinct-by | (distinct-by key coll) | Items with unique field values |
min-key | (min-key f x y & more) | Return x for which (f x) is least |
max-key | (max-key f x y & more) | Return x for which (f x) is greatest |
group-by | (group-by keyfn coll) | Group items by key |
frequencies | (frequencies coll) | Count occurrences of each item |
(count [1 2 3]) ; => 3
(reduce + 0 [1 2 3]) ; => 6
(reduce - 10 [1 2 3]) ; => 4 (10 - 1 - 2 - 3, Clojure style: f receives (acc, elem))
;; reduce on maps (v is [key value] pair)
;; NOTE: 3-arg form is preferred for maps as the 2-arg form uses the first [k v] pair as init.
(reduce (fn [acc [k v]] (+ acc v)) 0 {:a 1 :b 2}) ; => 3
;; reduce on strings (iterates over graphemes)
(reduce (fn [acc x] (str acc "-" x)) "a" "bc") ; => "a-b-c"
;; reduce on sets
(reduce + 0 #{1 2 3}) ; => 6
;; sum and avg for simple number collections
(sum [1 2 3 4 5]) ; => 15
(avg [1 2 3 4 5]) ; => 3.0
(avg [10 20]) ; => 15.0
(avg []) ; => nil (empty collection)
(sum []) ; => 0
(sum-by :amount expenses) ; sum of :amount fields
(avg-by :price products) ; average of :price fields
(min-by :price products) ; item with lowest price
(max-by :years employees) ; item with highest years
(group-by :category products) ; map of category -> items
(distinct-by :category products) ; one item per category (first occurrence)
(frequencies [:a :b :a :c :b :a]) ; => {:a 3, :b 2, :c 1}
(frequencies "hello") ; => {"h" 1, "e" 1, "l" 2, "o" 1}
(frequencies (map :status orders)) ; count orders by status
(min-by first [["b" 2] ["a" 1]]) ; => ["a" 1] (item with minimum first element)
(max-by (fn [x] (nth x 1)) [["a" 2] ["b" 3]]) ; item with maximum second element
(sum-by (fn [x] (nth x 1)) [["a" 2] ["b" 3]]) ; => 5 (sum second elements)
(group-by first [["a" 1] ["a" 2] ["b" 3]]) ; {"a" [["a" 1] ["a" 2]], "b" [["b" 3]]}
(distinct-by first [["a" 1] ["a" 2] ["b" 3]]) ; [["a" 1] ["b" 3]] (first of each key)
;; max-key / min-key - compare variadic args using function
(max-key count "a" "abc" "ab") ; => "abc" (longest string)
(min-key count "abc" "a" "ab") ; => "a" (shortest string)
(max-key #(nth % 1) ["a" 1] ["b" 5] ["c" 3]) ; => ["b" 5]
;; Common pattern: find map entry with max/min value using apply
(apply max-key second (seq {:a 3 :b 7 :c 2})) ; => [:b 7]
;; Note: max-key/min-key are variadic (take individual items) - use apply to spread a collection
;; max-by/min-by take a collection directly - no apply needed: (max-by :key coll)Predicates on Collections
| Function | Signature | Description |
|---|---|---|
empty? | (empty? coll) | True if empty or nil |
not-empty | (not-empty coll) | coll if not empty, else nil |
some | (some pred coll) | First truthy result of pred, or nil |
some | (some :key coll) | First truthy :key value, or nil |
every? | (every? pred coll) | True if all match |
every? | (every? :key coll) | True if all have truthy :key |
not-any? | (not-any? pred coll) | True if none match |
not-any? | (not-any? :key coll) | True if none have truthy :key |
not-every? | (not-every? pred coll) | True if not all match (complement of every?) |
not-every? | (not-every? :key coll) | True if not all have truthy :key |
distinct? | (distinct? x y ...) | True if all arguments are distinct |
contains? | (contains? coll key) | True if key/element exists (maps, sets, vectors) |
(empty? []) ; => true
(empty? nil) ; => true
(not-empty [1 2]) ; => [1 2]
(not-empty []) ; => nil
(not-empty nil) ; => nil
(some :admin users) ; any admins? (keyword shorthand)
(every? :active users) ; all active? (keyword shorthand)
(not-any? :error items) ; no errors?
(contains? {:a 1} :a) ; => true
(contains? {:a 1} :b) ; => false
(contains? ["a" "b" "c"] "b") ; => true (PTC vector membership)
(contains? ["a" "b" "c"] "x") ; => falseSequence Generation
| Function | Signature | Description |
|---|---|---|
range | (range end) | Returns sequence from 0 to end (exclusive) |
range | (range start end) | Returns sequence from start to end (exclusive) |
range | (range start end step) | Returns sequence with specific step |
(range 5) ; => [0 1 2 3 4]
(range 5 10) ; => [5 6 7 8 9]
(range 0 10 2) ; => [0 2 4 6 8]
(range 10 0 -2) ; => [10 8 6 4 2]
(range 5 5) ; => []
(take 3 (range 1 5 0)) ; => [1 1 1]Note: Unlike Clojure, range in PTC-Lisp is always finite and requires at least one argument. The zero-arity (range) which produces an infinite sequence is not supported because PTC-Lisp does not support lazy sequences. A direct zero-step (range start end 0) also raises unless it is consumed by bounded take.
8.2 Map Operations
| Function | Signature | Description |
|---|---|---|
get | (get m key) | Get value by key |
get | (get m key default) | Get with default |
get-in | (get-in m path) | Get nested value |
get-in | (get-in m path default) | Get nested with default |
assoc | (assoc m key val) | Add/update key |
assoc-in | (assoc-in m path val) | Add/update nested |
update | (update m key f) | Update value with function |
update | (update m key f & args) | Update with extra args passed to f |
update-in | (update-in m path f) | Update nested with function |
update-in | (update-in m path f & args) | Update nested with extra args |
dissoc | (dissoc m key ...) | Remove one or more keys |
merge | (merge m1 m2 ...) | Merge maps (later wins) |
hash-map | (hash-map & kvs) | Build a map from alternating key/value args (equivalent to a map literal) |
array-map | (array-map & kvs) | Alias for hash-map |
select-keys | (select-keys m keys) | Pick specific keys |
keys | (keys m) | Get all keys |
vals | (vals m) | Get all values |
entries | (entries m) | Get all [key value] pairs as a vector |
update-vals | (update-vals m f) | Apply f to each value (matches Clojure 1.11) |
update-keys | (update-keys m f) | Apply f to each key (collision: retained value unspecified) |
merge-with | (merge-with f m1 m2 ...) | Merge maps with combining function for duplicates |
reduce-kv | (reduce-kv f init m) | Reduce map with f receiving (acc, key, val) |
(get {:a 1} :a) ; => 1
(get {:a 1} :b "default") ; => "default"
(get-in {:user {:name "A"}} [:user :name]) ; => "A"
(assoc {:a 1} :b 2) ; => {:a 1 :b 2}
(assoc-in {} [:user :name] "Bob") ; => {:user {:name "Bob"}}
(update {:n 1} :n inc) ; => {:n 2}
(update {:n 1} :n + 5) ; => {:n 6} - extra args passed to f
(update {:n nil} :n (fnil inc 0)) ; => {:n 1} - fnil with 1-arity fn
(update {:n nil} :n (fnil + 0) 5) ; => {:n 5} - fnil with 2-arity fn + extra arg
(update-in {:a {:b 1}} [:a :b] + 10) ; => {:a {:b 11}}
(dissoc {:a 1 :b 2} :b) ; => {:a 1}
(dissoc {:a 1 :b 2 :c 3} :a :b) ; => {:c 3}
(hash-map :a 1 :b 2) ; => {:a 1 :b 2}
(merge {:a 1} {:b 2} {:a 3}) ; => {:a 3 :b 2}
(select-keys {:a 1 :b 2 :c 3} [:a :c]) ; => {:a 1 :c 3}
(keys {:a 1 :b 2}) ; => [:a :b]
(vals {:a 1 :b 2}) ; => [1 2]
(entries {:a 1 :b 2}) ; => [[:a 1] [:b 2]]
;; update-vals: apply function to each value (matches Clojure 1.11)
(update-vals {:a 1 :b 2} inc) ; => {:a 2 :b 3}
;; Common pattern: count items per group after group-by
;; Note: Use -> (not ->>) since map is first argument
(-> orders
(group-by :status)
(update-vals count)) ; => ...
;; update-keys: apply function to each key
(update-keys {:a 1 :b 2} str) ; => {":a" 1 ":b" 2}
;; merge-with: merge maps with combining function for duplicate keys
(merge-with + {:a 1 :b 2} {:a 3 :c 4}) ; => {:a 4 :b 2 :c 4}
(merge-with + {:a 1} {:a 2} {:a 3}) ; => {:a 6}
;; reduce-kv: reduce over map key-value pairs
(reduce-kv (fn [acc k v] (+ acc v)) 0 {:a 1 :b 2 :c 3}) ; => 6
(reduce-kv (fn [acc k v] (assoc acc k (* v 2))) {} {:a 1 :b 2}) ; => {:a 2 :b 4}Vector Index Support:
get-in, assoc, assoc-in, and update-in support numeric vector indices:
(get-in {:results [{:title "A"}]} [:results 0 :title]) ; => "A"
(get-in [1 2 3] [0]) ; => 1
(get-in [[1 2] [3 4]] [1 0]) ; => 3
(get-in [1 2 3] [10]) ; => nil (out of bounds)
(get-in [1 2 3] [-1]) ; => nil (negative not supported)
(assoc [1 2 3] 1 5) ; => [1 5 3]
(assoc-in [1 2 3] [1] 99) ; => [1 99 3]
(assoc-in [[1 2] [3 4]] [0 1] 99) ; => [[1 99] [3 4]]
(update-in [1 2 3] [1] inc) ; => [1 3 3]Note: assoc, assoc-in, and update-in fail with a :runtime_error for
out-of-bounds vector indices.
8.3 String Functions
| Function | Signature | Description |
|---|---|---|
str | (str ...) | Convert and concatenate to string |
pr-str | (pr-str ...) | Readable string representation (strings quoted, nil as "nil", space-separated) |
subs | (subs s start) | Substring from index to end |
subs | (subs s start end) | Substring from start to end |
split | (split s re-or-char) | Split string on a regex; single-char string ok, multi-char string signals :type_error |
split-lines | (split-lines s) | Split string into lines (\n or \r\n) |
join | (join separator coll) | Join collection elements with separator |
join | (join coll) | Join collection elements (no separator) |
trim | (trim s) | Remove leading/trailing whitespace |
triml | (triml s) | Remove leading whitespace |
trimr | (trimr s) | Remove trailing whitespace |
trim-newline | (trim-newline s) | Remove trailing newline/carriage-return characters |
blank? | (blank? s) | True if s is nil, empty, or only whitespace |
replace | (replace s pattern replacement) | Replace all occurrences |
upcase / upper-case | (upcase s) | Convert to uppercase |
downcase / lower-case | (downcase s) | Convert to lowercase |
starts-with? | (starts-with? s prefix) | Check if string starts with prefix |
ends-with? | (ends-with? s suffix) | Check if string ends with suffix |
includes? | (includes? s substring) | Check if string contains substring |
index-of | (index-of s value) | Index of first occurrence, or nil if not found |
index-of | (index-of s value from-index) | Index of first occurrence from position |
last-index-of | (last-index-of s value) | Index of last occurrence, or nil if not found |
last-index-of | (last-index-of s value from-index) | Index of last occurrence up to position |
format | (format fmt-string & args) | Java-style %s/%d/%f/%e/%x/%o formatting with bounded width, - left alignment, 0 numeric padding, and numeric precision |
name | (name x) | Returns name string of keyword or string |
Type coercion: str converts values to strings using these rules:
nil→""true/false→"true"/"false"- Numbers → decimal representation (e.g.,
42→"42",3.14→"3.14") - Strings → unchanged
- Keywords →
:keyword(with leading colon) - Collections → string representation
(str "hello") ; => "hello"
(str "Hello" " " "World") ; => "Hello World"
(subs "hello" 1) ; => "ello"
(subs "hello" 1 4) ; => "ell"PTC-Lisp specific string examples:
(str)→""(empty call)(str 42)→"42"(number conversion)(str true)→"true"(boolean conversion)(str :user)→":user"(keyword with colon)(str nil "x")→"x"(nil coerced to empty string)(str {:a 1})→"{:a 1}"(collections use Clojure syntax)(pr-str "hello")→"\"hello\""(string gets quoted)(pr-str nil)→"nil"(nil as readable literal)(pr-str 1 "a")→"1 \"a\""(space-separated, strings quoted)(split "a,b,c" ",")→["a" "b" "c"](single-char string delimiter)(split "hello" "")→["h" "e" "l" "l" "o"](split into characters)(split "a,,b" ",")→["a" "" "b"](preserves empty elements)(split "a--b--c" #"--")→["a" "b" "c"](multi-char delimiters need a regex)(split-lines "a\nb\r\nc")→["a" "b" "c"](split by line endings)(split-lines "a\n\n\n")→["a"](discards trailing empty lines)(join ", " ["a" "b" "c"])→"a, b, c"(join with separator)(join "-" [1 2 3])→"1-2-3"(numeric types converted)(trim "\n\tworld\r\n")→"world"(remove all whitespace)(replace "hello" "l" "L")→"heLLo"(replace all occurrences)(replace "aaa" "a" "b")→"bbb"(replace pattern)(upcase "hello")→"HELLO"(uppercase conversion)(upper-case "world")→"WORLD"(alias for upcase)(downcase "HELLO")→"hello"(lowercase conversion)(lower-case "WORLD")→"world"(alias for downcase)(starts-with? "hello" "he")→true(prefix check)(starts-with? "hello" "lo")→false(does not start with)(starts-with? "hello" "")→true(empty prefix always matches)(ends-with? "hello" "lo")→true(suffix check)(ends-with? "hello" "he")→false(does not end with)(ends-with? "hello" "")→true(empty suffix always matches)(includes? "hello" "ll")→true(substring check)(includes? "hello" "x")→false(does not contain)(includes? "hello" "")→true(empty substring always matches)(index-of "hello" "l")→2(first occurrence)(index-of "hello" "x")→nil(not found returns nil)(index-of "hello" "l" 3)→3(search from position)(index-of "hello" "")→0(empty value returns 0)(last-index-of "hello" "l")→3(last occurrence)(last-index-of "hello" "x")→nil(not found returns nil)(last-index-of "hello" "l" 2)→2(search up to position)(last-index-of "hello" "")→5(empty value returns string length)(last-index-of "aaa" "aa")→1(overlapping matches handled correctly)(format "%s has %d items" "cart" 3)→"cart has 3 items"(string and integer formatting)(format "%.2f" 3.14159)→"3.14"(float with precision)(format "%d in hex is %x" 255 255)→"255 in hex is ff"(decimal and hex)(format "%e" 1234.5)→"1.234500e+03"(scientific notation)(format "%o" 8)→"10"(octal)(format "100%%")→"100%"(literal percent)(format "%s" nil)→""(nil coerced via str — Clojure returns"null", see divergence)(format "%s and %s" "a" "b" "c")→"a and b"(extra args ignored)(format "%f" ##Inf)→"Infinity"(special values work with %f)(format "%d" ##Inf)→ ERROR (special values error with %d)(name :foo)→"foo"(keyword name without colon)(name "bar")→"bar"(string passes through)(name nil)→ ERROR (nil not supported)(name 42)→ ERROR (numbers not supported)
format supports these format specifiers: %s (string via str), %d (integer), %f/%.Nf (float with optional precision, default 6 decimal places), %e (scientific notation), %x (hex), %o (octal), %% (literal %). Extra arguments are ignored. Special values (##Inf, ##-Inf, ##NaN) work with %s and %f/%e but raise an error with %d. Divergence: (format "%s" nil) returns "" (Clojure returns "null").
name returns the name string of a keyword (without the leading colon) or passes strings through unchanged. Raises an error on nil, numbers, booleans, and special values.
Note: All string indices are grapheme-based (not byte offsets or UTF-16 code units), consistent with subs, count, and other PTC-Lisp string functions.
8.4 Arithmetic
| Function | Signature | Description |
|---|---|---|
+ | (+ x y ...) | Addition |
- | (- x y ...) | Subtraction |
* | (* x y ...) | Multiplication |
/ | (/ x y) | Division (always returns float) |
quot | (quot x y) | Integer division (truncated toward zero) |
mod | (mod x y) | Modulo (floored division, result sign matches divisor) |
rem | (rem x y) | Remainder (truncated division, result sign matches dividend) |
inc | (inc x) | Add 1 |
dec | (dec x) | Subtract 1 |
abs | (abs x) | Absolute value |
sqrt | (sqrt x) | Square root (returns float; ##NaN for negatives) |
pow | (pow x y) | Exponentiation (returns float) |
trunc | (trunc x) | Truncate toward zero |
compare | (compare x y) | Clojure-like natural comparison: nil first, natural order within compatible types, and an error for incompatible values. |
max | (max x y ...) | Maximum number; a unary call returns its argument, while reached non-numeric comparisons fail. |
min | (min x y ...) | Minimum number; a unary call returns its argument, while reached non-numeric comparisons fail. |
floor | (floor x) | Round toward -∞ |
ceil | (ceil x) | Round toward +∞ |
round | (round x) | Round to nearest integer |
float | (float x) | Alias for double (Clojure compat) |
double | (double x) | Type coercion (to float) |
int | (int x) | Type coercion (to integer) |
keyword | (keyword x) | Type coercion (string to keyword) |
Special Value Behavior:
- NaN Propagation: Any arithmetic operation involving
Double/NaNreturnsDouble/NaN. - Division by Zero: An integer zero divisor —
(/ n 0)— raises anarithmetic-error(Clojure conformance). A float zero divisor follows IEEE 754:(/ n 0.0)returnsDouble/POSITIVE_INFINITY(ifn > 0),Double/NEGATIVE_INFINITY(ifn < 0), orDouble/NaN(ifn = 0). - Indeterminate Forms: Operations like
(- Double/POSITIVE_INFINITY Double/POSITIVE_INFINITY)or(* Double/POSITIVE_INFINITY 0)returnDouble/NaN. - Coercion: Converting
Infinitytointraises anarithmetic-error;(int ##NaN)returns0, matching JVM int coercion.
(+ 1 2 3) ; => 6
(- 10 3) ; => 7
(* 2 3 4) ; => 24
(/ 10 2) ; => 5.0
(/ 10 3) ; => 3.333...
(quot 10 3) ; => 3
(quot -7 2) ; => -3 (truncates toward zero)
(mod 10 3) ; => 1
(mod -10 3) ; => 2 (sign matches divisor)
(rem 10 3) ; => 1
(rem -10 3) ; => -1 (sign matches dividend)
(inc 5) ; => 6
(dec 5) ; => 4
(abs -5) ; => 5
(max 1 5 3) ; => 5
(min 1 5 3) ; => 1
(floor 3.7) ; => 3
(ceil 3.2) ; => 4
(round 3.5) ; => 4
(double 5) ; => 5.0
(int 3.7) ; => 3
(int ##NaN) ; => 0
(keyword "foo") ; => :foo
(keyword :bar) ; => :bar
(keyword nil) ; => nil
(keyword "a/b") ; => ERROR (no `/` per DIV-13)
(keyword "") ; => ERROR (empty string invalid)
(keyword "+") ; => ERROR (operator chars not allowed)
(keyword ##Inf) ; => ERROR (special values rejected)
(/ 1.0 0.0) ; => ##Inf
(/ 0.0 0.0) ; => ##NaN
(sqrt -1) ; => ##NaN
(+ Double/POSITIVE_INFINITY 1) ; => ##Inf
(* Double/NaN 10) ; => ##NaN
(int Double/POSITIVE_INFINITY) ; => ARITHMETIC ERRORDivision behavior: The / operator always returns a float, even for exact
divisions. For integer division, use quot, which truncates toward zero—useful
for index calculations like (take (quot n 2) coll). An integer zero divisor
raises :arithmetic_error; a floating zero divisor returns Infinity,
-Infinity, or NaN according to IEEE 754. Converting Infinity to int
raises :arithmetic_error; (int ##NaN) returns 0.
keyword coercion: Coerces a string to a keyword, passes keywords through unchanged, and returns nil for nil. Validates that the name starts with a letter and contains only letters, digits, -, _, ?, !—no / (per DIV-13), no spaces, no empty strings, and no operator characters (+, *, <, >, =). Special numeric values (##Inf, ##-Inf, ##NaN) are rejected. Coercion never grows the BEAM atom table: names in the bounded vocabulary become atoms, every other name becomes a runtime keyword struct.
8.4.1 Bitwise Operations
Integer-only bit manipulation, mirroring clojure.core. All arguments must be integers; non-integer arguments raise a type-error. For the single-bit ops (bit-set, bit-clear, bit-flip, bit-test), n is the zero-based bit index and must be a non-negative integer.
| Function | Signature | Description |
|---|---|---|
bit-and | (bit-and x & more) | Bitwise AND of integers |
bit-or | (bit-or x & more) | Bitwise OR of integers |
bit-xor | (bit-xor x & more) | Bitwise exclusive OR of integers |
bit-and-not | (bit-and-not x & more) | Bitwise AND of x with the complement of each subsequent argument |
bit-not | (bit-not x) | Bitwise complement (two's complement) of an integer |
bit-shift-left | (bit-shift-left x n) | Shift x left by n bits |
bit-shift-right | (bit-shift-right x n) | Arithmetic (sign-extending) shift x right by n bits |
bit-set | (bit-set x n) | Set bit n of x to 1 |
bit-clear | (bit-clear x n) | Clear bit n of x (set it to 0) |
bit-flip | (bit-flip x n) | Flip bit n of x |
bit-test | (bit-test x n) | Return true if bit n of x is set, else false |
(bit-and 12 10) ; => 8
(bit-or 12 10) ; => 14
(bit-xor 12 10) ; => 6
(bit-and-not 15 9) ; => 6
(bit-not 0) ; => -1
(bit-shift-left 1 4) ; => 16
(bit-shift-right 256 4) ; => 16
(bit-set 0 3) ; => 8
(bit-clear 15 1) ; => 13
(bit-flip 0 2) ; => 4
(bit-test 5 0) ; => true
(bit-test 5 1) ; => falseBEAM divergence: Erlang/BEAM integers are arbitrary-precision, so the shift amount is not taken modulo 64 and
bit-shift-leftresults can grow without bound (unlike the JVM's fixed 64-bit longs).unsigned-bit-shift-rightis intentionally not provided because it has no defined meaning without a fixed integer width.
8.5 Comparison
| Function | Signature | Description |
|---|---|---|
= | (= x), (= x y & more) | Equality |
== | (== x), (== x y & more) | Numeric equality (alias for =; unlike Clojure, accepts any type rather than throwing on non-numbers — DIV-10) |
not= | (not= x), (not= x y & more) | Inequality |
< | (< x), (< x y & more) | Less than |
> | (> x), (> x y & more) | Greater than |
<= | (<= x), (<= x y & more) | Less or equal |
>= | (>= x), (>= x y & more) | Greater or equal |
Note: Comparison and equality operators are variadic but require at least one argument. Ordered comparisons compare adjacent pairs, so (< 1 2 3) is equivalent to (and (< 1 2) (< 2 3)).
(= 1 1) ; => true
(= 1 1 1) ; => true
(= 1 2) ; => false
(not= 1 2) ; => true
(< 1 2) ; => true
(< 1 2 3) ; => true
(> 3 2) ; => true
(<= 1 1) ; => true
(>= 3 2) ; => true
;; Special Value Comparisons (IEEE 754)
(< 1.0 Double/POSITIVE_INFINITY) ; => true
(> -1.0 Double/NEGATIVE_INFINITY) ; => true
(= Double/NaN Double/NaN) ; => false
(< Double/NaN 0.0) ; => false
(>= Double/NaN 0.0) ; => false8.6 Logic
| Function | Signature | Description |
|---|---|---|
and | (and x y ...) | Logical AND (short-circuits) |
or | (or x y ...) | Logical OR (short-circuits) |
not | (not x) | Logical NOT |
identity | (identity x) | Returns argument unchanged |
(and true true) ; => true
(and true false) ; => false
(and nil "x") ; => nil (short-circuits)
(or false true) ; => true
(or nil false "x") ; => "x" (returns first truthy)
(not true) ; => false
(not nil) ; => true
(identity 42) ; => 42identity function: Returns its argument unchanged. Useful as a default function argument, for passing to higher-order functions, or in pipelines where no transformation is needed.
8.7 Type Predicates
| Function | Description |
|---|---|
nil? | Is nil? |
some? | Is not nil? |
boolean? | Is boolean? |
number? | Is number? |
int? | Is integer? |
integer? | Is integer? (alias for int?) |
float? | Is float? |
double? | Is float? (alias for float?) |
string? | Is string? |
char? | Is single-character string? (See §3.5) |
keyword? | Is keyword? |
vector? | Is vector? |
map? | Is map? |
set? | Is set? |
fn? | Is function? |
false? | Is exactly false? |
true? | Is exactly true? |
symbol? | Always false, including for the limited inert references produced by quote (see §3.11 and DIV-19) |
decimal? | Always false — BEAM has no BigDecimal (see DIV-20) |
ratio? | Always false — BEAM has no ratio type (see DIV-20) |
rational? | Is integer? — integers are the only BEAM rationals (see DIV-20) |
nat-int? | Is non-negative integer? (>= 0) |
neg-int? | Is negative integer? |
pos-int? | Is positive integer? (> 0) |
infinite? | Is positive or negative infinity? |
NaN? | Is NaN? |
coll? | Is collection? (vectors, maps, or sets) |
sequential? | Is ordered collection? (vectors only) |
seq? | Is sequence? (vectors only; same as sequential? in PTC-Lisp) |
associative? | Supports assoc? (vectors and maps) |
counted? | Has O(1) count? (vectors, maps, sets, strings) |
indexed? | Supports nth? (vectors and strings) |
reversible? | Supports reverse? (vectors and strings) |
sorted? | Always false — no sorted collections in PTC-Lisp |
seqable? | Can produce a seq? (collections, strings, nil) |
ifn? | Is directly invokable? (functions, keywords, maps, and sets; not vectors). Higher-order argument validation also accepts sets, but currently rejects maps; wrap a map lookup in a closure. |
map-entry? | Always false — no MapEntry type on BEAM |
type | Returns the type as a keyword: :boolean, :number, :string, :vector, :map, :set, :keyword, :regex, :function, :java_object for a validated native Java wrapper, or :unknown for an unclassified value such as an inert quoted-symbol reference. For nil, returns nil (not :nil). |
describe | Returns a bounded map summary for data shape, type histograms, key coverage, structurally bounded examples, and optional nested paths. Forms: (describe x), (describe x opts). Options: {:paths true :depth 2 :sample 3}. |
;; coll? returns true for vectors, maps, and sets
(coll? [1 2 3]) ; => true
(coll? {:a 1}) ; => true
(coll? #{1 2}) ; => true
(coll? "hello") ; => false
(coll? 42) ; => false
;; sequential? returns true only for ordered collections (vectors)
(sequential? [1 2 3]) ; => true
(sequential? {:a 1}) ; => false
(sequential? #{1 2}) ; => false
;; seq? is effectively the same as sequential? (no lazy sequences in PTC-Lisp)
(seq? [1 2 3]) ; => true
;; Useful with tree-seq for walking nested vectors
(tree-seq sequential? seq [[1 2] [3 [4 5]]])Note: coll? returns false for strings, although capability predicates such
as seqable?, counted?, indexed?, and reversible? return true. flatten
does not recurse into strings.
Collection Functions on Maps and Strings:
Maps satisfy coll?, while strings do not. Both nevertheless work with many
sequence-oriented functions:
| Function | Maps | Strings | Notes |
|---|---|---|---|
count | ✓ | ✓ | Returns key count / character count |
empty? | ✓ | ✓ | True if no keys / no characters (or nil) |
not-empty | ✓ | ✓ | Returns map/string if not empty, else nil |
first | ✗ | ✓ | Maps: use (first (keys m)). Strings: returns first character |
second | ✗ | ✓ | Maps: use (second (keys m)). Strings: returns second character |
last | ✗ | ✓ | Maps: use (last (keys m)). Strings: returns last character |
nth | ✗ | ✓ | Maps: not supported. Strings: returns character at index |
rest | ✗ | ✓ | Strings: returns a vector of remaining characters |
butlast | ✗ | ✓ | Strings: returns a vector of all but the last character |
next | ✗ | ✓ | Strings: returns a vector of remaining characters or nil |
take | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of first n characters |
drop | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of characters after dropping n |
take-last | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of last n characters |
drop-last | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of characters after dropping last n |
take-while | ✓ | ✓ | Maps: iterates [key value] pairs. Strings: returns a vector of characters while predicate is true |
drop-while | ✓ | ✓ | Maps: iterates [key value] pairs. Strings: returns a vector of characters after predicate becomes false |
map | ✓ | ✓ | Maps: iterates over [key value] pairs. Strings: iterates over characters |
mapv | ✓ | ✓ | Same as map, returns vector |
filter | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of characters |
remove | ✓ | ✓ | Maps: returns a vector of [key value] pairs. Strings: returns a vector of characters |
find | ✓ | ✗ | Maps: associative entry lookup. Strings: not associative and signal :type_error |
sort | ✓ | ✓ | Maps: sorts [key value] entries. Strings: returns a sorted vector of characters |
sort-by | ✓ | ✓ | Maps: returns a sorted vector of [key value] pairs. Strings: a sorted vector of characters |
reverse | ✗ | ✓ | Strings: returns a reversed vector of characters |
distinct | ✗ | ✓ | Maps: use (distinct (entries m)). Strings: returns a vector of unique characters |
some | ✓ | ✓ | Maps pass [key value] entries; strings pass characters |
every? | ✓ | ✓ | Maps pass [key value] entries; strings pass characters |
not-any? | ✓ | ✓ | Maps pass [key value] entries; strings pass characters |
not-every? | ✓ | ✓ | Maps pass [key value] entries; strings pass characters |
reduce | ✓ | ✓ | Maps: iterates over [key value] pairs. Strings: iterates over characters |
entries | ✓ | ✗ | Explicit conversion to a vector of [key value] pairs |
Note: String operations that return character sequences return vectors of
single-character strings, not a string. Use (join "" result) to convert back
to a string if needed.
Mapping over maps: When you call map on a map, each entry is passed as a [key value] vector. Use destructuring to extract the key and value:
;; Transform grouped data
(let [by-category (group-by :category expenses)]
(map (fn [[cat items]]
{:category cat :total (sum-by :amount items)})
by-category))To iterate over just keys or values, extract them first:
(->> (keys my-map)
(map (fn [k] {:key k :val (get my-map k)})))8.8 Numeric Predicates
| Function | Description |
|---|---|
zero? | Is zero? |
pos? | Is positive? |
neg? | Is negative? |
even? | Is even? |
odd? | Is odd? |
Note on Special Values:
number?returnstrueforInfinityandNaN.pos?returnstrueforDouble/POSITIVE_INFINITY.neg?returnstrueforDouble/NEGATIVE_INFINITY.- All predicates (including
zero?) returnfalseforDouble/NaN. Double/NaNis not equal to itself:(= Double/NaN Double/NaN)isfalse.
Integer predicates on floats: even? and odd? accept whole-number floats like 4.0 (treating them as integers), and return false for non-whole floats like 4.5. This diverges from Clojure, which throws on float arguments (see GAP-S08).
(even? 4) ; => true
(even? 4.0) ; => true (whole-number float)
(even? 4.5) ; => false (non-whole float)8.9 String Parsing
| Function | Description |
|---|---|
parse-long | Parse string to integer, returns nil on failure |
parse-int | Alias for parse-long |
parse-double | Parse string to double, returns nil on failure |
parse-boolean | Parse "true"/"false", returns nil on failure |
String parsing functions provide safe conversion from strings to numbers, compatible with Clojure 1.11+. These functions return nil on parse failure rather than throwing exceptions.
Java-named numeric parsers use closed manifest dispatch and Java semantics instead: Integer/parseInt and Long/parseLong accept decimal strings within their exact primitive ranges, while Double/parseDouble and Float/parseFloat accept Java decimal, hexadecimal, suffix, special-value, and surrounding Java-whitespace syntax and round directly to the declared IEEE 754 kind. Invalid Java-named parses produce bounded NumberFormatException or NullPointerException conditions. Math/ accepts only admitted Java members; non-Java aliases such as Math/bit-and and Math/trunc are rejected, while the ordinary PTC functions remain available as bit-and and trunc. Boolean/parseBoolean is also resolved by the bounded Java manifest and closed dispatch: it returns true only for case-insensitive "true", returns false for nil/null and every other string, and raises a bounded Java type error for non-string, non-nil inputs. In value position each Java parser is a native Java callable; public results render that authority as an inert #java[...] label.
First-class Java callables always recover their invocation kind from the bounded
manifest. Static and constructor callables consume their ordinary arguments;
instance callables consume the receiver as the first application argument.
Manifest-admitted direct-dot spellings are host-owned Java syntax, not ordinary
user names. They cannot be introduced by local bindings, function parameters,
or def/defonce/defn; attempting to bind one is an invalid form. This keeps
call and value positions on the same closed-dispatch path.
Tagged Java numeric values retain their overload identity through ordinary data
operations, but PTC arithmetic and numeric index/count positions unwrap the
payload and end that provenance, including when the builtin is called through a
higher-order function. Numeric positions depend on the selected builtin arity:
collection arguments in shorter drop-last, partition, and partition-all
forms remain data rather than being unwrapped as numeric steps. Comparator
callbacks use the same callable path and erase primitive provenance from numeric
comparison results. Constructor heads such as java.util.Date. and direct-dot
families such as .contains are manifest-resolved source identities; they move
to Java CoreAST only once their complete reference family uses closed dispatch.
Parsing behavior:
- The unqualified parsers require the entire string to be consumed. Partial parses are rejected.
- Leading/trailing whitespace is not stripped—the string must be in exact numeric form.
- Invalid input returns
nilrather than an error.
These three rules apply to the unqualified Clojure-named helpers. Java-named numeric parsers follow the class-specific behavior described above.
;; Successful parses
(parse-long "42") ; => 42
(parse-long "-17") ; => -17
(parse-double "3.14") ; => 3.14
(parse-double "-0.5") ; => -0.5
(parse-double "1.23e-4") ; => 1.23e-4
(Double/parseDouble "3.14") ; => 3.14
(Integer/parseInt "42") ; => 42
(Boolean/parseBoolean "true") ; => true
(Boolean/parseBoolean "TRUE") ; => true
(Boolean/parseBoolean "x") ; => false;; Failed parses
(parse-long "abc") ; => nil
(parse-double "invalid") ; => nil
(parse-long "42abc") ; => nil (partial parse rejected)
(parse-double "3.14 ") ; => nil (trailing whitespace not allowed)Type checking: The unqualified parsing functions return nil for
non-string input (see
DIV-18).
(parse-long 42) ; => nil
(parse-long nil) ; => nil
(parse-double nil) ; => nil
(parse-double 3.14) ; => nilTypical usage filters recoverable failures:
(->> ["1" "2" "not-a-number" "4"]
(map parse-long)
(filter some?)
(reduce + 0)) ; => 78.10 Regex Functions
Regex functions provide validation and extraction capabilities. To ensure system stability, PTC-Lisp uses a "Safety-First" regex engine with forced backtracking and recursion limits.
| Function | Signature | Description |
|---|---|---|
re-pattern | (re-pattern s) | Compile string s into an opaque regex object |
re-find | (re-find re s) | Returns the first match of re in s |
re-matches | (re-matches re s) | Returns match if re matches the entire string s |
re-seq | (re-seq re s) | Returns all matches of re in s as a vector |
re-split | (re-split re s) | Split string s by regex pattern re |
regex? | (regex? x) | Returns true if x is a regex object |
extract | (extract pattern s) | Extract capture group 1 from match |
extract | (extract pattern s n) | Extract capture group n (0 = full match) |
extract-int | (extract-int pattern s) | Extract group 1 and parse as integer |
extract-int | (extract-int pattern s n) | Extract group n and parse as integer |
extract-int | (extract-int pattern s n default) | Extract group n, parse as int, return default on failure |
Opaque Regex Type: Regexes are created with re-pattern or the standard
reader shorthand #"...". Internally, they are opaque values that can be
passed to regex functions but are not otherwise inspected.
Return Value Semantics:
- If no match is found,
re-findandre-matchesreturnnil;re-seqreturns an empty vector. - If the regex has no capture groups, returns the matching string (or vector of strings for
re-seq). - If the regex contains capture groups, returns a vector where the first element is the full match and subsequent elements are the groups.
(re-find (re-pattern "\\d+") "v1") ; => "1"
(re-matches (re-pattern "\\d+") "123") ; => "123"
(re-matches (re-pattern "\\d+") "123abc") ; => nil (not entire string)
(re-find (re-pattern "(\\d+)-(\\d+)") "10-20") ; => ["10-20" "10" "20"]
(re-seq (re-pattern "\\d+") "a1b22c333") ; => ["1" "22" "333"]
(re-seq (re-pattern "(\\d)(\\w)") "1a2b") ; => [["1a" "1" "a"] ["2b" "2" "b"]]
(re-split (re-pattern "\\s+") "a b c") ; => ["a" "b" "c"]
(re-split (re-pattern ",") "a,b,c") ; => ["a" "b" "c"]
;; extract - simplified capture group extraction
(extract "ID:(\\d+)" "ID:42") ; => "42" (group 1)
(extract "ID:(\\d+)" "ID:42" 0) ; => "ID:42" (full match)
(extract "x=(\\d+) y=(\\d+)" "x=10 y=20" 2) ; => "20" (group 2)
(extract "ID:(\\d+)" "no match") ; => nil
;; extract-int - extract and parse as integer
(extract-int "age=(\\d+)" "age=25") ; => 25
(extract-int "age=(\\d+)" "no match") ; => nil (2-arity)
(extract-int "x=(\\d+) y=(\\d+)" "x=10 y=20" 2) ; => 20 (group 2)
(extract-int "age=(\\d+)" "no match" 1 0) ; => 0 (4-arity with default)
(extract-int "x=(\\d+) y=(\\d+)" "x=10 y=20" 2 0) ; => 20 (group 2 with default)Note: #"..." is shorthand for (re-pattern "..."). Both forms produce compiled regex values. split follows Clojure: the delimiter is a regex. A single-character string delimiter is accepted (chars are one-character strings), but a multi-character string delimiter signals a :type_error — use a regex literal for those, e.g. (split s #"---\n"). For splitting on newlines, prefer (split-lines s).
Safety Constraints:
- Match Limit: Regex execution is restricted to 100,000 backtracking steps. Exceeding this limit (e.g., due to ReDoS) terminates evaluation with an error.
- Input Truncation: To prevent super-linear scaling on massive inputs, regex functions only scan the first 32KB of any input string.
- Pattern Complexity: Patterns are limited to 256 bytes in length.
8.11 Function Combinators
| Function | Signature | Description |
|---|---|---|
juxt | (juxt f1 f2 ...) | Returns a function that applies all functions and returns a vector of results |
comp | (comp f1 f2 ...) | Returns a function composing fns right-to-left; (comp) returns identity |
partial | (partial f arg1 ...) | Returns a function with some arguments pre-filled |
complement | (complement f) | Returns a function with the opposite truth value (always boolean) |
constantly | (constantly x) | Returns a function that always returns x, ignoring its arguments |
every-pred | (every-pred p1 p2 ...) | Returns a predicate true when all preds are satisfied (always boolean) |
some-fn | (some-fn f1 f2 ...) | Returns a function that returns the first truthy result from any fn |
The juxt combinator creates a function that applies each of its argument functions to the same input and returns a vector containing all results. This is particularly useful for multi-criteria sorting and extracting multiple values at once. juxt requires at least one function — a zero-argument (juxt) is an arity error (GAP-S110), matching Clojure.
;; Basic usage: extract multiple values from a map
((juxt :name :age) {:name "Alice" :age 30})
; => ["Alice" 30]
;; Multi-criteria sorting (primary: priority, secondary: name)
(sort-by (juxt :priority :name) tasks)
; Sorts first by priority, then by name for equal priorities
;; Extracting coordinates from point maps
(map (juxt :x :y) points)
; => [[1 2] [3 4] ...]
;; Using closures for computed values
((juxt #(+ % 1) #(* % 2)) 5)
; => [6 10]
;; Using builtin functions
((juxt first last) [1 2 3])
; => [1 3]Comparison with explicit function:
;; These are equivalent:
(sort-by (juxt :priority :name) tasks)
(sort-by (fn [t] [(:priority t) (:name t)]) tasks)
;; juxt is more concise for multiple key extraction
(map (juxt :id :name :email) users)
(map (fn [u] [(:id u) (:name u) (:email u)]) users)Supported function types:
- Keywords (used as map accessors)
- Closures (
fnand#()syntax) - Builtin functions (
first,last,count, etc.)
comp — function composition (right-to-left):
((comp str inc) 5) ; => "6" (inc first, then str)
((comp str +) 1 2 3) ; => "6" (rightmost fn gets all args)
((comp inc inc inc) 0) ; => 3
((comp) 42) ; => 42 (identity)
(map (comp inc inc) [1 2 3]) ; => [3 4 5]partial — partially apply arguments:
((partial + 10) 5) ; => 15
((partial + 1 2)) ; => 3 (no extra args needed)
(map (partial + 10) [1 2 3]) ; => [11 12 13]
((partial str "a" "b") "c" "d") ; => "abcd"complement — negate a predicate (returns boolean):
((complement even?) 3) ; => true
((complement even?) 4) ; => false
(filter (complement even?) [1 2 3 4]) ; => [1 3]constantly — ignore arguments, always return the same value:
((constantly 5) 1 2 3) ; => 5
((constantly nil) :a :b) ; => nilevery-pred — combine predicates with AND (returns boolean):
((every-pred even? pos?) 4) ; => true
((every-pred even? pos?) -4) ; => false (pos? fails)
((every-pred even? pos?) 4 6 8) ; => true (all values pass all preds)
(filter (every-pred even? pos?) [-2 -1 0 1 2 3 4]) ; => [2 4]some-fn — combine functions with OR (returns actual truthy value):
((some-fn :a :b) {:a 1}) ; => 1 (returns the value, not true)
((some-fn :a :b) {:b 2}) ; => 2
((some-fn :a :b) {:c 3}) ; => nil (no match)
((some-fn even? pos?) 3) ; => true (pos? matches)8.12 Functional Tools: apply
| Function | Signature | Description |
|---|---|---|
apply | (apply f coll) | Applies function f to the argument sequence coll |
(apply f x y ... coll) | Applies function f to x, y, ... and the argument sequence coll |
The apply function invokes a function f with the provided arguments. The
last argument must be a vector, set, or map, which is "unrolled" into
individual arguments. A map is unrolled as [key value] entry vectors. Any
arguments between f and the collection are passed as fixed prefix arguments.
;; Basic usage
(apply + [1 2 3]) ; => 6
(apply str ["a" "b" "c"]) ; => "abc"
;; Spreading with fixed arguments
(apply + 1 2 [3 4]) ; => 10
(apply merge {:a 1} [{:b 2} {:c 3}]) ; => {:a 1 :b 2 :c 3}
(apply vector {:a 1}) ; => [[:a 1]]
;; With Keywords as functions
(apply :name [{:name "Alice"}]) ; => "Alice"
;; With Sets as functions
(apply #{1 2 3} [2]) ; => 2
;; With filtering (passing apply as a value)
(map #(apply + %) [[1 2] [3 4]]) ; => [3 7]Edge Cases:
- Empty collection:
(apply + [])is equivalent to(+), returning0. - Nil as last argument:
(apply + 1 2 nil)returns atype-error. PTC-Lisp requires an explicit collection. - Sets as last argument:
(apply + 1 #{2 3})is allowed, but since sets are unordered, the application order is undefined (not an issue for commutative operations like+). - Maps as last argument: Each map entry is spread as one
[key value]argument; map iteration order is unspecified. - Non-callable first argument: Raises a
not-callableerror. - Non-collection last argument: Raises a
type_error.
8.13 Debugging with println
| Function | Signature | Description |
|---|---|---|
println | (println ...) | Records spaced arguments in the evaluation result's bounded prints list. Returns nil. |
println records bounded diagnostic text in the evaluation result. It does not
write to stdout or to a canonical execution trace; a host or frontend must
render or retain the returned entries explicitly.
Behavior:
- String arguments remain plain text. Other arguments use the same bounded structural renderer as REPL and model-observation previews.
- Multiple arguments are separated by single spaces.
- Each
printlncall appends one entry to theprintslist. - Returns
nil.
Structural rendering bounds collection items, depth, nodes, strings,
characters, and UTF-8 bytes while traversing. A clipped diagnostic includes an
explicit preview marker instead of cutting a nested value at an arbitrary byte.
This does not change pr-str: explicit pr-str remains exact and may allocate
in proportion to its input.
(def results (tool/search {:q "test"}))
(println "Found:" (count results))
(println "First:" (first results))
resultsEvaluation Result:
Programs evaluated through PtcRunner.Lisp.run/2 return captured println
output in the result's prints list:
# Result of Lisp.run(...)
{:ok, %PtcRunner.Lisp.Result{
return: [...],
prints: ["Found: 42", "First: {:id 1}"]
}}Note: In parallel operations like pmap and pcalls, println output from
retained worker envelopes is captured in input order, independent of worker
completion order. Output from the selected worker error envelope is retained;
concurrently returned secondary errors are not retained, and detailed output
may be unavailable when a worker is killed before it can return an envelope.
8.14 Date and Time (Bounded Java Interop)
PTC-Lisp admits a closed, class-aware temporal profile. LocalDate, Instant, Duration, and legacy Date values retain their Java class identity while they remain inside native evaluation. Raw host Date and DateTime structs are never promoted implicitly to Java values.
| Symbol | Signature | Native result or behavior |
|---|---|---|
| LocalDate/parse | (LocalDate/parse text) | Strict ISO LocalDate |
| Instant/parse | (Instant/parse text) | Strict ISO Instant with nanosecond precision |
| Duration/between | (Duration/between start end) | Duration between two Instants |
| java.util.Date. | (java.util.Date.) | Date at the current epoch millisecond |
| (java.util.Date. epoch-millis) | Date from one exact signed Java long millisecond value | |
| (java.util.Date. legacy-text) | Date from the bounded legacy English grammar | |
| .toEpochDay | (.toEpochDay local-date) | Java long epoch-day |
| .plusDays / .minusDays | (.plusDays local-date n) | Shift LocalDate using Java long coercion |
| .isBefore / .isAfter | (.isBefore value other) | Same-class LocalDate or Instant comparison |
| .toEpochMilli | (.toEpochMilli instant) | Java long epoch milliseconds |
| .toMillis / .toDays | (.toMillis duration) | Java long Duration projections |
| .getTime | (.getTime date) | Exact Date epoch milliseconds |
| .before / .after | (.before date other) | Legacy Date comparison |
| System/currentTimeMillis | (System/currentTimeMillis) | Current Java long epoch milliseconds |
Both shorthand and fully qualified static spellings are admitted: LocalDate/parse and java.time.LocalDate/parse, Instant/parse and java.time.Instant/parse, and Duration/between and java.time.Duration/between. There is no bare parse or between alias.
Native identity and display
Native execution and continuation memory retain validated wrappers:
- LocalDate stores one Java-range epoch day.
- Instant stores epoch seconds plus a nanosecond adjustment.
- Duration stores signed seconds plus a normalized nanosecond adjustment.
- Date stores one signed Java long epoch-millisecond value.
The runtime str function emits Java-compatible canonical text. Diagnostic formatting uses inert class-labelled forms rather than exposing host struct fields. Public and Kernel results recursively project the wrappers to strings: ISO local date, Java Instant text, Java Duration text, and a UTC instant derived from exact Date milliseconds.
Parsing and construction
LocalDate/parse accepts only the Java ISO local-date grammar and full Java LocalDate range. Date-time text is rejected.
Instant/parse requires a UTC marker or numeric offset, retains up to nine fractional digits, and follows Java ISO Instant normalization, including 24:00 rollover and the parser's leap-second normalization. Offsetless date-times and date-only strings are rejected.
Duration/between accepts two native Instants only. LocalDate, Date, host
DateTime, and mixed-class arguments are type errors. This is the bounded
Temporal-profile divergence recorded as DIV-52. Partial units in toDays
truncate according to Java Duration semantics.
Ordinary compare and natural sorting compare two values of the same admitted
temporal class by their Java temporal value, never by the field order of the
native wrapper. Numeric min, max, min-key, and max-key reject temporal
values; the PTC min-by and max-by data helpers retain natural ordering.
Date(long) always means milliseconds. It performs no seconds-versus-milliseconds
heuristic. Host temporal structs are not accepted as constructor arguments.
Date(String) accepts a deterministic bounded subset of the deprecated Java
legacy English grammar. It requires an explicit year with at least four digits
and a date on or after Java's Gregorian cutover (1582-10-15), and treats a
missing zone as UTC; see DIV-51.
Receiver ownership
Dot methods are selected by the receiver's admitted class:
- LocalDate owns toEpochDay, plusDays, minusDays, isBefore, and isAfter.
- Instant owns toEpochMilli, isBefore, and isAfter.
- Duration owns toMillis and toDays.
- Date owns getTime, before, and after.
Instant does not own getTime, and Date does not own isBefore or isAfter. Mixed temporal classes and ordinary host temporal structs cannot bypass this selection.
Boundary rules
run_native and run/2 continuation memory retain wrappers. Observable public result fields and Kernel boundaries emit canonical inert strings. Direct tool arguments use the declared field type only for Java leaves: string and any receive canonical text; datetime accepts Instant or Date only when the conversion is exact and host-representable. LocalDate and Duration are rejected for datetime, as are nanosecond Instants that would lose precision. Direct tool results reject Java wrappers. Namespace export also rejects native Java wrappers so class authority cannot be serialized as ordinary Lisp data.
Malformed or forged wrappers, projection collisions, invalid strings, null object arguments, wrong classes, and arithmetic overflow become bounded Lisp errors rather than host exceptions.
8.15 String Methods (Java Interop)
PTC-Lisp supports Java-style string methods for common operations.
| Method | Signature | Description |
|---|---|---|
.indexOf | (.indexOf s substr) | Index of first occurrence, or -1 if not found |
.indexOf | (.indexOf s substr from) | Index of first occurrence starting from position |
.lastIndexOf | (.lastIndexOf s substr) | Index of last occurrence, or -1 if not found |
.startsWith | (.startsWith s prefix) | Returns true if string starts with prefix |
.endsWith | (.endsWith s suffix) | Returns true if string ends with suffix |
.contains | (.contains s substr) | Returns true if string contains substring; raises on non-string |
.length | (.length s) | UTF-16 code-unit length; raises on non-string |
.substring | (.substring s start) | Suffix from UTF-16 code-unit index start; raises on out-of-range index |
.substring | (.substring s start end) | UTF-16 code units in [start, end); raises on out-of-range index |
(.indexOf "hello" "ll") ; => 2
(.indexOf "hello" "x") ; => -1
(.indexOf "hello" "l" 3) ; => 3 (finds second 'l')
(.lastIndexOf "hello" "l") ; => 3 (last 'l')
(.startsWith "hello" "he") ; => true
(.endsWith "hello" "lo") ; => true
(.contains "hello" "ell") ; => true
(.length "hello") ; => 5
(.substring "hello" 2) ; => "llo"
(.substring "hello" 1 3) ; => "el"
(.length "😀a") ; => 3
(.substring "😀a" 2) ; => "a"Unlike ordinary PTC string functions, Java-named indexes and lengths use UTF-16
code units. PTC strings remain valid UTF-8, so a substring selecting only one
half of a surrogate pair returns the bounded invalid_java_string divergence.
The temporary UTF-16 view accepts at most 256,000 input bytes.
These Java-named methods raise on bad input — .substring raises when
start < 0, start > length, end > length, or start > end.
Locale-sensitive no-argument .toLowerCase and .toUpperCase are not admitted
until a deterministic locale and pinned Unicode-data contract are selected.
Return Value: These methods return -1 when the substring is not found (Java semantics). Prefer index-of / last-index-of (§8.3) which return nil when not found (Clojure semantics).
Errors: Passing a non-string raises a descriptive error:
(.indexOf 123 "x") ; => Java interop error: receiver does not match an admitted class9. Namespaces, Context, and Tools
Programs have access to data and functions through namespaced symbols and special forms.
9.1 Namespace Overview
| Access Pattern | Source | Description |
|---|---|---|
| Plain symbols | Stored values | Values defined via def/defn, persisted across turns |
data/ | Current request context | Current request context (read-only) |
tool/ | Tool invocation | Call registered tools |
*1, *2, *3 | Recent results | Previous turn results (for debugging) |
9.2 Persistent Values — User Namespace symbols
Access values stored in the User Namespace as plain symbols. These values are defined using the def or defn forms and persist across turns within a session:
high-paid ; access symbol defined via (def high-paid ...)
orders ; access symbol defined via (def orders ...)
query-count ; access symbol defined via (def query-count ...)Stored values are read-only during evaluation unless redefined via def. To update a value for the next turn, use def in your program (see Section 16).
(def new-orders (tool/get-orders {:since "2024-01-01"}))
(def orders (concat orders new-orders))
orders ; return current totalUse defonce to initialize a value before updating it:
(defonce query-count 0)
(def query-count (inc query-count))
query-count9.3 Context Access — data/
Read from current request context using the data/ namespace prefix:
data/input ; get :input from context
data/user-id ; get :user-id from context
data/request-id ; get :request-id from contextContext is per-request data passed by the host. It does not persist across turns.
(->> data/expenses
(filter (fn [e] (= (:category e) "travel")))
(sum-by :amount))Every Kernel boundary looks up data/<name> strictly — a workflow entry, a
normal mission run, and both ptc repl session kinds. A missing grant is a
:runtime_error that names the rejected symbol and lists the granted
data/<name> forms available at that boundary. Calling a granted data value,
as in (data/tickets), is :not_callable and names the symbol rather than
rendering the value. Only the generic PtcRunner.Lisp.run/2 embedding API is
permissive, where a missing data/<name> still evaluates to nil.
A manifest binds exactly one workflow name, data/input, from its input
declaration, so a misspelled workflow reference is rejected against that single
granted form.
data/params is injected only by kernel/eval-with and
kernel/eval-source-with. Referencing it when this evaluation supplied no
params is a distinct error, not a missing-grant diagnostic. Discover granted
names from the mission inventory (:context in a mission REPL), not from
apropos or doc.
9.4 Turn History — *1, *2, *3
Access results from previous turns using the turn history symbols:
*1 ; result from the previous turn (most recent)
*2 ; result from 2 turns ago
*3 ; result from 3 turns agoSemantics:
*1returns the exact native result of the most recent ordinary successful turn*2and*3return the next two older ordinary successful results- Returns
nilif the turn doesn't exist (e.g.,*1on turn 1) - A Kernel run and direct Kernel REPL retain exactly the last three results; the oldest is discarded when a fourth ordinary success commits
- Explicit
returncommits definitions but does not advance history - Failed evaluations and explicit
failroll back definitions and history - History values retain their exact native types and callable identities; only inert projections cross public or model-observation boundaries
- Use stored values (plain symbols defined via
def) for persistent access to full values
Use cases:
- Quick inspection of previous results during debugging
- Lightweight chaining when full values aren't needed
;; On turn 2, check if the previous result was a vector
(if (vector? *1)
(count *1)
0)
;; Compare current with previous
(> (count data/items) (count *1))History and definition memory commit atomically under separate host byte
ceilings. If either candidate exceeds its ceiling, neither changes. Use
(def name value) when a value needs a stable name beyond the three-result
history window.
9.5 Tool Invocation — tool/tool-name
Invoke registered tools using the tool/ namespace:
(tool/tool-name) ; no arguments
(tool/tool-name args-map) ; with arguments (named parameters)Syntax:
- A
tool/namesymbol resolves only against the tools explicitly granted by the host - Tools require named arguments (maps):
- No arguments:
(tool/get-users)→ tool receives%{} - Map argument:
(tool/fetch {:id 123})→ tool receives%{"id" => 123} - Keyword-style:
(tool/search :query "x" :limit 10)→ tool receives%{"query" => "x", "limit" => 10}
- No arguments:
Examples:
(tool/get-users) ; no arguments
(tool/search {:query "budget"}) ; single map argument
(tool/fetch {:id 123}) ; with parameters
(tool/search {:query "foo" :limit 10})
;; Store tool result for later use
(let [users (tool/get-users)]
(->> users
(filter :active)
(count)))Tool boundary contract:
- PTC-Lisp keywords (
:foo) become string keys at the host boundary - Tools always receive string-keyed maps:
%{"key" => value} - This matches JSON conventions and prevents atom memory leaks
- Tool authors pattern match on string keys:
def run(%{"query" => q}, _ctx)
Tool behavior:
- Tools are registered host callbacks
- Tools may have side effects (external API calls, database queries)
- Tool errors propagate as execution errors
- Tool calls are logged for auditing
9.6 Namespace Compatibility
LLMs often generate code with namespace-qualified symbols. PTC-Lisp does not
evaluate namespace declarations (ns, require, refer, import), but it
does allow a fixed set of namespace-qualified symbols. These normalize to
built-ins or reserved runtime operations at analysis time.
Supported namespaces:
| Group | Namespace(s) | Category |
|---|---|---|
| Clojure compatibility | clojure.core, core | Core functions |
| Clojure compatibility | clojure.string, str, string | String functions |
| Clojure compatibility | clojure.set, set | Set functions |
| Clojure compatibility | clojure.walk, walk | Tree traversal functions |
| Clojure compatibility | regex | Regex helpers (re-find, re-pattern, etc.; underlying vars are audited as clojure.core) |
| Java compatibility | Math | Closed Java primitive overloads for abs, ceil, floor, max, min, pow, round, and sqrt; other inventoried members remain PTC namespace helpers |
| Java compatibility | System | Java System time helper |
| Java compatibility | Boolean | Boolean parse alias |
| Java compatibility | Double | Double constants and parse alias |
| Java compatibility | Float | Float parse alias (returns PTC-Lisp double/float) |
| Java compatibility | Integer, Long | Integer parse aliases |
| Java compatibility | LocalDate, java.time.LocalDate | Java Date parsing (ISO-8601) |
| Java compatibility | Instant, java.time.Instant | Java Instant parsing (ISO-8601) |
| Java compatibility | Duration, java.time.Duration | Java Duration between helper |
| Java compatibility | java.util.Date. | Java Date constructors |
| PTC runtime/helper | data | Context access |
| PTC runtime/helper | tool | Registered tool invocation |
| PTC runtime/helper | json | JSON parse/generate helpers |
Examples of normalization:
;; These all normalize to the same built-in function:
(clojure.string/join "," items) ; → (join "," items)
(str/join "," items) ; → (join "," items)
(join "," items) ; (no change)
;; Core functions work too:
(clojure.core/map inc xs) ; → (map inc xs)
(core/filter even? xs) ; → (filter even? xs)
;; Tree traversal functions work via clojure.walk:
(clojure.walk/prewalk f data) ; → (prewalk f data)
(walk/postwalk f data) ; → (postwalk f data)
;; Regex helpers can be qualified when that improves clarity:
(regex/re-find #"error" line) ; → (re-find #"error" line)
;; Java compatibility namespaces use bounded Java dispatch:
(Math/sqrt 9) ; Java double result, distinct from bare sqrt
(Math/max 1 2.0) ; Java type error: no exact mixed overload
(System/currentTimeMillis) ; closed Java static call; no bare alias
(Instant/parse "2026-05-18T12:00:00Z") ; closed static call returning a native InstantThe admitted qualified Math references preserve Java primitive identity and
overload behavior. Untagged in-range integer literals select long; untagged
floating literals and special numeric values select double. Results retain
their selected int, long, float, or double provenance while native.
Overloaded families require exact primitive agreement, so mixed long and
double arguments do not silently widen. A sole double overload (ceil,
floor, pow, or sqrt) accepts a bounded numeric argument using Java's
double conversion. Math/round keeps its float and double overloads distinct,
including Java NaN and infinity conversion. Bare abs, ceil, floor, max,
min, pow, round, and sqrt remain ordinary PTC-Lisp helpers with their
documented generic semantics.
System/currentTimeMillis is a closed zero-argument Java static call. It
retains Java long identity in native execution and projects to an ordinary
integer at the public boundary; bare currentTimeMillis is not a compatibility
alias.
Error handling:
When a namespaced function doesn't exist as a built-in, the analyzer provides helpful error messages with available alternatives:
(clojure.string/capitalize s)
;; Error: capitalize is not available. String functions: str, subs, join, split, trim, ...
(clojure.set/project relations [:id])
;; Error: project is not available. Set functions: set, set?, vec, vector, contains?, intersection, union, difference
(clojure.walk/stringify-keys data)
;; Error: stringify-keys is not available. Walk functions: prewalk, postwalk, walkNote: The data/ and tool/ namespaces are reserved for context access
and tool invocation respectively. They are not aliases for Clojure
namespaces.
9.7 Kernel Component Namespaces
Hosts compile namespace-bearing components with PtcRunner.Kernel.compile_bundle/1.
Public defn/def exports are callable by qualified name; defn- helpers stay
private. Every referenced tool/name is recorded as a tool:name requirement,
including transitive helper and component-dependency calls. Requirements are
validated against an explicit workflow or mission environment and never grant
authority. See Components and preludes.
Manifest component arrays may combine local source objects with exact
{"library": id} selections. Library selections resolve only from the trusted
installed catalog, expand their transitive closure before compilation, and may
not be repeated or shadowed by a local component ID.
9.8 Public Component Contracts
Host-compiled components may attach an optional contract metadata map to a public function or constant:
(defn search
"Search documents."
{:signature "(query :string, limit :int?) -> {items [:string]}"}
[query limit]
...)
(def default-limit {:type ":int"} 10)This uses the supported defn/def metadata-map position. Clojure reader
metadata syntax such as ^{:signature "..."} is not supported.
Contract enforcement has three stages:
- Component compilation parses the signature/type string and rejects invalid syntax, duplicate parameter names, normalized shaped-map field collisions, and invalid constant values.
- Compilation checks that a function signature has exactly the declared function arity.
- Evaluation validates signed function arguments before body entry and every
successful result after execution. This applies to direct calls and calls
through higher-order functions. An export-local
(return value)is a successful result;(fail value)is not.
Supported scalar types are :string, :int, :float, :bool/:boolean,
:keyword, :map, :datetime, and :any, plus homogeneous sequences such as
[:string] and shaped maps such as {id :string, title :string?}. A ?
means the value may be nil. It does not make a positional parameter
omittable; fixed arity still applies. For a shaped-map field, the same marker
allows the field to be omitted or set to nil. Signatures are currently
supported only on fixed-arity component exports; the contract grammar has no
rest-parameter type. Prelude contracts distinguish internal Lisp keywords from
strings: :ready satisfies :keyword, while "ready" satisfies :string and
does not satisfy :keyword.
These contracts apply to public prelude exports. Raw tool/name argument and
result validation remains governed by the host capability's JSON Schema.
Capability input-property names must be stable under the tool boundary's
hyphen-to-underscore normalization, as must object keys nested in input
const/enum values; the host rejects incompatible schemas rather than
publishing an exact call that runtime validation cannot accept.
9.9 Introspection
Five builtins provide one language-level discovery interface. They answer
identically in the REPL, in generated workflow or mission source, and inside a
prelude export reading another prelude's documentation. dir, export-meta,
and source describe the attached prelude API; apropos and doc also cover
fixed built-ins and the bounded Java surface.
| Form | Result |
|---|---|
(dir) | Sorted vector of namespace names holding public exports |
(dir "ns") | Sorted vector of export refs in ns |
(apropos "term") | Sorted vector of matching prelude refs and canonical fixed-function names |
(doc "name") | Prints prelude or fixed-function documentation, returns nil |
(export-meta "ns/name") | Metadata map, or nil when unknown |
(source "ns/name") | Prints the attached prelude defining form, or a miss notice; returns nil |
For dir/doc/export-meta/source, references accept a string, a quoted
symbol, or an unquoted symbol (the analyzer auto-quotes bare and namespaced
symbols in those call positions, matching clojure.repl/doc). apropos
accepts a string or a quoted symbol; an unquoted query evaluates normally.
Computed arguments still evaluate normally, so (doc (str "ns/" "name")) and
#(doc %) over string refs keep working. clojure.core/meta is unchanged and
is not part of this family.
Answers depend on the prelude a given run attaches, so the results below are illustrative:
(dir "inspection") ; => ...
(apropos "exchange") ; => ...
(export-meta "inspection/runs") ; => ...
(map export-meta (dir "inspection")) ; => ...The last line is the point of these being ordinary function values rather than special forms: they compose in any position a function is accepted.
doc prints and returns nil so documentation is charged to the print budget
rather than the result channel. For an attached prelude export, a program that
needs the same information as data calls export-meta; registry metadata is
reader-facing documentation only.
export-meta reports the full calling contract: :ref, :namespace,
:symbol, :kind, :call, :doc, :visibility, :effect, plus
:arity/:params for functions and :signature or :type where declared.
doc renders the reader-facing subset — ref, call form, declared contract,
effect, and docstring — so :visibility and the separate arity and parameter
fields are available only from export-meta. Neither reports capability
wiring.
The reported effect is conservative. The authoritative value is the
mission-resolved effect in the prompt inventory, which combines an export's
declaration with the effects of the capabilities it reaches; a wrapper declaring
:read over a :write capability resolves to :write. Introspection cannot
see installed capability effects, so an export that reaches any capability is
reported as :write when its chain declares :write and :unknown otherwise.
It is never reported as :read, so no answer here presents an unresolved effect
as safe.
apropos performs a case-insensitive literal substring search. For attached
prelude exports it searches refs and docstrings. For the fixed registry it
searches canonical names, signatures, descriptions, notes, divergences, and
sections; aliases present in signatures, including fully qualified Java names,
remain searchable, while results use canonical names. Results are sorted and
deduplicated.
doc resolves an exact attached prelude export before consulting the fixed
registry. Visibility is applied after that occupancy check, so a hidden
attached export cannot reveal registry documentation through the same
spelling. apropos similarly suppresses a colliding registry name when its
attached export is hidden. This preserves the invariant that attached API
discovery never advertises something the running program cannot call.
None of the forms enumerate data/... values or tool/... capabilities;
those appear in the mission inventory. dir, export-meta, and source
remain attached prelude-only: namespace/export records and defining forms have
no lossless equivalent for fixed registry entries. source has no registry
fallthrough at all.
Both :prompt and :discoverable exports are visible to dir/doc/
export-meta/apropos, which is how a :discoverable export is found at
all. Private defn- helpers have no export record and never appear there, and
a namespace holding only private helpers is absent from (dir). source is
the exception for implementation inspection: it also reveals private helpers
that are transitively reachable from a public export.
Attached prelude results for dir/doc/apropos/export-meta are filtered
to what the running program may actually call. A miss is not a failure:
export-meta returns nil, doc and source print a not-found line, and
dir returns []. A blank apropos query returns [] rather than every
fixed and attached function.
export-meta is not clojure.core/meta, which takes an object rather than a
reference string and is not implemented.
10. Complete Examples
10.1 Filter and Sum (Pure Query)
Filter expenses by category and sum amounts:
(->> data/expenses
(filter (fn [e] (= (:category e) "travel")))
(sum-by :amount))Returns a number. No memory is updated because the program contains no def;
the result's type does not control persistence.
10.2 Find Single Item
Find the cheapest product:
(min-by :price data/products)Find employee with most years:
(max-by :years-employed data/employees)10.3 Sort and Limit
Get top 5 products by price:
(->> data/products
(sort-by :price >)
(take 5))10.4 Extract Field Values
Get all product names:
(map :name data/products)10.5 Conditional Classification
Classify invoice by total:
(let [{:keys [total]} data/invoice]
(cond
(> total 1000) "high-value"
(> total 100) "medium-value"
:else "low-value"))10.6 Complex Filtering
Find eligible orders (high value, premium status, not flagged):
(->> data/orders
(filter (fn [o]
(and (> (:total o) 100)
(or (= (:status o) "vip")
(= (:status o) "premium"))
(not (:flagged o))))))10.7 Transform and Select Fields
Get names and emails of active users:
(->> data/users
(filter :active)
(mapv (fn [u] (select-keys u [:name :email]))))10.8 Combine Multiple Data Sources
Join orders with user information:
(let [users (tool/get-users)
orders (tool/get-orders)]
(->> orders
(filter (fn [o] (> (:total o) 100)))
(mapv (fn [order]
(let [user (first (filter (fn [u] (= (:id u) (:user-id order))) users))]
(merge order (select-keys user [:name :email])))))))10.9 Grouping and Aggregation
Sum expenses by category:
(let [by-category (group-by :category data/expenses)]
(->> (keys by-category)
(mapv (fn [cat]
{:category cat
:total (sum-by :amount (get by-category cat))}))))10.10 Nested Data Access
Get email from nested user profile:
(get-in data/user [:profile :contact :email])Filter by nested field:
(->> data/users
(filter (fn [u] (= (get-in u [:profile :verified]) true))))11. Semantics and Edge Cases
11.1 Empty Collections
| Operation | Empty Input | Result |
|---|---|---|
(count []) | [] | 0 |
(first []) | [] | nil |
(last []) | [] | nil |
(sum []) | [] | 0 |
(avg []) | [] | nil |
(sum-by :x []) | [] | 0 |
(avg-by :x []) | [] | nil |
(min-by :x []) | [] | nil |
(max-by :x []) | [] | nil |
(distinct-by :x []) | [] | [] |
(filter pred []) | [] | [] |
(sort-by :x []) | [] | [] |
11.2 Nil Handling
;; Accessing missing key returns nil
(get {:a 1} :b) ; => nil
(:b {:a 1}) ; => nil
(get-in {:a {:b 1}} [:a :c]) ; => nil
;; Arithmetic with nil is a type error
(+ 1 nil) ; => TYPE ERROR
;; Equality with nil is allowed
(= nil nil) ; => true
(= 5 nil) ; => false
(nil? nil) ; => true
;; Numeric ordering rejects a missing value
(> 5 nil) ; => TYPE ERROR
(< nil 10) ; => TYPE ERROR
;; filter/map handle nil gracefully
(filter (fn [m] (= (:x m) nil)) [{:x nil} {:x 1}]) ; => [{:x nil}]11.3 Numeric Predicates and Default Comparison
The ordering predicates (>, <, >=, <=), min, max, min-key, and
max-key accept numeric operands, matching their Clojure contracts. A reached
non-numeric operand signals :type_error; comparisons with NaN return false.
The one-argument predicate forms return true without inspecting the argument,
also matching Clojure. Unary min/max return their sole argument, and unary
min-key/max-key return the sole value without invoking the key function;
numeric validation begins only when a comparison is reached.
compare, default sort, and default sort-by are distinct from the numeric
predicates. They use Clojure-like natural comparison: nil sorts before every
non-nil comparable value, and incompatible types fail rather than inheriting a
BEAM term order. Same-class validated Java temporal values retain their Java
natural comparison as a PTC interoperability extension.
;; Numeric comparisons
(> 5 3) ; => true
(< 1.5 2.0) ; => true
;; Non-numeric predicates fail
(> "b" "a") ; => TYPE ERROR
(< 1 nil) ; => TYPE ERROR
(<= nil nil) ; => TYPE ERROR
(< Double/NaN 0.0) ; => false
;; Natural comparison is nil-first and rejects incompatible values
(compare nil 1) ; => -1
(sort [1 nil]) ; => [nil 1]
(sort [1 "a"]) ; => TYPE ERROR
;; PTC has no distinct Character type (GAP-S120)
(compare \a "a") ; => 0
(sort [\b "a"]) ; => ["a" "b"]Heterogeneous values without a shared natural order produce a type error. A workflow that intentionally accepts mixed representations must normalize them or supply an explicit domain comparator.
11.4 Aggregation with Missing/Nil Fields
;; sum-by skips nil/missing fields
(sum-by :amount [{:amount 10} {:amount nil} {:other 5}]) ; => 10
;; avg-by skips nil/missing (not counted in denominator)
(avg-by :amount [{:amount 10} {:amount nil} {:amount 20}]) ; => 15.0
;; min-by/max-by skip nil values
(min-by :price [{:price nil} {:price 10} {:price 5}]) ; => {:price 5}11.5 Non-Numeric Aggregation Fields
Aggregation functions require numeric field values:
;; Arithmetic error - string in numeric aggregation
(sum-by :amount [{:amount "10"} {:amount 20}]) ; => ARITHMETIC ERROR
;; Arithmetic error - map in numeric aggregation
(avg-by :value [{:value {:x 1}}]) ; => ARITHMETIC ERRORRule: If a field exists and is not nil but is non-numeric, aggregation functions raise an arithmetic error (:arithmetic_error). Only nil and missing fields are silently skipped.
11.6 Short-Circuit Evaluation
and and or short-circuit:
(and false (tool/expensive)) ; "expensive" not called
(or true (tool/expensive)) ; "expensive" not called11.7 Keyword as Function with Default
(:name {:name "Alice"}) ; => "Alice"
(:name {}) ; => nil
(:name {} "Unknown") ; => "Unknown"11.8 Map as Function
Maps can be called as functions with a keyword argument:
({:name "Alice"} :name) ; => "Alice"
({} :name) ; => nil
({} :name "Unknown") ; => "Unknown"11.9 Flatten Behavior
flatten recursively flattens nested collections:
(flatten [[1 2] [3 [4]]]) ; => [1 2 3 4]
(flatten [1 [2 {:a 3}] "str"]) ; => [1 2 {:a 3} "str"]- Only vectors are recursively flattened
- Maps, sets, strings, and other non-vector values pass through unchanged (even
though sets satisfy
coll?, they are not flattened)
11.10 Tool Call Evaluation Order
Outside explicit parallel forms, tool calls are evaluated left-to-right:
(let [a (tool/tool-1) ; called first
b (tool/tool-2)] ; called second
[a b])This matters because tools may have side effects. Sequential evaluation guarantees:
- Arguments evaluated left-to-right
- Tool calls execute in program order
- No speculative execution
pmap and pcalls are the explicit exception: their worker calls may overlap
and complete in any order, while their result vectors retain input order.
12. Error Handling
PtcRunner.Lisp.run/2 returns {:ok, %PtcRunner.Lisp.Result{}} on success and
{:error, %PtcRunner.Lisp.Result{}} on failure. The public failure is in the
result's fail field:
{:error,
%PtcRunner.Lisp.Result{
return: nil,
fail: %{
reason: :type_error,
message: "type_error: add: invalid argument types: number, nil",
details: %{}
},
memory: %{},
prints: [],
tool_calls: []
}}The evaluator uses internal tagged reasons while executing, but callers should
consume the stable Result.fail map rather than depend on those internal
tuples. Failed evaluations normally return the original continuation memory,
and candidate def changes are rolled back. If supplied continuation memory
contains an improper list or a malformed symbol-reference or keyword wrapper,
the public error returns empty memory instead: copying that invalid value into
the error result would make the error itself unsafe to project. Invalid context
or history still preserves otherwise-valid continuation memory. A setup-phase
heap kill or timeout also returns empty memory: the environment worker could
not safely import and normalize the grant, so the caller does not traverse and
project that input memory again outside the setup bounds. The failure details
identify the phase as :setup; evaluation-phase failures identify :eval.
12.1 Error Types
fail.reason | Cause |
|---|---|
:parse_error | Invalid syntax |
:invalid_form | Malformed program structure (static analysis phase) |
:invalid_arity | Wrong number of arguments to a special form (detected during analysis) |
:type_error | Wrong argument type |
:arithmetic_error | Arithmetic operation error (e.g. integer division by zero) |
:arity_error / :arity_mismatch | Wrong number of arguments / closure arity mismatch (detected at runtime) |
:unbound_var | Unknown symbol/variable |
:not_callable | Attempt to call a non-callable value |
:runtime_error | General runtime evaluation error |
:loop_limit_exceeded | loop/recur iteration limit exceeded |
:unknown_tool | Tool not registered |
:tool_error | Tool execution failed |
:destructure_error | Destructuring pattern mismatch |
:invalid_placeholder | Invalid placeholder in #() syntax |
:unsupported_pattern | Unsupported destructuring/binding pattern |
:unsupported_method | Unknown Java-interop method |
:invalid_keyword | A supplied or returned keyword wrapper is malformed or its name is outside the reader's keyword grammar |
:invalid_lisp_list | A supplied public value contains an improper host list, which cannot represent a PTC-Lisp vector |
:invalid_symbol_ref | A supplied or returned symbol-reference wrapper is malformed or its name is outside the reader's symbol grammar |
:symbol_ref_collision | Distinct public/native symbol-reference values collapse to the same map key or set member |
:timeout | Setup or execution time exceeded (fail.details.phase identifies the phase) |
:memory_exceeded | Memory limit exceeded |
12.2 Error Message Format
fail.reason is the machine-readable atom. fail.message is a bounded
human-readable rendering, and fail.details carries structured data when the
error supplies it. Message wording is diagnostic and should not be parsed as an
API.
12.3 Common Errors and Hints
| Error | Hint |
|---|---|
Unknown symbol foo | Did you mean: filter, first, find? |
Wrong arity for if | expected (if cond then else?) |
let bindings not paired | let requires an even number of binding forms |
13. Language Scope and Restrictions
PTC-Lisp intentionally omits many Clojure features for sandbox safety and simplicity, and supports a few (like anonymous functions) only with restrictions. For a complete list of intentional divergences with rationale, see Clojure Conformance Gaps — Intentional Divergences.
Key omissions: lazy sequences, macros, mutable state (atom/ref/agent),
eval/read-string, file I/O, try/catch/throw,
multi-methods/protocols, user-defined namespaces, and arbitrary Java or host
interop. PTC-Lisp instead exposes a closed, manifest-defined compatibility
subset of Java-named methods, constructors, static members, and constants; see
the Java Interop Reference.
Note: println IS supported — see §8.13. It appends to the evaluation
result's bounded prints list, not stdout or a trace.
13.1 Anonymous Functions (Supported, With Restrictions)
Anonymous functions are supported via fn or #() shorthand with restrictions:
Full fn Syntax
(fn [x] body) ; single argument
(fn [a b] body) ; multiple arguments
(fn [a & rest] body) ; variadic arguments
(fn [[a b]] body) ; vector destructuring in params
(fn [{:keys [x]}] body) ; map destructuring in params (keyword keys)
(fn [{:strs [x]}] body) ; map destructuring in params (string keys)Implicit do: As in Clojure, multiple body expressions are supported:
(fn [x]
(def last-input x) ; stage a continuation binding
(* x 2)) ; return valueShort #() Syntax
The #() shorthand syntax provides concise lambdas (like Clojure):
#(+ % 1) ; % is the first parameter (p1)
#(+ %1 %2) ; explicit numbered parameters
#(* % %) ; same parameter used multiple times
#(42) ; zero-arity thunk (no parameters)
#(vector %1 %&) ; %& captures remaining args as a vectorThe #() syntax desugars to the equivalent fn:
#(+ % 1)→(fn [p1] (+ p1 1))#(+ %1 %2)→(fn [p1 p2] (+ p1 p2))#()with no placeholders →(fn [] ...)#(vector %1 %&)→(fn [p1 & rest] (vector p1 rest))- Arity is determined by the highest numbered placeholder, or 1 if only
%is used %&captures remaining arguments in PTC-Lisp's vector sequence representation (unlike Clojure's list/seq representation)
Restrictions:
#()accepts a single expression as the body%,%1,%2, etc. are parameter placeholders;%&captures rest args (not regular symbols within#())- Nested
#()is not allowed (DIV-17) - Recursion is supported via
recur(no self-reference by name, see §5.16) - Closures over local
letbindings are allowed - No closures over mutable host state (there is none)
Examples:
;; Filter with #() shorthand
(filter #(> % 10) items)
;; Map with string construction
(map #(str "id-" %) items)
;; Transform each item with fn (more complex)
(mapv (fn [u] (select-keys u [:name :email])) users)
;; Access outer let bindings (closure)
(let [threshold 100]
(filter #(> (:price %) threshold) products))
;; Destructuring in fn params
(mapv (fn [{:keys [name age]}] {:name name :years age}) users)When to use #() vs fn:
- Use a keyword (
:active) as the predicate when you just need a truthy field check. - Use
#()for simple, single-argument lambdas (most common LLM use case). - Use
fnfor complex logic, destructuring, or multiple parameters.
13.2 Functions Excluded from Core
For supported functions and special forms, see the Function Reference.
Key exclusions: iterate, repeat, cycle (infinite sequences), infinite (range) (finite range is supported: see §8.1), and transducers.
14. Grammar (EBNF)
program = expression* ; (* Multiple top-level expressions with implicit do *)
expression = literal
| symbol
| keyword
| regex
| var-lookup
| quoted-symbol
| short-fn
| vector
| set
| map
| list-expr ;
literal = nil | boolean | number | special-number | string | char ;
nil = "nil" ;
boolean = "true" | "false" ;
number = integer | float ;
special-number = "##Inf" | "##-Inf" | "##NaN" ;
integer = ["-"] digit+ ;
float = ["-"] digit+ "." digit+ [exponent]
| ["-"] digit+ exponent ;
exponent = ("e" | "E") ["+" | "-"] digit+ ;
string = '"' string-char* '"' ;
regex = '#"' regex-char* '"' ;
string-char = escape-seq | (any char except '"' and '\') ; (* literal newlines allowed *)
regex-char = regex-escape | (any char except '"' and '\') ; (* literal newlines allowed *)
escape-seq = '\\' (any char except newline or carriage return) ;
regex-escape = '\\' (any char except newline or carriage return) ;
char = '\\' (char-name | any-char) ;
char-name = "newline" | "space" | "tab" | "return" | "backspace" | "formfeed" ;
any-char = (any single Unicode grapheme) ;
symbol = symbol-first symbol-rest* ;
var-lookup = "#'" symbol ;
quoted-symbol = "'" symbol ;
short-fn = "#(" expression* ")" ;
symbol-first = letter | special-initial ;
symbol-rest = letter | digit | special-rest ;
letter = "a"-"z" | "A"-"Z" ;
digit = "0"-"9" ;
special-initial = "+" | "-" | "*" | "/" | "<" | ">" | "=" | "?" | "!" | "_" | "%" | "." | "&" ;
special-rest = special-initial | "'" ; (* "'" only after the first char, e.g. inc' *)
keyword = ":" keyword-char+ ;
keyword-char = letter | digit | "-" | "_" | "?" | "!" | "+" | "*" | "<" | ">" | "=" ; (* no "/" in keywords *)
vector = "[" expression* "]" ;
set = "#{" expression* "}" ;
map = "{" (map-entry)* "}" ;
map-entry = expression expression ;
list-expr = "(" expression expression* ")" ; (* operator can be any expression *)
comment = ";" (any char except newline)* (newline | end-of-input) ;
whitespace = " " | "\t" | "\n" | "\r" | "," ;Grammar notes:
/is allowed in symbols for namespaced access (data/bar,tool/bar)/is NOT allowed in keywords (:foo/baris invalid; see DIV-13)- The operator position in
list-expraccepts any expression, enabling:(:name user)— keyword as function((fn [x] x) 42)— anonymous function application(tool/tool-name args)— tool invocation
Tokenization precedence: When a token could match multiple grammar rules, literals take precedence over symbols:
nil,true,false→ reserved literals (not symbols)-123,3.14→ numbers (not symbols starting with-or digits):foo→ keyword\a,\newline→ character literal- Everything else → symbol
Outside reader-quote shorthand, -1 is always the integer negative one, never
a symbol named "-1". Reader quote consumes raw symbol-shaped text, so '-1
is the reference described in §3.11. Similarly, \r is the character "r", not
a symbol.
15. Implementation Notes
15.1 Evaluation Model
- Programs can contain multiple expressions (evaluated sequentially, last value returned)
- Evaluation is strict (eager), not lazy
defstages continuation changes;printlnrecords bounded diagnostics- Granted tools are the only general external-effect boundary
System/currentTimeMillisand zero-argument(java.util.Date.)explicitly read the system clock
15.2 Resource Limits
| Resource | Default | Notes |
|---|---|---|
| Timeout | 1,000 ms | Execution time limit |
| Max Heap | ~10 MB | Sandbox-process memory limit (1,250,000 words) |
| Worker Max Heap | = Max Heap | Fixed per-worker pmap/pcalls heap cap |
| Max Parallel Workers | 8 | Global cap on live pmap/pcalls workers |
| Max Tool Calls | unlimited (nil) | Program-wide uncached tool invocation limit shared atomically by ordinary closures and all pmap/pcalls workers; enforced only when set via the :max_tool_calls option |
| Loop/Recur Iterations | 1,000 | Per-loop/recur jump limit; ordinary non-tail recursion is bounded by timeout and heap |
Every pmap/pcalls worker process — top-level and nested — is
spawned with a fixed max_heap_size of Worker Max Heap words (the
cap is NOT divided by concurrency). The number of such workers alive at
once, across the whole program and at every nesting depth, is bounded by
a shared slot budget of Max Parallel Workers. Aggregate live parallel
heap is therefore bounded by Max Parallel Workers × Worker Max Heap.
For Kernel-owned workflow, mission, and REPL evaluations,
Max Parallel Workers is set explicitly from the effective
live_provider_tasks run limit; the default of 8 in the table applies to
direct PtcRunner.Lisp callers.
- A worker that exceeds its fixed heap cap is killed; the program fails
with
:memory_exceeded. - A
pmap/pcallsthat cannot obtain a worker slot — e.g. deeply nested parallelism that would exceed the global budget — fails with:parallel_capacity_exceeded(there is no sequential fallback). - The whole parallel operation (including nested
pmap/pcalls) shares one deadline derived frompmap_timeout, clamped by the embedding run's absolute deadline when one exists; exceeding it fails with:timeout.
Note: Kernel runs derive pmap_timeout from the parallel_timeout_ms
limit (default 30,000 ms); direct library embedders configure their own
value to accommodate slow tool calls.
15.3 Compatibility Testing
Supported compatibility cases are exercised by opt-in Babashka oracle suites. They compare individual forms/functions after supplying adapters for PTC-specific context and helper vocabulary; they do not assert whole-language equivalence. Run them with:
mix test --include clojure
The suite skips when the bb executable is unavailable. Intentional
differences, unsupported features, and open gaps remain authoritative in
Clojure Conformance Gaps.
16. Memory Model for Agentic Loops
This section specifies how PTC-Lisp programs interact with persistent memory across multiple turns in an LLM-agent loop.
16.1 Core Principle: Functional Transactions
Each evaluation:
- Reads stored definitions, turn history, and
data/context - May call explicitly granted tools
- Produces an ordinary value or explicit
return/failoutcome - Stages stored-value updates only through
def/defonce/defn
The stateful Kernel host applies these changes transactionally: it commits the
native continuation only after a successful turn and rolls it back on failure.
PtcRunner.Lisp.run/2 is a lower-level, stateless API; its returned memory is a
continuation snapshot for the caller to accept or discard, not an automatic
commit.
16.2 Environment Structure
Direct callers supply the environment through PtcRunner.Lisp.run/2 options:
PtcRunner.Lisp.run(source,
memory: %{high_paid: employees, query_count: 5},
turn_history: [previous_result],
context: %{"input" => request_items, "user-id" => "user-123"},
tools: %{
"get-users" => &Host.get_users/1,
"get-orders" => &Host.get_orders/1
},
timeout: 1_000
)run/2 returns a filtered continuation snapshot but does not retain it
globally. In particular, direct runtime-callable aliases such as a binding to a
builtin or tool are omitted. Runtime callables nested in ordinary collection
data are replaced with display labels. Supported closures and composed
callables preserve embedded builtin/tool references, but Java callables become
display labels even when captured by a closure; that closure is therefore
observation-only on the public continuation path. A direct caller may thread
the snapshot into a later call for ordinary data and supported callable forms
that do not capture Java callables, but must discard it when Result.return is
{:__ptc_fail__, _}.
A Kernel owner instead uses the native continuation path, which preserves the full callable state needed by the runtime, and atomically commits continuation memory and turn history only for successful turns.
Parameterized subordinate evaluation
The shipped workflow kernel component exposes two code/value boundaries:
(kernel/eval-with
"default"
(program (return (get data/params "evidence_id")))
{"evidence_id" evidence-id})
(kernel/eval-source-with "default" generated-source
{"evidence_id" evidence-id})After the mission name, (program ...) supplies opaque static source to
eval-with, while eval-source-with accepts bounded source text. The final
argument must project to a JSON value and is available only for that mission
evaluation as data/params. It replaces any mission data already stored at
the "params" key for that evaluation; kernel/eval and kernel/eval-source
leave mission data unchanged. Referencing data/params when this evaluation
supplied none is a runtime error that names those two entry points; it is not
reported as a missing mission grant.
Use this boundary for evidence identifiers, paths, queries, and other runtime values. Building source strings from those values changes the program identity and creates an avoidable code-injection boundary. Parameter values and source text are withheld from the public effect ledger; only deterministic byte-size and SHA-256 identity metadata is retained.
Named mission selection
Kernel helpers require the mission name as their first argument:
(kernel/eval "reader" (program (return 1)))
(kernel/eval-source "reader" generated-source)
(kernel/eval-with "reader" (program (return data/params)) params)
(kernel/eval-source-with "reader" generated-source params)
(kernel/check-source "reader" generated-source)
(kernel/mission-inventory "reader")
(kernel/mission-model-context "reader")The reserved request object requires a non-null mission string.
Unknown names return a bounded protocol error listing the sorted declared
names, without dispatching an evaluation or provider call. Each mission owns
its own data, definitions, value history, source revision, frozen API, and
direct provider grants; workflows can deliberately reuse a continuation only
by selecting the same mission again. The run retains one FIFO evaluation lease,
so concurrent named requests serialize only while evaluating and preserve the
mission selected by each queued caller.
Mission-aware source checking
(kernel/check-source mission-name source) runs the production compiler against
the selected frozen mission bundle, granted tool names, and current committed
definitions, but does not execute the resulting AST. It consumes one
subordinate_source_checks reservation, not a subordinate evaluation or mission
capability call. A valid result carries the exact source byte count and SHA-256
identity. Compile errors return :invalid with a diagnostic containing kind,
a message bounded to 4,096 UTF-8 bytes, and bounded JSON-safe details.
The remaining closed outcomes are :limit_exceeded for source size, check
quota, compiler timeout/heap, deadline, or closure; :busy while an evaluation
lease is active; and :stale when the continuation commits during compilation.
Oversized source is not hashed. A valid check is advisory: the later
kernel/eval-source or kernel/eval-source-with compiles the text again and
can observe a newer continuation or budget state.
16.3 Result and Continuation Contract
At the language boundary, ordinary completion produces the value of the last
expression and storage is explicit via def. A host running a multi-turn
Kernel agent distinguishes that ordinary completion from the explicit control
forms: an ordinary value is an intermediate observation, (return value) is
successful terminal completion, and (fail value) is terminal failure.
| Behavior | How It Works |
|---|---|
| Ordinary value | Last expression result; a Kernel agent continues to another turn |
| Terminal success | (return value) yields {:__ptc_return__, value} for direct callers and explicitly completes a Kernel agent |
| Terminal failure | (fail value) yields {:__ptc_fail__, value} for direct callers, which must discard the returned continuation snapshot; a Kernel aborts and rolls back the current program |
| Persistent storage | Use (def name value) to stage a binding; persistence depends on the caller accepting the snapshot or the Kernel committing the turn |
| Access stored values | Use plain symbols (e.g., my-value) |
Returning a map does not store its keys. Use def for explicit storage.
Pure Query (No Storage)
;; Returns a number - nothing stored
(->> data/expenses
(filter (fn [e] (= (:category e) "travel")))
(sum-by :amount))Explicit Storage with def
;; Store values explicitly and produce an intermediate result
(def high-paid (->> (tool/find-employees {})
(filter (fn [e] (> (:salary e) 100000)))))
(def last-query "employees")
(map :email high-paid)After execution:
high-paid= the filtered vector (available as symbol in next turn)last-query="employees"(available as symbol in next turn)- Ordinary value =
["alice@example.com", "bob@example.com", ...]
Within one Kernel run, the successful definitions above are available to every later generated program. A later turn can complete explicitly:
(return {:query last-query :emails (map :email high-paid)})Return Map Without Storage
Maps return as-is, no special handling:
;; Returns a map - nothing stored unless you use def
{:summary "Query complete"
:count (count data/items)
:items data/items}Ordinary value = {:summary "Query complete", :count 5, :items [...]}, no symbols stored.
In a Kernel agent this is an intermediate observation unless it is wrapped in
return.
16.4 Symbol Storage Semantics
Values stored via def persist across turns. Each def sets a single key:
;; Turn 1: Store values
(def a 1)
(def b {:x 10})
"stored"
;; Turn 2: Access and update
(def b {:y 20}) ; replaces previous value
(def c 3) ; new value
{:a a, :b b, :c c}After Turn 2: a=1, b={:y 20}, c=3
- Definition-memory keys use the symbol's canonical binary spelling at the host boundary, including names that have a bounded internal atom spelling. Atom-keyed continuation entries are not alternate variable bindings.
- New symbols are added
- Existing symbols are replaced (not deep-merged)
- Symbols not referenced remain unchanged
Definition memory and exact turn history form one transactional continuation
inside a Kernel run. Each ordinary successful program appends its native result
to the oldest-to-newest history and retains only the last three entries.
*1, *2, and *3 resolve newest-first on the next program. An explicit
return commits definitions without adding its terminal value. Parse,
analysis, runtime, limit, and explicit-failure outcomes change neither memory
nor history.
The host applies separate definition-memory and history byte ceilings. It validates each history value and the complete three-value aggregate before one atomic owner commit. A history rejection therefore cannot publish candidate definitions, and a valid definition map is not charged again to the history ceiling.
16.5 Execution Flow
┌─────────────────────────────────────────────────────────────────┐
│ AGENTIC LOOP EXECUTION FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. HOST BUILDS ENVIRONMENT │
│ ├─ Load stored symbols from previous turns (def bindings) │
│ ├─ Attach current request context │
│ └─ Register available tools │
│ │
│ 2. RECEIVE PROGRAM FROM LLM │
│ └─ Parse source → AST │
│ │
│ 3. EXECUTE IN SANDBOX │
│ ├─ Validate AST │
│ ├─ Evaluate with resource limits │
│ ├─ Track def bindings (become symbols for next turn) │
│ └─ Track tool calls for logging │
│ │
│ 4. HANDLE RESULT │
│ │ │
│ ├─ ON ORDINARY SUCCESS: │
│ │ ├─ Publish last expression as an intermediate value │
│ │ ├─ Persist def bindings as symbols │
│ │ └─ Continue the agent when another turn remains │
│ │ │
│ ├─ ON EXPLICIT RETURN: │
│ │ ├─ Persist def bindings as symbols │
│ │ └─ Complete successfully with the returned value │
│ │ │
│ └─ ON FAIL OR ERROR: │
│ ├─ NO symbol changes (rollback) │
│ ├─ Log: program, error, partial trace │
│ └─ Fail or offer bounded correction under host policy │
│ │
│ 5. NEXT TURN │
│ ├─ Feed stored symbols to LLM │
│ └─ LLM generates next program │
│ │
└─────────────────────────────────────────────────────────────────┘16.6 Multi-Turn Example
Turn 1: Find high-paid employees and store with def
(def high-paid (->> (tool/find-employees {})
(filter (fn [e] (> (:salary e) 100000)))))
(count high-paid)Returns: 5
Symbols stored: {:high-paid [{:id 1, :name "Alice", :salary 150000}, ...]}
Turn 2: Query stored data (no symbol update)
(count high-paid)Returns: 5
Symbols unchanged
Turn 3: Fetch orders for stored employees, add new symbol
(def orders (let [ids (map :id high-paid)]
(tool/get-orders {:employee-ids ids})))
{:orders-count (count orders)}Returns: {:orders-count 42}
Symbols stored: {:high-paid [...], :orders [...]}
Turn 4: Return summary
(return {:employee-count (count high-paid)
:order-count (count orders)})Terminal return: {:employee-count 5, :order-count 42}
Symbols unchanged
16.7 Logging and Audit Trail
PtcRunner.Lisp.run/2 returns bounded evaluation diagnostics in
PtcRunner.Lisp.Result:
%PtcRunner.Lisp.Result{
return: value,
fail: nil,
memory: filtered_continuation_snapshot,
prints: ["..."],
tool_calls: [%{name: "get-orders", args: %{"ids" => [1, 2, 3]}, ...}],
pmap_calls: [...],
usage: %{...}
}Tool results retained in tool_calls are bounded by
max_tool_call_result_bytes; oversized entries retain a preview and truncation
metadata without changing the value delivered to the program.
A Kernel host additionally emits trace events through its configured
event/inspection sinks. Direct Lisp.run/2 does not create a persistent log by
itself; persistence and redaction are host responsibilities.
16.8 Resource Limits for Agentic Execution
Kernel hosts enforce positive time, heap, count, source, value, retained-memory, and event ceilings. The generated Kernel limits reference lists the meaning, unit, effective default, installed default, accepted range, and application scope of every limit from the canonical catalog.
Direct PtcRunner.Lisp.run/2 has its own options. Its defaults include
timeout: 1_000, max_heap: 1_250_000 words, no tool-call count limit
(max_tool_calls: nil), and a 16,384-byte retained tool-result ledger cap.
Kernel capability quotas still apply when tools are invoked through a Kernel
environment.
On limit violation:
- Execution aborts immediately
- No memory changes (transaction rollback)
- Error returned to LLM with limit details
- LLM can retry with a modified program
16.9 Error Handling in Agentic Loops
Failures retain a machine-readable reason, bounded message, and structured details:
{:error,
%PtcRunner.Lisp.Result{
return: nil,
fail: %{reason: reason, message: message, details: details},
memory: prior_memory,
prints: retained_prints,
tool_calls: retained_tool_calls
}}A Kernel host renders a bounded failure observation for the model and may offer another turn under its correction and run-limit policy. The language itself does not promise a retry.
16.10 Security Considerations
| Concern | Mitigation |
|---|---|
| Memory exhaustion | Max memory size limit |
| Infinite loops | Timeout + loop iteration limit (default 1000) |
| Unbounded recursion | Timeout + memory limit |
| Tool abuse | Explicit capability grants; Kernel total/per-name quotas; optional direct max_tool_calls |
| Data exfiltration | Tools are host-controlled, audited |
| Memory pollution | Explicit def storage only |
| Cross-turn continuation growth | Owner-scoped Kernel state plus independent definition/history ceilings |
Appendix A: Symbol Resolution
Resolution Order
When the interpreter encounters an ordinary plain symbol, it resolves in this order:
- Local bindings —
let-/loop-bound variables in current scope defbindings — values stored viadef/defn(the User Namespace; persists across turns and shadows builtins)- Built-in functions —
filter,map,count, etc.
Namespaced accesses (data/y, tool/z) are not part of this plain-symbol chain — they are dispatched by a separate AST path before plain-symbol lookup is reached.
Manifest-admitted Java direct-dot spellings are likewise host-owned syntax and
are excluded from this chain; they cannot be rebound.
Namespace Symbols
| Pattern | Resolves To |
|---|---|
data/bar | (get env.data :bar) |
tool/baz | Tool invocation |
foo | Local binding, def binding, or built-in |
Example
(let [x 10] ; x is local
(+ x ; resolves to local x (10)
data/x)) ; resolves to env.data[:x]Whole Map Access
The bare symbol data is not accessible as a whole map. Only namespaced access is allowed:
data/bar ; OK - access :bar key
data ; ERROR - cannot access whole data map
(keys data) ; ERROR - data is not a valueThis restriction prevents accidental data leakage and simplifies reasoning about what data a program can access.
Appendix B: Documentation Tests
This specification contains executable examples that are automatically validated against the PTC-Lisp implementation using PtcRunner.Lisp.SpecValidator.
Example Syntax
Examples use the pattern code ; => expected where the expected value is parsed and compared to the actual execution result:
(+ 1 2) ; => 3
(filter even? [1 2 3]) ; => [2]
{:a 1 :b 2} ; => {:a 1 :b 2}Semantic Markers
For examples that cannot be automatically validated, use these markers:
| Marker | Meaning | Example |
|---|---|---|
; => TODO: description | Feature not yet implemented | ; => TODO: :or defaults not implemented |
; => BUG: description | Known bug | ; => BUG: edge case fails |
; => ... | Illustrative example (requires external context) | ; => ... |
When to use each:
- TODO — The feature is documented but the implementation is incomplete. Running the example would fail.
- BUG — The example documents expected behavior but currently fails due to a known bug.
- ... — The example requires external context (tools, data/memory data) that isn't available during automated testing. These are illustrative examples showing usage patterns.
Running Validation
# Validate all examples
{:ok, results} = PtcRunner.Lisp.SpecValidator.validate_spec()
# Results include:
# - passed: count of passing examples
# - failed: count of failing examples
# - todos: list of {code, description, section} tuples
# - bugs: list of {code, description, section} tuples
# - skipped: count of illustrative examples (using ...)Supported Expected Values
The validator can parse these value types:
- Literals:
nil,true,false, integers (42), floats (3.14) - Strings:
"hello"(with escape sequences) - Keywords:
:name,:user-id - Collections:
[1 2 3],(1 2 3) - Maps:
{:a 1 :b 2}(simple keyword/value pairs only)