PTC-Lisp Language Specification

Copy Markdown View Source

PTC-Lisp is a bounded Clojure-like language for data processing and tool calling through explicitly granted capabilities.

Related docs:


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/fail outcome, 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

  1. LLM-friendly: Easy for language models to generate correctly
  2. Safe: Resource-limited execution with no ambient filesystem, network, or process access; external effects require granted tools
  3. Compact: Minimal syntax, high information density
  4. Verifiable: Can be validated against real Clojure for correctness
  5. Expressive: Sufficient for common data transformation tasks
  6. 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:

  1. 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") returns nil, not a NumberFormatException.
  2. Eager, not lazy. Inputs must be finite and bounded; programs run under wall-clock and memory caps.
  3. 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 (.indexOf returns -1), and bounds errors for invalid indexes (.substring raises on out-of-range; .length/.indexOf raise on non-string-like receivers). They do not preserve Java object/type distinctions PTC-Lisp intentionally does not model — e.g. Character vs one-character String, so (.length \a) returns 1 rather than raising (see DIV-40/DIV-41). Java is a compatibility heuristic, not the design owner. subs, parse-long, get follow the safer-for-sandbox signal pattern.
  4. 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 areaGAP-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 renumbered DIV-*.
  • 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/eval choices.
  • 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.

CategoryExamples
Standard Clojure syntax or core vocabularyImplicit 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 meaningdata/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 extensionsMultiple body expressions in for; :asc/:desc comparator keywords for sort and sort-by
PTC-provided helperssum, avg, the *-by helpers, unqualified rounding helpers, JSON helpers, and bounded Java compatibility namespaces
PTC control formsreturn 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]        ; equivalent

2.2 Comments

Single-line comments start with ; and extend to end of line:

; This is a comment
(+ 1 2) ; inline comment

2.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 both special-initial (for the division operator) and special-rest (for namespaced symbols like data/bar or tool/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 like inc', +'

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: :foo/bar (see DIV-13)


3. Data Types

3.1 Nil

The absence of a value:

nil

3.2 Booleans

true
false

3.3 Numbers

Integers — arbitrary-precision arithmetic (literals are limited to 100 digits):

0
42
-17
1000000

Floats — double precision:

3.14
-0.5
1.0
2.5e10
1.23e-4

Special Values (IEEE 754) — literals and namespaced constants:

LiteralConstantDescription
##InfDouble/POSITIVE_INFINITYPositive infinity
##-InfDouble/NEGATIVE_INFINITYNegative infinity
##NaNDouble/NaNNot 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"))  ; => 3

3.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:

LiteralValueDescription
\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")         ; => false

Use 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
:else

Keywords 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]]  ; nested

3.8 Maps

Key-value associations:

{}
{:name "Alice"}
{:name "Alice" :age 30}
{:user {:name "Bob" :email "bob@example.com"}}  ; nested
{"string-key" 42}  ; string keys allowed

Map 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/2 results: 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/2 results: 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 run always 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 a type_error naming the position (DIV-24).
  • json/parse-lines: line-delimited JSON helper; skips blank lines and parses each remaining line with json/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 args

For 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:

ExpressionResultDescription
({:a 1 :b 2} :a)1Keyword key lookup
({:a 1} :missing)nilMissing 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 set

Sets are unordered - iteration order is not guaranteed.

Set operations:

FunctionSignatureDescription
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:

ExpressionResultDescription
(#{1 2 3} 2)2Element found, returns it
(#{1 2 3} 4)nilNot 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:

ValueTruthy?
nilNo
falseNo
trueYes
0Yes
"" (empty string)Yes
[] (empty vector)Yes
{} (empty map)Yes
Any other valueYes
(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 let can 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))             ; => 2

Implicit 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")          ; => nil

Implicit do: As in Clojure, multiple body expressions are supported:

(when (> x 0)
  (def positive x)    ; stage a continuation binding
  (* x 2))            ; return value

5.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 do for multiple body expressions.
(when-not false "yes")            ; => "yes"
(when-not true "yes")             ; => nil
(when-not (> x 0) (log "neg"))    ; => result of log, or nil

5.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
  • :else is conventional for default (it's truthy)
  • Returns nil if 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")                           ; => nil

5.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 nil if 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 pred and expr evaluated exactly once
  • Returns nil if 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)               ; => nil

5.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-let evaluates condition-expr, binds result to name, then evaluates then-expr if truthy, otherwise else-expr
  • when-let is like if-let but returns nil instead 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 value

Limitations:

  • 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-some evaluates expr, binds result to name, then evaluates then-expr if the value is not nil, otherwise else-expr
  • when-some returns nil when the value is nil, otherwise evaluates the body
  • The key difference from if-let/when-let: false is 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)             ; => false

