# Clojure Conformance Gaps

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`
**Related issue:** [#832](https://github.com/andreasronge/ptc_runner/issues/832)
**Design policy:** see the *Design Philosophy* section in [`docs/ptc-lisp-specification.md`](ptc-lisp-specification.md)
**Audit (function coverage):** [`docs/conformance/clojure-core-audit.md`](conformance/clojure-core-audit.md)
**Function reference:** [`docs/function-reference.md`](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
;; 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

| 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
;; 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]}]`

| 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
;; 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
;; 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
;; 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
;; 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`

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

| Field | Value |
|-------|-------|
| **Priority** | n/a |
| **Status** | by design |
| **Source** | Spec §8.9 |

```clojure
;; 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
```

**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
;; 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
;; 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
;; 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
;; 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 |

```clojure
;; 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 |

```clojure
;; 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
;; 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`

| Field | Value |
|-------|-------|
| **Priority** | n/a |
| **Status** | by design |

```clojure
;; 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](ptc-lisp-specification.md) — state why Clojure conformance loses to sandbox safety, bounded execution, or recoverable signal values
