Clojure Conformance Gaps

Copy Markdown View Source

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

LevelMeaning
P0Breaks idiomatic Clojure patterns; likely to cause silent bugs in LLM-generated code
P1Missing feature that limits expressiveness; workarounds exist
P2Edge 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

FieldValue
PriorityP0
Statusfixed
SourceSCI 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

FieldValue
PriorityP1
Statusfixed
SourceSCI 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

FieldValue
PriorityP1
Statusfixed
SourceSCI 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

FieldValue
PriorityP1
Statusfixed
SourceSCI 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

FieldValue
PriorityP2
Statusfixed
SourceSCI 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

FieldValue
PriorityP1
Statusfixed
SourceSCI 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

FieldValue
PriorityP2
Statusfixed
SourceSCI 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

FieldValue
PriorityP2
Statusfixed
SourceSCI 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

FieldValue
PriorityP2
Statusfixed
SourceSCI destructure-test line 139
;; Clojure
((fn [{:strs [a]}] a) {"a" 1})   ;=> 1

;; PTC-Lisp
((fn [{:strs [a]}] a) {"a" 1})   ;=> 1

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

FieldValue
PriorityP1
Statusfixed
SourceSCI 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

FieldValue
PriorityP2
Statusfixed
SourceSCI 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)   ;=> 2

Fix: 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]}]

FieldValue
PriorityP2
Statusfixed
SourceSCI 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

FieldValue
Priorityn/a
Statusby design
SourceSCI 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design

No defmacro, macroexpand, eval, read-string. LLM safety boundary.

DIV-05: No mutable state

FieldValue
Priorityn/a
Statusby design

No atom, ref, agent, swap!, reset!. Pure functional only.

DIV-06: Silent deduplication of computed duplicate keys in map/set literals

FieldValue
Priorityn/a
Statusby design
SourceSCI 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design

No slurp, spit, or any filesystem access.

Rationale: Security. All data must flow through the tool/context API.

DIV-10: No exception handling

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design

No defmulti, defmethod, defprotocol, defrecord.

Rationale: Complexity. Not needed for data transformation pipelines.

DIV-12: No transducers

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design
;; Clojure
(defn f ([x] x) ([x y] (+ x y)))

;; PTC-Lisp: not supported — use separate defn forms or rest args

Rationale: Simplicity. Rest args and separate functions cover most cases.

DIV-16: No pre/post conditions in defn

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design
;; Clojure: also disallows this
#(map #(+ % 1) %&)   ;=> error in both Clojure and PTC-Lisp

Rationale: Matches Clojure. Ambiguous which % refers to which scope.

DIV-18: parse-long/parse-double/parse-boolean return nil for non-string input

FieldValue
Priorityn/a
Statusby design
SourceSpec §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)   ;=> nil

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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby 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

FieldValue
Priorityn/a
Statusby design
SourceIssue #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

FieldValue
Priorityn/a
Statusby design
SourcePtcRunner 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

FieldValue
Priorityn/a
Statusby design
SourcePtcRunner 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

FieldValue
PriorityP2
Statusfixed (intentional divergence)
SourceSpec §8.8
;; Clojure
(even? 4.0)   ;=> IllegalArgumentException

;; PTC-Lisp
(even? 4.0)   ;=> true
(even? 4.5)   ;=> false

Clojure 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

FieldValue
Priorityn/a
Statusby 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)) ;=> true

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

  1. Classify it: Semantics (S), Special Form (F), Core Function (C), or Intentional Divergence (DIV)
  2. Assign the next number in that category (e.g., GAP-S04)
  3. Set priority: P0 if it causes silent wrong results, P1 if it errors where Clojure succeeds, P2 if edge case
  4. Include a minimal reproducer with both Clojure and PTC-Lisp output
  5. Note the source (SCI test name + line, Joker test, manual, etc.)
  6. 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