Implicit 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-expr once, calls seq on it
  • If the result is nil (empty or nil collection), returns nil
  • Otherwise, binds the first element to name and 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))                          ; => 20

5.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 returns nil
  • 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)                              ; => nil

5.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 ignored

Semantics:

  • 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 let then def)
  • 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 defonce

5.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 metadata

Desugars 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 call tool/ 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 vector

Multiple body expressions (implicit do):

(defn with-logging [x]
  (def last-input x)                  ; stage a continuation binding
  (* x 2))                            ; return value

Multi-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:

  • loop establishes bindings just like let.
  • recur can only appear in a tail position of a loop or fn.
  • When recur is evaluated, it re-binds the arguments and jumps back to the start of the loop or fn body.
  • 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))
; => 10

Safety 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 loops

Semantics:

  • Iterates over each element, executing the body for side effects
  • Supports multiple bindings (nested loops, cartesian product)
  • Supports destructuring in bindings
  • Supports :when, :let, :while modifiers (same as for)
  • 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
; => nil

5.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/2 succeeds and Result.return contains {:__ptc_return__, value}
  • Cannot be used inside pmap or pcalls (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/2 succeeds and Result.return contains {:__ptc_fail__, value}
  • Cannot be used inside pmap or pcalls (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))           ; => 3

6.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 argument
  • some->> threads as the last argument
  • false is NOT nil — threading continues through false values
  • With no pipeline steps — (some-> expr) — returns expr directly
(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))              ; => nil

7. 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:

ShapeExampleWhen 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"}])) ; => 1

7.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}]))              ; => 2

7.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 nil

7.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-summaries matches a :turn_summaries or "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:

  1. When looking up a field, the accessor tries the exact key type first.
  2. If not found, it falls back to the alternative type (atom ↔ string).
  3. 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.
  4. When both exist on the same map, the exact key type takes precedence.
  5. This applies to nested fields independently at each level.
  6. 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.exs and priv/java_interop.exs. This section covers semantics, edge cases, and examples.

8.1 Collection Operations

Filtering

FunctionSignatureDescription
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)      ;=> nil

Map 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

FunctionSignatureDescription
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_tasks ceiling. 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 pcalls expression 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

FunctionSignatureDescription
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 ERROR

Map 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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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")      ; => false

Sequence Generation

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
+(+ 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/NaN returns Double/NaN.
  • Division by Zero: An integer zero divisor — (/ n 0) — raises an arithmetic-error (Clojure conformance). A float zero divisor follows IEEE 754: (/ n 0.0) returns Double/POSITIVE_INFINITY (if n > 0), Double/NEGATIVE_INFINITY (if n < 0), or Double/NaN (if n = 0).
  • Indeterminate Forms: Operations like (- Double/POSITIVE_INFINITY Double/POSITIVE_INFINITY) or (* Double/POSITIVE_INFINITY 0) return Double/NaN.
  • Coercion: Converting Infinity to int raises an arithmetic-error; (int ##NaN) returns 0, 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 ERROR

Division 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.

FunctionSignatureDescription
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)          ; => false

BEAM divergence: Erlang/BEAM integers are arbitrary-precision, so the shift amount is not taken modulo 64 and bit-shift-left results can grow without bound (unlike the JVM's fixed 64-bit longs). unsigned-bit-shift-right is intentionally not provided because it has no defined meaning without a fixed integer width.

8.5 Comparison

FunctionSignatureDescription
=(= 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)                  ; => false

8.6 Logic

FunctionSignatureDescription
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)       ; => 42

identity 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

FunctionDescription
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
typeReturns 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).
describeReturns 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:

