Tracked differences between PTC-Lisp and Clojure semantics, discovered via conformance testing against SCI, Babashka, Joker, and manual investigation.
Test file: test/ptc_runner/lisp/sci_conformance_test.exs
Design policy: see the Design Philosophy section in docs/ptc-lisp-specification.md
Function reference: docs/function-reference.md
Priority Levels
| Level | Meaning |
|---|---|
| P0 | Breaks idiomatic Clojure patterns; likely to cause silent bugs in LLM-generated code |
| P1 | Missing feature that limits expressiveness; workarounds exist |
| P2 | Edge case or minor divergence; rarely encountered in practice |
1. Semantics — Supported features with incorrect behavior
Features marked ✅ in the audit but whose behavior diverges from Clojure.
GAP-S01: and/or return boolean instead of actual value
| Field | Value |
|---|---|
| Priority | P0 |
| Status | fixed |
| Source | SCI core-test line 81, do-and-or-test line 1812 |
Clojure behavior: and returns the last truthy value or the first falsey value. or returns the first truthy value or the last falsey value.
Fix: do_eval_and now tracks the last evaluated truthy value and returns it when the expression list is exhausted, matching Clojure semantics. or was already correct.
GAP-S02: #() wrapping a defn call returns closure instead of invoking
| Field | Value |
|---|---|
| Priority | P1 |
| Status | fixed |
| Source | SCI core-test line 92 |
Fix: #(foo) short fn desugaring now wraps a single symbol as a function call (fn [] (foo)) instead of a variable reference.
GAP-S03: defn inside let not visible across program expressions
| Field | Value |
|---|---|
| Priority | P1 |
| Status | fixed |
| Source | SCI closure-test line 185 |
Fix: Static analysis (collect_undefined_vars) now uses extract_def_names/1 to find def/defonce names inside definite-execution contexts (let, do, loop), propagating them to subsequent program expressions. Runtime eval already handled this correctly.
2. Special Forms — Missing or broken language constructs
GAP-F01: Named fn not supported
| Field | Value |
|---|---|
| Priority | P1 |
| Status | fixed |
| Source | SCI fn-test line 199 |
Fix: Added named fn support: (fn name [params] body) stores the name in closure metadata, and do_execute_closure binds the closure to its name at call time for self-recursion. Variants with rest args and destructuring also work.
GAP-F02: Destructuring inside rest args
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI fn-test line 206 |
Fix: Already working — rest args with vector destructuring ([& [y]]) are handled correctly by the existing variadic binding + pattern matching logic.
3. Core Functions — Missing functions
Functions listed as 🔲 candidate in the audit that showed up in conformance testing.
GAP-C01: int? predicate not implemented
| Field | Value |
|---|---|
| Priority | P1 |
| Status | fixed |
| Source | SCI cond-test line 832; audit lists as candidate |
| Audit status | 🔲 candidate |
Fix: Added int? predicate delegating to is_integer/1. Still missing from the same family: nat-int?, neg-int?, pos-int? (all 🔲 candidate in audit).
GAP-C02: comment form not supported
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI comment-test line 632 |
Fix: Added comment as a special form in the analyzer that returns nil without evaluating its arguments.
GAP-C03: %& rest args in anonymous function shorthand
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI fn-literal-test line 196 |
Fix: Updated placeholder? to recognize %&, and extended the short fn desugarer (determine_arity, generate_params, placeholder_to_param) to produce variadic (fn [p1 & rest] ...) forms when %& is present.
GAP-C04: :strs map destructuring
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI destructure-test line 139 |
;; Clojure
((fn [{:strs [a]}] a) {"a" 1}) ;=> 1
;; PTC-Lisp
((fn [{:strs [a]}] a) {"a" 1}) ;=> 1Fix: Added :strs as a parallel pattern type to :keys across analyzer, pattern matcher, scope analysis, and formatter. :strs converts key atoms to strings before lookup via flex_fetch.
Additional semantics gaps
GAP-S04: assoc with many key-value pairs
| Field | Value |
|---|---|
| Priority | P1 |
| Status | fixed |
| Source | SCI more-than-twenty-args-test line 1596 |
Fix: assoc_variadic already handled many pairs correctly. The conformance test comparison failed because Babashka output goes through JSON (string keys) while PTC-Lisp uses integer keys. Fixed normalize_value in ClojureValidator to normalize integer map keys to strings.
GAP-S05: Moved to DIV-06 (intentional divergence)
GAP-S06: Parameter named fn shadows builtin incorrectly
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI variable-can-have-macro-or-var-name line 904 |
;; Clojure
(defn foo [fn] (fn 1)) (foo inc) ;=> 2
;; PTC-Lisp (fixed)
(defn foo [fn] (fn 1)) (foo inc) ;=> 2Fix: The analyzer pre-marks shadowed special form names in RawAST before analysis. When fn/defn/let/loop bindings introduce a name matching a shadowable form (Clojure macros like fn, let, when, cond), occurrences in call position are rewritten to {:shadowed_local, name}, treated as a plain variable reference. True special forms (if, def, recur, do) remain unshadowable, matching Clojure.
GAP-S07: Keyword args via rest destructuring [& {:keys [a]}]
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed |
| Source | SCI defn-kwargs-test line 303 |
Fix: Added coercion in bind_args/2 that converts rest args from a flat key-value list to a map when the rest pattern is a map destructuring form ({:keys ...} or {:map ...}).
Intentional Divergences — By design, not bugs
Documented differences where PTC-Lisp intentionally departs from Clojure for sandbox safety or simplicity.
DIV-01: Loop/recursion iteration limit
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | SCI recur-test line 667 |
PTC-Lisp enforces a default limit of 1,000 iterations (configurable up to 10,000) on loop/recur and recursive function calls. Clojure has no such limit.
;; Clojure: succeeds
(defn hello [x] (if (< x 10000) (recur (inc x)) x)) (hello 0) ;=> 10000
;; PTC-Lisp: loop_limit_exceeded (default limit 1000)Rationale: Sandbox safety. LLM-generated code must terminate within bounded time/memory. See lib/ptc_runner/lisp/eval/context.ex.
DIV-02: No lazy sequences
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
All collection operations are eager. (range) without arguments is not supported; bounds must be specified. No lazy-seq, iterate, or infinite sequences.
Rationale: Sandbox safety and simplicity. All operations must complete within timeout.
DIV-04: No macros, eval, or metaprogramming
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No defmacro, macroexpand, eval, read-string. LLM safety boundary.
DIV-05: No mutable state
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No atom, ref, agent, swap!, reset!. Pure functional only.
DIV-06: Silent deduplication of computed duplicate keys in map/set literals
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | SCI core-test line 114-116 |
;; Clojure: throws "Duplicate key: 1"
(let [a 1 b 1] #{a b})
;; PTC-Lisp: silently creates #{1} (no error)Clojure detects duplicate computed keys at runtime and throws an error. PTC-Lisp silently deduplicates. Without exception handling (try/catch), a duplicate-key error would crash the entire program with no recovery path. Silent deduplication is more resilient for LLM-generated sandboxed code.
DIV-07: No user-defined namespaces
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No user-defined namespaces or modules. All definitions live in a single flat namespace.
Rationale: Simplicity. Single-file programs don't need module systems.
DIV-08: No full Java interop
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No general Java/host interop. A minimal Date/Time subset is supported (see spec §8.13).
Rationale: Security. Arbitrary host access would break the sandbox.
DIV-09: No file I/O
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No slurp, spit, or any filesystem access.
Rationale: Security. All data must flow through the tool/context API.
DIV-10: No exception handling
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No try, catch, throw. Use (fail reason) for error signaling.
Rationale: Simplicity and safety. Exception handling adds complexity; fail provides a single, predictable error path.
DIV-11: No multi-methods or protocols
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No defmulti, defmethod, defprotocol, defrecord.
Rationale: Complexity. Not needed for data transformation pipelines.
DIV-12: No transducers
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
Transducers are not supported. comp, partial, complement, constantly, every-pred, and some-fn are now supported (see §8.10).
Rationale: Transducers add significant complexity. Threading macros (->, ->>) and the supported combinators cover most composition needs.
DIV-13: Namespaced keywords not supported
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
:foo/bar style namespaced keywords are not supported. Only simple keywords like :name, :user-id.
Rationale: Simplicity. No user-defined namespaces means namespace-qualified keywords have no use.
DIV-14: if-let/when-let only support single symbol bindings
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure: supports destructuring
(if-let [{:keys [a]} (get-map)] a nil)
;; PTC-Lisp: only single symbol
(if-let [x (get-map)] (:a x) nil)Rationale: Simplicity. Destructuring in let covers this need.
DIV-15: No multi-arity defn
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure
(defn f ([x] x) ([x y] (+ x y)))
;; PTC-Lisp: not supported — use separate defn forms or rest argsRationale: Simplicity. Rest args and separate functions cover most cases.
DIV-16: No pre/post conditions in defn
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
No :pre/:post condition maps in defn. Without exception handling, assertion failures would crash the program.
Rationale: No exception handling (DIV-10) makes pre/post conditions dangerous in sandboxed code.
DIV-17: Nested #() not allowed
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure: also disallows this
#(map #(+ % 1) %&) ;=> error in both Clojure and PTC-LispRationale: Matches Clojure. Ambiguous which % refers to which scope.
DIV-18: parse-long/parse-double/parse-boolean return nil for non-string input
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | Spec §8.9 |
;; Clojure 1.11+
(parse-long 42) ;=> IllegalArgumentException
(parse-double 42) ;=> IllegalArgumentException
(parse-boolean 42) ;=> IllegalArgumentException
;; PTC-Lisp
(parse-long 42) ;=> nil
(parse-double 42) ;=> nil
(parse-boolean 42) ;=> nilJava-shaped parse aliases (Integer/parseInt, Long/parseLong,
Double/parseDouble, Float/parseFloat, Boolean/parseBoolean) intentionally
share the same safe PTC-Lisp semantics: invalid or non-string input returns
nil instead of raising. They are compatibility spellings for LLM-generated
code, not full Java throwing semantics.
Rationale: No exception handling (DIV-10). Returning nil is safer for LLM-generated code.
DIV-19: symbol? always returns false
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure
(symbol? 'foo) ;=> true
;; PTC-Lisp
(symbol? :foo) ;=> false (always false)PTC-Lisp uses keywords where Clojure uses symbols. There is no symbol type.
Rationale: Simplicity. Keywords cover all identifier needs in data transformation pipelines.
DIV-20: decimal? and ratio? always return false
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure
(decimal? 1.0M) ;=> true
(ratio? 1/3) ;=> true
(rational? 1/3) ;=> true
;; PTC-Lisp
(decimal? 1.0) ;=> false (always false)
(ratio? 1) ;=> false (always false)
(rational? 42) ;=> true (integers only, no ratios on BEAM)BEAM has no BigDecimal or ratio types. rational? returns true only for integers (the only BEAM rationals).
Rationale: Platform difference. BEAM number types are integers and floats only.
DIV-21: format renders nil as empty string
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure
(format "%s" nil) ;=> "null"
;; PTC-Lisp
(format "%s" nil) ;=> "" (empty string)PTC-Lisp's str converts nil to "" (not "nil" or "null"), and format %s follows the same convention for consistency.
Rationale: Consistency with (str nil) → "", which is already an established PTC-Lisp convention.
DIV-22: subs returns signal values instead of raising on out-of-range indices
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | Issue #886, follow-up to codex review of c45bdbc |
;; Clojure
(subs "abcdef" -1) ;=> StringIndexOutOfBoundsException
(subs "abc" 10) ;=> StringIndexOutOfBoundsException
(let [s "abcdef"] (subs s (.indexOf s "xyz"))) ;=> StringIndexOutOfBoundsException
;; PTC-Lisp
(subs "abcdef" -1) ;=> ""
(subs "abc" 10) ;=> ""
(let [s "abcdef"] (subs s (.indexOf s "xyz"))) ;=> "" (the canonical idiom, clean signal)
(subs "abc" 1 10) ;=> "bc" (end > length truncates)
(subs "abc" 0 100) ;=> "abc" ("first N chars" idiom preserved)Rationale: No exception handling (DIV-10). Clojure's subs raises on out-of-range, but in PTC-Lisp raising means the program crashes with no recovery path. We return signal values (empty string) so callers can guard with (when (seq result) ...).
The negative-start rule specifically kills the (.indexOf s needle) → -1 → subs trap, where .indexOf misses and feeds -1 into subs. Pre-fix, subs clamped -1 to 0 and silently returned the whole string — wrong-but-plausible output that propagated downstream. Post-fix, the negative start short-circuits to "".
Asymmetry with .substring is principled: Java-named methods (.substring, .indexOf, .length) follow Java semantics and raise on out-of-range (see a44b75c for the .substring fix). The dot-prefix signals "Java idiom expected." Clojure-named functions (subs, parse-long, get) follow the safer-for-sandbox pattern. The naming convention tells the LLM which contract applies.
DIV-23: json/parse-string returns nil on invalid input
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | PtcRunner JSON support design decision |
;; Cheshire / Jason.decode!
(cheshire.core/parse-string "not json") ;=> JsonParseException
(cheshire.core/parse-string nil) ;=> NullPointerException
;; PTC-Lisp
(json/parse-string "not json") ;=> nil
(json/parse-string nil) ;=> nil
(json/parse-string 42) ;=> nil (non-binary)
(json/parse-string "null") ;=> nil (real JSON null — collides with parse-error signal; see OQ-1)Rationale: No exception handling in the sandbox (DIV-10) means raising = unrecoverable program crash. json/parse-string returns nil on any failure (invalid JSON, nil input, non-binary input) so callers can guard with (when result ...) or thread through (some->). Map keys are decoded as strings (not atoms) to match PTC-Lisp's tool-boundary convention and avoid atom memory leaks on untrusted input.
The nil return for both real JSON null and parse failure is a known ambiguity (OQ-1 in the plan). Programs that need to distinguish should guard on (empty? s) / shape before calling. MCP aggregator calls use a separate tagged tool/mcp-call result where :ok distinguishes success from failure.
DIV-24: json/generate-string returns nil on non-encodable input
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
| Source | PtcRunner JSON support design decision |
;; Vanilla Jason.encode/1 silently coerces non-boolean atoms to JSON strings:
;; Jason.encode!(:fs) ;=> "\"fs\"" (lossy auto-stringification)
;; Jason.encode!(%{a: 1}) ;=> "{\"a\":1}" (atom key silently stringified)
;; PTC-Lisp deliberately rejects them up-front, returning nil:
(json/generate-string :fs) ;=> nil (non-boolean atom value)
(json/generate-string {:server "fs"}) ;=> nil (atom key)
(json/generate-string {"server" :fs}) ;=> nil (atom value)
(json/generate-string {1 "a"}) ;=> "{\"1\":\"a\"}" (integer keys allowed; carve-out, no round-trip)
(json/generate-string POSITIVE_INFINITY) ;=> nil (special-float carve-out)
(json/generate-string {:tuple [{:ok 1}]}) ;=> nil (any tuple, anywhere)
;; Programs that want strings on the wire convert explicitly:
(json/generate-string {"server" (name :fs)})
;=> "{\"server\":\"fs\"}"Rationale: Silently auto-stringifying keywords would erode PTC-Lisp's type signal at the wire boundary. The implementation runs a pre-validation walk (encodable_value? / encodable_key?) over the value tree before invoking Jason.encode/1 — any non-boolean atom, atom-keyed map entry, tuple, PID, reference, or function short-circuits to nil. Special floats (POSITIVE_INFINITY, NEGATIVE_INFINITY, NaN — which resolve to atoms :infinity / :negative_infinity / :nan) are also rejected because they aren't valid JSON scalars.
Map-key validation is stricter than value validation: JSON only accepts string keys. Once stringified, atom and float keys preserve no type signal across a round-trip and would break the §4.3 round-trip property, so they are rejected at the key position even when acceptable as values. Integer keys are allowed (Jason's default stringifies them) but do not round-trip — {1 "a"} parses back as %{"1" => "a"}.
The asymmetry with parse-string (returns nil on bad input) is the same DIV-* signal-value pattern: failures are observable as nil and the caller decides how to react.
GAP-S08: even?/odd? handle floats gracefully
| Field | Value |
|---|---|
| Priority | P2 |
| Status | fixed (intentional divergence) |
| Source | Spec §8.8 |
;; Clojure
(even? 4.0) ;=> IllegalArgumentException
;; PTC-Lisp
(even? 4.0) ;=> true
(even? 4.5) ;=> falseClojure throws on float arguments. PTC-Lisp accepts whole-number floats (returns true/false) and returns false for non-whole floats, consistent with the no-exceptions design (DIV-10). Previously PTC-Lisp crashed with an arithmetic error on any float input.
Fix: Changed even?/odd? to truncate whole-number floats before rem, and return false for non-whole floats and non-numbers.
DIV-25: list is an alias for vector
| Field | Value |
|---|---|
| Priority | n/a |
| Status | by design |
;; Clojure
(list 1 2 3) ;=> (1 2 3) ; a persistent list
(list? (list 1)) ;=> true
;; PTC-Lisp
(list 1 2 3) ;=> [1 2 3] ; a vector
(vector? (list 1)) ;=> truePTC-Lisp has no separate list type — it is vector-first. list is provided
because LLMs reach for it out of Clojure training data; it returns a vector so
downstream code behaves uniformly. list? and list* are not provided.
Rationale: Eliminates a common LLM error class (list/cons reflexes) at
near-zero cost, without introducing a second sequential collection type.
Adding New Gaps
When conformance testing reveals a new gap:
- Classify it: Semantics (S), Special Form (F), Core Function (C), or Intentional Divergence (DIV)
- Assign the next number in that category (e.g., GAP-S04)
- Set priority: P0 if it causes silent wrong results, P1 if it errors where Clojure succeeds, P2 if edge case
- Include a minimal reproducer with both Clojure and PTC-Lisp output
- Note the source (SCI test name + line, Joker test, manual, etc.)
- For
DIV-*entries, apply the design policy from the PTC-Lisp Specification — state why Clojure conformance loses to sandbox safety, bounded execution, or recoverable signal values