FunctionMapsStringsNotes
countReturns key count / character count
empty?True if no keys / no characters (or nil)
not-emptyReturns map/string if not empty, else nil
firstMaps: use (first (keys m)). Strings: returns first character
secondMaps: use (second (keys m)). Strings: returns second character
lastMaps: use (last (keys m)). Strings: returns last character
nthMaps: not supported. Strings: returns character at index
restStrings: returns a vector of remaining characters
butlastStrings: returns a vector of all but the last character
nextStrings: returns a vector of remaining characters or nil
takeMaps: returns a vector of [key value] pairs. Strings: returns a vector of first n characters
dropMaps: returns a vector of [key value] pairs. Strings: returns a vector of characters after dropping n
take-lastMaps: returns a vector of [key value] pairs. Strings: returns a vector of last n characters
drop-lastMaps: returns a vector of [key value] pairs. Strings: returns a vector of characters after dropping last n
take-whileMaps: iterates [key value] pairs. Strings: returns a vector of characters while predicate is true
drop-whileMaps: iterates [key value] pairs. Strings: returns a vector of characters after predicate becomes false
mapMaps: iterates over [key value] pairs. Strings: iterates over characters
mapvSame as map, returns vector
filterMaps: returns a vector of [key value] pairs. Strings: returns a vector of characters
removeMaps: returns a vector of [key value] pairs. Strings: returns a vector of characters
findMaps: associative entry lookup. Strings: not associative and signal :type_error
sortMaps: sorts [key value] entries. Strings: returns a sorted vector of characters
sort-byMaps: returns a sorted vector of [key value] pairs. Strings: a sorted vector of characters
reverseStrings: returns a reversed vector of characters
distinctMaps: use (distinct (entries m)). Strings: returns a vector of unique characters
someMaps 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
reduceMaps: iterates over [key value] pairs. Strings: iterates over characters
entriesExplicit 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

FunctionDescription
zero?Is zero?
pos?Is positive?
neg?Is negative?
even?Is even?
odd?Is odd?

Note on Special Values:

  • number? returns true for Infinity and NaN.
  • pos? returns true for Double/POSITIVE_INFINITY.
  • neg? returns true for Double/NEGATIVE_INFINITY.
  • All predicates (including zero?) return false for Double/NaN.
  • Double/NaN is not equal to itself: (= Double/NaN Double/NaN) is false.

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

FunctionDescription
parse-longParse string to integer, returns nil on failure
parse-intAlias for parse-long
parse-doubleParse string to double, returns nil on failure
parse-booleanParse "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 nil rather 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)        ; => nil

Typical usage filters recoverable failures:

(->> ["1" "2" "not-a-number" "4"]
     (map parse-long)
     (filter some?)
     (reduce + 0))  ; => 7

8.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.

FunctionSignatureDescription
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-find and re-matches return nil; re-seq returns 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

FunctionSignatureDescription
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 (fn and #() 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)  ; => nil

every-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

FunctionSignatureDescription
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 (+), returning 0.
  • Nil as last argument: (apply + 1 2 nil) returns a type-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-callable error.
  • Non-collection last argument: Raises a type_error.

8.13 Debugging with println

FunctionSignatureDescription
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 println call appends one entry to the prints list.
  • 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))
results

Evaluation 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.

SymbolSignatureNative 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.

MethodSignatureDescription
.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 class

9. Namespaces, Context, and Tools

Programs have access to data and functions through namespaced symbols and special forms.

9.1 Namespace Overview

Access PatternSourceDescription
Plain symbolsStored valuesValues defined via def/defn, persisted across turns
data/Current request contextCurrent request context (read-only)
tool/Tool invocationCall registered tools
*1, *2, *3Recent resultsPrevious 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 total

Use defonce to initialize a value before updating it:

(defonce query-count 0)
(def query-count (inc query-count))
query-count

9.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 context

Context 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 ago

Semantics:

  • *1 returns the exact native result of the most recent ordinary successful turn
  • *2 and *3 return the next two older ordinary successful results
  • Returns nil if the turn doesn't exist (e.g., *1 on 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 return commits definitions but does not advance history
  • Failed evaluations and explicit fail roll 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/name symbol 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}

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:

GroupNamespace(s)Category
Clojure compatibilityclojure.core, coreCore functions
Clojure compatibilityclojure.string, str, stringString functions
Clojure compatibilityclojure.set, setSet functions
Clojure compatibilityclojure.walk, walkTree traversal functions
Clojure compatibilityregexRegex helpers (re-find, re-pattern, etc.; underlying vars are audited as clojure.core)
Java compatibilityMathClosed Java primitive overloads for abs, ceil, floor, max, min, pow, round, and sqrt; other inventoried members remain PTC namespace helpers
Java compatibilitySystemJava System time helper
Java compatibilityBooleanBoolean parse alias
Java compatibilityDoubleDouble constants and parse alias
Java compatibilityFloatFloat parse alias (returns PTC-Lisp double/float)
Java compatibilityInteger, LongInteger parse aliases
Java compatibilityLocalDate, java.time.LocalDateJava Date parsing (ISO-8601)
Java compatibilityInstant, java.time.InstantJava Instant parsing (ISO-8601)
Java compatibilityDuration, java.time.DurationJava Duration between helper
Java compatibilityjava.util.Date.Java Date constructors
PTC runtime/helperdataContext access
PTC runtime/helpertoolRegistered tool invocation
PTC runtime/helperjsonJSON 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 Instant

The 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, walk

Note: 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:

  1. Component compilation parses the signature/type string and rejects invalid syntax, duplicate parameter names, normalized shaped-map field collisions, and invalid constant values.
  2. Compilation checks that a function signature has exactly the declared function arity.
  3. 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.

FormResult
(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

OperationEmpty InputResult
(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 ERROR

Rule: 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 called

11.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.reasonCause
:parse_errorInvalid syntax
:invalid_formMalformed program structure (static analysis phase)
:invalid_arityWrong number of arguments to a special form (detected during analysis)
:type_errorWrong argument type
:arithmetic_errorArithmetic operation error (e.g. integer division by zero)
:arity_error / :arity_mismatchWrong number of arguments / closure arity mismatch (detected at runtime)
:unbound_varUnknown symbol/variable
:not_callableAttempt to call a non-callable value
:runtime_errorGeneral runtime evaluation error
:loop_limit_exceededloop/recur iteration limit exceeded
:unknown_toolTool not registered
:tool_errorTool execution failed
:destructure_errorDestructuring pattern mismatch
:invalid_placeholderInvalid placeholder in #() syntax
:unsupported_patternUnsupported destructuring/binding pattern
:unsupported_methodUnknown Java-interop method
:invalid_keywordA supplied or returned keyword wrapper is malformed or its name is outside the reader's keyword grammar
:invalid_lisp_listA supplied public value contains an improper host list, which cannot represent a PTC-Lisp vector
:invalid_symbol_refA supplied or returned symbol-reference wrapper is malformed or its name is outside the reader's symbol grammar
:symbol_ref_collisionDistinct public/native symbol-reference values collapse to the same map key or set member
:timeoutSetup or execution time exceeded (fail.details.phase identifies the phase)
:memory_exceededMemory 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

ErrorHint
Unknown symbol fooDid you mean: filter, first, find?
Wrong arity for ifexpected (if cond then else?)
let bindings not pairedlet 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 value

Short #() 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 vector

The #() 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 let bindings 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 fn for 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/bar is invalid; see DIV-13)
  • The operator position in list-expr accepts 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:

  1. nil, true, false → reserved literals (not symbols)
  2. -123, 3.14 → numbers (not symbols starting with - or digits)
  3. :foo → keyword
  4. \a, \newline → character literal
  5. 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
  • def stages continuation changes; println records bounded diagnostics
  • Granted tools are the only general external-effect boundary
  • System/currentTimeMillis and zero-argument (java.util.Date.) explicitly read the system clock

15.2 Resource Limits

ResourceDefaultNotes
Timeout1,000 msExecution time limit
Max Heap~10 MBSandbox-process memory limit (1,250,000 words)
Worker Max Heap= Max HeapFixed per-worker pmap/pcalls heap cap
Max Parallel Workers8Global cap on live pmap/pcalls workers
Max Tool Callsunlimited (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 Iterations1,000Per-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/pcalls that 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 from pmap_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/fail outcome
  • 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.

BehaviorHow It Works
Ordinary valueLast 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 storageUse (def name value) to stage a binding; persistence depends on the caller accepting the snapshot or the Kernel committing the turn
Access stored valuesUse 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

ConcernMitigation
Memory exhaustionMax memory size limit
Infinite loopsTimeout + loop iteration limit (default 1000)
Unbounded recursionTimeout + memory limit
Tool abuseExplicit capability grants; Kernel total/per-name quotas; optional direct max_tool_calls
Data exfiltrationTools are host-controlled, audited
Memory pollutionExplicit def storage only
Cross-turn continuation growthOwner-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:

  1. Local bindingslet-/loop-bound variables in current scope
  2. def bindings — values stored via def/defn (the User Namespace; persists across turns and shadows builtins)
  3. Built-in functionsfilter, 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

PatternResolves To
data/bar(get env.data :bar)
tool/bazTool invocation
fooLocal 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 value

This 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:

MarkerMeaningExample
; => TODO: descriptionFeature not yet implemented; => TODO: :or defaults not implemented
; => BUG: descriptionKnown 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)