⏺ Clean build with no warnings. Here's a summary of what was completed: #2

Merged
navicore merged 17 commits from phase-5 into main 2026-03-06 01:01:21 +00:00
navicore commented 2026-03-02 04:52:58 +00:00 (Migrated from github.com)

Phase 5 — Complete

All 203 tests pass (132 unit + 71 integration), zero warnings.

Implementation (from previous session)

All Phase 5 builtins were implemented across builtins.rs, solver.rs, tokenizer.rs, and parser.rs:

  • I/O: write/1, writeln/1, nl/0
  • Term ordering: compare/3, @</2, @>/2, @=</2, @>=/2
  • Term introspection: functor/3, arg/3, =../2
  • Enumeration: between/3
  • Copying: copy_term/2
  • Peano arithmetic: succ/2, plus/3
  • Sorting: msort/2, sort/2
  • Number conversion: number_chars/2, number_codes/2

Bugs found and fixed this session

  1. collect_list deep resolution — Changed walk() to apply() for list arguments everywhere collect_list is called, so variables inside lists are properly resolved. This fixed =.. usage in rules with [F|Args] patterns.
  2. between/3 in findall — Added explicit enumeration loop in try_solve_collecting since try_exec_misc only returned the first value.
  3. number_chars/2 reverse — Enhanced to also accept single-digit integers in the char list (since forward direction produces atoms that display identically to integers).

Tests added (38 new integration tests)

Covering: compare, term ordering operators, functor (decompose/construct/number), arg, =.. (decompose/construct/in-rule), between
(basic/single/empty/in-rule/with-findall), copy_term, succ, plus, msort, sort, number_chars, number_codes, and combined scenarios like sort-in-rule and
functor-in-rule.

Phase 5 — Complete All 203 tests pass (132 unit + 71 integration), zero warnings. Implementation (from previous session) All Phase 5 builtins were implemented across builtins.rs, solver.rs, tokenizer.rs, and parser.rs: - I/O: write/1, writeln/1, nl/0 - Term ordering: compare/3, @</2, @>/2, @=</2, @>=/2 - Term introspection: functor/3, arg/3, =../2 - Enumeration: between/3 - Copying: copy_term/2 - Peano arithmetic: succ/2, plus/3 - Sorting: msort/2, sort/2 - Number conversion: number_chars/2, number_codes/2 Bugs found and fixed this session 1. collect_list deep resolution — Changed walk() to apply() for list arguments everywhere collect_list is called, so variables inside lists are properly resolved. This fixed =.. usage in rules with [F|Args] patterns. 2. between/3 in findall — Added explicit enumeration loop in try_solve_collecting since try_exec_misc only returned the first value. 3. number_chars/2 reverse — Enhanced to also accept single-digit integers in the char list (since forward direction produces atoms that display identically to integers). Tests added (38 new integration tests) Covering: compare, term ordering operators, functor (decompose/construct/number), arg, =.. (decompose/construct/in-rule), between (basic/single/empty/in-rule/with-findall), copy_term, succ, plus, msort, sort, number_chars, number_codes, and combined scenarios like sort-in-rule and functor-in-rule.
claude[bot] commented 2026-03-02 05:06:35 +00:00 (Migrated from github.com)

patch-prolog Completeness & Usability Plan

Context

patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles .pl rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete.


Phase 1: Critical Built-ins — COMPLETE

  • 1a. Type-checking predicates in builtins.rs

    • var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
  • 1b. List predicates as Prolog rules in knowledge/stdlib.pl

    • member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3
  • 1c. Solution collection: findall/3

    • Invokes solver internally, collects all solutions, builds result list
    • Handles conjunction, disjunction, negation within findall goals
  • 1d. If-then-else and disjunction

    • Tokenizer: -> (Arrow) and ; (Semicolon) tokens
    • Parser: parse_paren_body() handles (Cond -> Then ; Else) and (A ; B)
    • Solver: BuiltinResult variants — Disjunction, IfThenElse, IfThen, Conjunction
    • Disjunction choice points on backtrack stack

Phase 2: Robustness & Safety — COMPLETE

  • 2a. Recursion depth limit

    • max_depth field on Solver (default 10,000)
    • with_max_depth() builder method
    • Returns SolveResult::Error instead of stack overflow
  • 2b. Integer overflow protection

    • checked_add, checked_sub, checked_mul, checked_div, checked_neg
    • Returns arithmetic error on overflow
  • 2c. Float NaN/Infinity detection

    • check_float() validates every float operation result
    • Returns error for NaN or Infinity

Phase 3: Usability — COMPLETE

3a. once/1 and call/1

  • once/1 — solve goal, take first solution, cut (uses try_solve_once + choice stack truncation)
  • call/1 — execute a term as a goal (basic meta-call)
  • Files: builtins.rs, solver.rs

3b. Atom/string predicates

  • atom_length/2
  • atom_concat/3
  • atom_chars/2
  • Files: builtins.rs (BuiltinResult variants), solver.rs (execution with mutable interner)

3c. Additional arithmetic functions

Added to is/2 evaluation:

  • abs/1
  • max/2
  • min/2
  • sign/1
  • File: builtins.rs

Phase 4: Testing — COMPLETE

  • 4a. Integration tests (tests/integration.rs)

    • Full pipeline tests (family relationships, factorial, list ops)
    • Type-checking, if-then-else, disjunction, findall tests
    • Error case tests (depth limit, overflow, div-by-zero, unbound vars)
    • Edge case tests (empty KB, no matching predicate, parse errors, ground queries)
  • 4b. Edge case tests

    • Deeply recursive rules (verify depth limit works)
    • Integer overflow in arithmetic
    • Empty knowledge base with queries
    • Division by zero
    • Unbound variable in arithmetic
  • 4c. Stdlib tests

    • List predicates tested end-to-end in integration tests

Test counts

  • 132 unit tests in prolog-core
  • 71 integration tests in tests/integration.rs
  • 203 total

Phase 5: Nice-to-have — COMPLETE

  • write/1, writeln/1, nl/0 — I/O for debugging rules
  • compare/3, @</2, @>/2, @=</2, @>=/2 — term ordering (ISO standard order)
  • functor/3, arg/3, =../2 — term introspection/decomposition
  • between/3 — integer enumeration (with backtracking, works inside findall)
  • copy_term/2 — term copying with fresh variables
  • succ/2, plus/3 — Peano arithmetic (bidirectional)
  • msort/2, sort/2 — list sorting (sort removes duplicates)
  • number_chars/2, number_codes/2 — number/string conversion (bidirectional)
  • assert/1, retract/1 — dynamic predicates (future)
  • REPL mode (future)

Architecture Notes

  • Workspace: root patch-prolog binary + crates/prolog-core library
  • Build-time compilation: build.rs compiles knowledge/*.pl into binary via bincode
  • Core modules: term, tokenizer, parser, unify, builtins, solver, database, index
  • [] and ! always interned: CompiledDatabase::new() ensures this (required for findall, list ops, once/1)
  • Solver runtime interner: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars)
  • BuiltinResult enum: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants
  • Disjunction choice points: use disjunction: bool flag on ChoicePoint to distinguish from clause alternatives
  • Parenthesized expressions: parse_paren_body() handles ;, ->, and , as control flow operators

Current Built-in Predicates (~55 total)

Category Predicates
Core true, fail, false, !, =, \=, is, <, >, =<, >=, =:=, =\=, \+
Type checking var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
Control flow ;/2 (disjunction), ->/2 (if-then), ,/2 (conjunction)
Meta-call once/1, call/1
Collection findall/3
Atom/string atom_length/2, atom_concat/3, atom_chars/2
Arithmetic ops +, -, *, /, mod, unary -, abs/1, max/2, min/2, sign/1
I/O write/1, writeln/1, nl/0
Term ordering compare/3, @</2, @>/2, @=</2, @>=/2
Term introspection functor/3, arg/3, =../2
Enumeration between/3
Copying copy_term/2
Peano arithmetic succ/2, plus/3
Sorting msort/2, sort/2
Number conversion number_chars/2, number_codes/2

Stdlib (knowledge/stdlib.pl)

member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3

# patch-prolog Completeness & Usability Plan ## Context patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles `.pl` rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete. --- ## Phase 1: Critical Built-ins — COMPLETE - [x] **1a. Type-checking predicates** in `builtins.rs` - [x] `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` - [x] **1b. List predicates** as Prolog rules in `knowledge/stdlib.pl` - [x] `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3` - [x] **1c. Solution collection: `findall/3`** - [x] Invokes solver internally, collects all solutions, builds result list - [x] Handles conjunction, disjunction, negation within findall goals - [x] **1d. If-then-else and disjunction** - [x] Tokenizer: `->` (Arrow) and `;` (Semicolon) tokens - [x] Parser: `parse_paren_body()` handles `(Cond -> Then ; Else)` and `(A ; B)` - [x] Solver: `BuiltinResult` variants — Disjunction, IfThenElse, IfThen, Conjunction - [x] Disjunction choice points on backtrack stack --- ## Phase 2: Robustness & Safety — COMPLETE - [x] **2a. Recursion depth limit** - [x] `max_depth` field on Solver (default 10,000) - [x] `with_max_depth()` builder method - [x] Returns `SolveResult::Error` instead of stack overflow - [x] **2b. Integer overflow protection** - [x] `checked_add`, `checked_sub`, `checked_mul`, `checked_div`, `checked_neg` - [x] Returns arithmetic error on overflow - [x] **2c. Float NaN/Infinity detection** - [x] `check_float()` validates every float operation result - [x] Returns error for NaN or Infinity --- ## Phase 3: Usability — COMPLETE ### 3a. `once/1` and `call/1` - [x] `once/1` — solve goal, take first solution, cut (uses `try_solve_once` + choice stack truncation) - [x] `call/1` — execute a term as a goal (basic meta-call) - Files: `builtins.rs`, `solver.rs` ### 3b. Atom/string predicates - [x] `atom_length/2` - [x] `atom_concat/3` - [x] `atom_chars/2` - Files: `builtins.rs` (BuiltinResult variants), `solver.rs` (execution with mutable interner) ### 3c. Additional arithmetic functions Added to `is/2` evaluation: - [x] `abs/1` - [x] `max/2` - [x] `min/2` - [x] `sign/1` - File: `builtins.rs` --- ## Phase 4: Testing — COMPLETE - [x] **4a. Integration tests** (`tests/integration.rs`) - [x] Full pipeline tests (family relationships, factorial, list ops) - [x] Type-checking, if-then-else, disjunction, findall tests - [x] Error case tests (depth limit, overflow, div-by-zero, unbound vars) - [x] Edge case tests (empty KB, no matching predicate, parse errors, ground queries) - [x] **4b. Edge case tests** - [x] Deeply recursive rules (verify depth limit works) - [x] Integer overflow in arithmetic - [x] Empty knowledge base with queries - [x] Division by zero - [x] Unbound variable in arithmetic - [x] **4c. Stdlib tests** - [x] List predicates tested end-to-end in integration tests ### Test counts - 132 unit tests in prolog-core - 71 integration tests in tests/integration.rs - **203 total** --- ## Phase 5: Nice-to-have — COMPLETE - [x] `write/1`, `writeln/1`, `nl/0` — I/O for debugging rules - [x] `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` — term ordering (ISO standard order) - [x] `functor/3`, `arg/3`, `=../2` — term introspection/decomposition - [x] `between/3` — integer enumeration (with backtracking, works inside findall) - [x] `copy_term/2` — term copying with fresh variables - [x] `succ/2`, `plus/3` — Peano arithmetic (bidirectional) - [x] `msort/2`, `sort/2` — list sorting (sort removes duplicates) - [x] `number_chars/2`, `number_codes/2` — number/string conversion (bidirectional) - [ ] `assert/1`, `retract/1` — dynamic predicates (future) - [ ] REPL mode (future) --- ## Architecture Notes - **Workspace**: root `patch-prolog` binary + `crates/prolog-core` library - **Build-time compilation**: `build.rs` compiles `knowledge/*.pl` into binary via bincode - **Core modules**: term, tokenizer, parser, unify, builtins, solver, database, index - **`[]` and `!` always interned**: `CompiledDatabase::new()` ensures this (required for findall, list ops, once/1) - **Solver runtime interner**: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars) - **BuiltinResult enum**: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants - **Disjunction choice points**: use `disjunction: bool` flag on ChoicePoint to distinguish from clause alternatives - **Parenthesized expressions**: `parse_paren_body()` handles `;`, `->`, and `,` as control flow operators ## Current Built-in Predicates (~55 total) | Category | Predicates | |----------|-----------| | Core | `true`, `fail`, `false`, `!`, `=`, `\=`, `is`, `<`, `>`, `=<`, `>=`, `=:=`, `=\=`, `\+` | | Type checking | `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` | | Control flow | `;/2` (disjunction), `->/2` (if-then), `,/2` (conjunction) | | Meta-call | `once/1`, `call/1` | | Collection | `findall/3` | | Atom/string | `atom_length/2`, `atom_concat/3`, `atom_chars/2` | | Arithmetic ops | `+`, `-`, `*`, `/`, `mod`, unary `-`, `abs/1`, `max/2`, `min/2`, `sign/1` | | I/O | `write/1`, `writeln/1`, `nl/0` | | Term ordering | `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` | | Term introspection | `functor/3`, `arg/3`, `=../2` | | Enumeration | `between/3` | | Copying | `copy_term/2` | | Peano arithmetic | `succ/2`, `plus/3` | | Sorting | `msort/2`, `sort/2` | | Number conversion | `number_chars/2`, `number_codes/2` | ## Stdlib (knowledge/stdlib.pl) `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3`
claude[bot] commented 2026-03-02 05:06:39 +00:00 (Migrated from github.com)

patch-prolog Completeness & Usability Plan

Context

patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles .pl rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete.


Phase 1: Critical Built-ins — COMPLETE

  • 1a. Type-checking predicates in builtins.rs

    • var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
  • 1b. List predicates as Prolog rules in knowledge/stdlib.pl

    • member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3
  • 1c. Solution collection: findall/3

    • Invokes solver internally, collects all solutions, builds result list
    • Handles conjunction, disjunction, negation within findall goals
  • 1d. If-then-else and disjunction

    • Tokenizer: -> (Arrow) and ; (Semicolon) tokens
    • Parser: parse_paren_body() handles (Cond -> Then ; Else) and (A ; B)
    • Solver: BuiltinResult variants — Disjunction, IfThenElse, IfThen, Conjunction
    • Disjunction choice points on backtrack stack

Phase 2: Robustness & Safety — COMPLETE

  • 2a. Recursion depth limit

    • max_depth field on Solver (default 10,000)
    • with_max_depth() builder method
    • Returns SolveResult::Error instead of stack overflow
  • 2b. Integer overflow protection

    • checked_add, checked_sub, checked_mul, checked_div, checked_neg
    • Returns arithmetic error on overflow
  • 2c. Float NaN/Infinity detection

    • check_float() validates every float operation result
    • Returns error for NaN or Infinity

Phase 3: Usability — COMPLETE

3a. once/1 and call/1

  • once/1 — solve goal, take first solution, cut (uses try_solve_once + choice stack truncation)
  • call/1 — execute a term as a goal (basic meta-call)
  • Files: builtins.rs, solver.rs

3b. Atom/string predicates

  • atom_length/2
  • atom_concat/3
  • atom_chars/2
  • Files: builtins.rs (BuiltinResult variants), solver.rs (execution with mutable interner)

3c. Additional arithmetic functions

Added to is/2 evaluation:

  • abs/1
  • max/2
  • min/2
  • sign/1
  • File: builtins.rs

Phase 4: Testing — COMPLETE

  • 4a. Integration tests (tests/integration.rs)

    • Full pipeline tests (family relationships, factorial, list ops)
    • Type-checking, if-then-else, disjunction, findall tests
    • Error case tests (depth limit, overflow, div-by-zero, unbound vars)
    • Edge case tests (empty KB, no matching predicate, parse errors, ground queries)
  • 4b. Edge case tests

    • Deeply recursive rules (verify depth limit works)
    • Integer overflow in arithmetic
    • Empty knowledge base with queries
    • Division by zero
    • Unbound variable in arithmetic
  • 4c. Stdlib tests

    • List predicates tested end-to-end in integration tests

Test counts

  • 132 unit tests in prolog-core
  • 71 integration tests in tests/integration.rs
  • 203 total

Phase 5: Nice-to-have — COMPLETE

  • write/1, writeln/1, nl/0 — I/O for debugging rules
  • compare/3, @</2, @>/2, @=</2, @>=/2 — term ordering (ISO standard order)
  • functor/3, arg/3, =../2 — term introspection/decomposition
  • between/3 — integer enumeration (with backtracking, works inside findall)
  • copy_term/2 — term copying with fresh variables
  • succ/2, plus/3 — Peano arithmetic (bidirectional)
  • msort/2, sort/2 — list sorting (sort removes duplicates)
  • number_chars/2, number_codes/2 — number/string conversion (bidirectional)
  • assert/1, retract/1 — dynamic predicates (future)
  • REPL mode (future)

Architecture Notes

  • Workspace: root patch-prolog binary + crates/prolog-core library
  • Build-time compilation: build.rs compiles knowledge/*.pl into binary via bincode
  • Core modules: term, tokenizer, parser, unify, builtins, solver, database, index
  • [] and ! always interned: CompiledDatabase::new() ensures this (required for findall, list ops, once/1)
  • Solver runtime interner: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars)
  • BuiltinResult enum: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants
  • Disjunction choice points: use disjunction: bool flag on ChoicePoint to distinguish from clause alternatives
  • Parenthesized expressions: parse_paren_body() handles ;, ->, and , as control flow operators

Current Built-in Predicates (~55 total)

Category Predicates
Core true, fail, false, !, =, \=, is, <, >, =<, >=, =:=, =\=, \+
Type checking var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
Control flow ;/2 (disjunction), ->/2 (if-then), ,/2 (conjunction)
Meta-call once/1, call/1
Collection findall/3
Atom/string atom_length/2, atom_concat/3, atom_chars/2
Arithmetic ops +, -, *, /, mod, unary -, abs/1, max/2, min/2, sign/1
I/O write/1, writeln/1, nl/0
Term ordering compare/3, @</2, @>/2, @=</2, @>=/2
Term introspection functor/3, arg/3, =../2
Enumeration between/3
Copying copy_term/2
Peano arithmetic succ/2, plus/3
Sorting msort/2, sort/2
Number conversion number_chars/2, number_codes/2

Stdlib (knowledge/stdlib.pl)

member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3

# patch-prolog Completeness & Usability Plan ## Context patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles `.pl` rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete. --- ## Phase 1: Critical Built-ins — COMPLETE - [x] **1a. Type-checking predicates** in `builtins.rs` - [x] `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` - [x] **1b. List predicates** as Prolog rules in `knowledge/stdlib.pl` - [x] `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3` - [x] **1c. Solution collection: `findall/3`** - [x] Invokes solver internally, collects all solutions, builds result list - [x] Handles conjunction, disjunction, negation within findall goals - [x] **1d. If-then-else and disjunction** - [x] Tokenizer: `->` (Arrow) and `;` (Semicolon) tokens - [x] Parser: `parse_paren_body()` handles `(Cond -> Then ; Else)` and `(A ; B)` - [x] Solver: `BuiltinResult` variants — Disjunction, IfThenElse, IfThen, Conjunction - [x] Disjunction choice points on backtrack stack --- ## Phase 2: Robustness & Safety — COMPLETE - [x] **2a. Recursion depth limit** - [x] `max_depth` field on Solver (default 10,000) - [x] `with_max_depth()` builder method - [x] Returns `SolveResult::Error` instead of stack overflow - [x] **2b. Integer overflow protection** - [x] `checked_add`, `checked_sub`, `checked_mul`, `checked_div`, `checked_neg` - [x] Returns arithmetic error on overflow - [x] **2c. Float NaN/Infinity detection** - [x] `check_float()` validates every float operation result - [x] Returns error for NaN or Infinity --- ## Phase 3: Usability — COMPLETE ### 3a. `once/1` and `call/1` - [x] `once/1` — solve goal, take first solution, cut (uses `try_solve_once` + choice stack truncation) - [x] `call/1` — execute a term as a goal (basic meta-call) - Files: `builtins.rs`, `solver.rs` ### 3b. Atom/string predicates - [x] `atom_length/2` - [x] `atom_concat/3` - [x] `atom_chars/2` - Files: `builtins.rs` (BuiltinResult variants), `solver.rs` (execution with mutable interner) ### 3c. Additional arithmetic functions Added to `is/2` evaluation: - [x] `abs/1` - [x] `max/2` - [x] `min/2` - [x] `sign/1` - File: `builtins.rs` --- ## Phase 4: Testing — COMPLETE - [x] **4a. Integration tests** (`tests/integration.rs`) - [x] Full pipeline tests (family relationships, factorial, list ops) - [x] Type-checking, if-then-else, disjunction, findall tests - [x] Error case tests (depth limit, overflow, div-by-zero, unbound vars) - [x] Edge case tests (empty KB, no matching predicate, parse errors, ground queries) - [x] **4b. Edge case tests** - [x] Deeply recursive rules (verify depth limit works) - [x] Integer overflow in arithmetic - [x] Empty knowledge base with queries - [x] Division by zero - [x] Unbound variable in arithmetic - [x] **4c. Stdlib tests** - [x] List predicates tested end-to-end in integration tests ### Test counts - 132 unit tests in prolog-core - 71 integration tests in tests/integration.rs - **203 total** --- ## Phase 5: Nice-to-have — COMPLETE - [x] `write/1`, `writeln/1`, `nl/0` — I/O for debugging rules - [x] `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` — term ordering (ISO standard order) - [x] `functor/3`, `arg/3`, `=../2` — term introspection/decomposition - [x] `between/3` — integer enumeration (with backtracking, works inside findall) - [x] `copy_term/2` — term copying with fresh variables - [x] `succ/2`, `plus/3` — Peano arithmetic (bidirectional) - [x] `msort/2`, `sort/2` — list sorting (sort removes duplicates) - [x] `number_chars/2`, `number_codes/2` — number/string conversion (bidirectional) - [ ] `assert/1`, `retract/1` — dynamic predicates (future) - [ ] REPL mode (future) --- ## Architecture Notes - **Workspace**: root `patch-prolog` binary + `crates/prolog-core` library - **Build-time compilation**: `build.rs` compiles `knowledge/*.pl` into binary via bincode - **Core modules**: term, tokenizer, parser, unify, builtins, solver, database, index - **`[]` and `!` always interned**: `CompiledDatabase::new()` ensures this (required for findall, list ops, once/1) - **Solver runtime interner**: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars) - **BuiltinResult enum**: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants - **Disjunction choice points**: use `disjunction: bool` flag on ChoicePoint to distinguish from clause alternatives - **Parenthesized expressions**: `parse_paren_body()` handles `;`, `->`, and `,` as control flow operators ## Current Built-in Predicates (~55 total) | Category | Predicates | |----------|-----------| | Core | `true`, `fail`, `false`, `!`, `=`, `\=`, `is`, `<`, `>`, `=<`, `>=`, `=:=`, `=\=`, `\+` | | Type checking | `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` | | Control flow | `;/2` (disjunction), `->/2` (if-then), `,/2` (conjunction) | | Meta-call | `once/1`, `call/1` | | Collection | `findall/3` | | Atom/string | `atom_length/2`, `atom_concat/3`, `atom_chars/2` | | Arithmetic ops | `+`, `-`, `*`, `/`, `mod`, unary `-`, `abs/1`, `max/2`, `min/2`, `sign/1` | | I/O | `write/1`, `writeln/1`, `nl/0` | | Term ordering | `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` | | Term introspection | `functor/3`, `arg/3`, `=../2` | | Enumeration | `between/3` | | Copying | `copy_term/2` | | Peano arithmetic | `succ/2`, `plus/3` | | Sorting | `msort/2`, `sort/2` | | Number conversion | `number_chars/2`, `number_codes/2` | ## Stdlib (knowledge/stdlib.pl) `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3`
claude[bot] commented 2026-03-02 05:07:19 +00:00 (Migrated from github.com)

patch-prolog Completeness & Usability Plan

Context

patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles .pl rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete.


Phase 1: Critical Built-ins — COMPLETE

  • 1a. Type-checking predicates in builtins.rs

    • var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
  • 1b. List predicates as Prolog rules in knowledge/stdlib.pl

    • member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3
  • 1c. Solution collection: findall/3

    • Invokes solver internally, collects all solutions, builds result list
    • Handles conjunction, disjunction, negation within findall goals
  • 1d. If-then-else and disjunction

    • Tokenizer: -> (Arrow) and ; (Semicolon) tokens
    • Parser: parse_paren_body() handles (Cond -> Then ; Else) and (A ; B)
    • Solver: BuiltinResult variants — Disjunction, IfThenElse, IfThen, Conjunction
    • Disjunction choice points on backtrack stack

Phase 2: Robustness & Safety — COMPLETE

  • 2a. Recursion depth limit

    • max_depth field on Solver (default 10,000)
    • with_max_depth() builder method
    • Returns SolveResult::Error instead of stack overflow
  • 2b. Integer overflow protection

    • checked_add, checked_sub, checked_mul, checked_div, checked_neg
    • Returns arithmetic error on overflow
  • 2c. Float NaN/Infinity detection

    • check_float() validates every float operation result
    • Returns error for NaN or Infinity

Phase 3: Usability — COMPLETE

3a. once/1 and call/1

  • once/1 — solve goal, take first solution, cut (uses try_solve_once + choice stack truncation)
  • call/1 — execute a term as a goal (basic meta-call)
  • Files: builtins.rs, solver.rs

3b. Atom/string predicates

  • atom_length/2
  • atom_concat/3
  • atom_chars/2
  • Files: builtins.rs (BuiltinResult variants), solver.rs (execution with mutable interner)

3c. Additional arithmetic functions

Added to is/2 evaluation:

  • abs/1
  • max/2
  • min/2
  • sign/1
  • File: builtins.rs

Phase 4: Testing — COMPLETE

  • 4a. Integration tests (tests/integration.rs)

    • Full pipeline tests (family relationships, factorial, list ops)
    • Type-checking, if-then-else, disjunction, findall tests
    • Error case tests (depth limit, overflow, div-by-zero, unbound vars)
    • Edge case tests (empty KB, no matching predicate, parse errors, ground queries)
  • 4b. Edge case tests

    • Deeply recursive rules (verify depth limit works)
    • Integer overflow in arithmetic
    • Empty knowledge base with queries
    • Division by zero
    • Unbound variable in arithmetic
  • 4c. Stdlib tests

    • List predicates tested end-to-end in integration tests

Test counts

  • 132 unit tests in prolog-core
  • 71 integration tests in tests/integration.rs
  • 203 total

Phase 5: Nice-to-have — COMPLETE

  • write/1, writeln/1, nl/0 — I/O for debugging rules
  • compare/3, @</2, @>/2, @=</2, @>=/2 — term ordering (ISO standard order)
  • functor/3, arg/3, =../2 — term introspection/decomposition
  • between/3 — integer enumeration (with backtracking, works inside findall)
  • copy_term/2 — term copying with fresh variables
  • succ/2, plus/3 — Peano arithmetic (bidirectional)
  • msort/2, sort/2 — list sorting (sort removes duplicates)
  • number_chars/2, number_codes/2 — number/string conversion (bidirectional)
  • assert/1, retract/1 — dynamic predicates (future)
  • REPL mode (future)

Architecture Notes

  • Workspace: root patch-prolog binary + crates/prolog-core library
  • Build-time compilation: build.rs compiles knowledge/*.pl into binary via bincode
  • Core modules: term, tokenizer, parser, unify, builtins, solver, database, index
  • [] and ! always interned: CompiledDatabase::new() ensures this (required for findall, list ops, once/1)
  • Solver runtime interner: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars)
  • BuiltinResult enum: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants
  • Disjunction choice points: use disjunction: bool flag on ChoicePoint to distinguish from clause alternatives
  • Parenthesized expressions: parse_paren_body() handles ;, ->, and , as control flow operators

Current Built-in Predicates (~55 total)

Category Predicates
Core true, fail, false, !, =, \=, is, <, >, =<, >=, =:=, =\=, \+
Type checking var/1, nonvar/1, atom/1, number/1, integer/1, float/1, compound/1, is_list/1
Control flow ;/2 (disjunction), ->/2 (if-then), ,/2 (conjunction)
Meta-call once/1, call/1
Collection findall/3
Atom/string atom_length/2, atom_concat/3, atom_chars/2
Arithmetic ops +, -, *, /, mod, unary -, abs/1, max/2, min/2, sign/1
I/O write/1, writeln/1, nl/0
Term ordering compare/3, @</2, @>/2, @=</2, @>=/2
Term introspection functor/3, arg/3, =../2
Enumeration between/3
Copying copy_term/2
Peano arithmetic succ/2, plus/3
Sorting msort/2, sort/2
Number conversion number_chars/2, number_codes/2

Stdlib (knowledge/stdlib.pl)

member/2, append/3, length/2, last/2, reverse/2, nth0/3, nth1/3

# patch-prolog Completeness & Usability Plan ## Context patch-prolog is a Rust-based Prolog engine for linting generative AI output. It compiles `.pl` rules at build time into a binary, then runs queries at runtime. The core engine works — unification, backtracking, cut, negation-as-failure, arithmetic — and Phase 1+2 improvements are complete. --- ## Phase 1: Critical Built-ins — COMPLETE - [x] **1a. Type-checking predicates** in `builtins.rs` - [x] `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` - [x] **1b. List predicates** as Prolog rules in `knowledge/stdlib.pl` - [x] `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3` - [x] **1c. Solution collection: `findall/3`** - [x] Invokes solver internally, collects all solutions, builds result list - [x] Handles conjunction, disjunction, negation within findall goals - [x] **1d. If-then-else and disjunction** - [x] Tokenizer: `->` (Arrow) and `;` (Semicolon) tokens - [x] Parser: `parse_paren_body()` handles `(Cond -> Then ; Else)` and `(A ; B)` - [x] Solver: `BuiltinResult` variants — Disjunction, IfThenElse, IfThen, Conjunction - [x] Disjunction choice points on backtrack stack --- ## Phase 2: Robustness & Safety — COMPLETE - [x] **2a. Recursion depth limit** - [x] `max_depth` field on Solver (default 10,000) - [x] `with_max_depth()` builder method - [x] Returns `SolveResult::Error` instead of stack overflow - [x] **2b. Integer overflow protection** - [x] `checked_add`, `checked_sub`, `checked_mul`, `checked_div`, `checked_neg` - [x] Returns arithmetic error on overflow - [x] **2c. Float NaN/Infinity detection** - [x] `check_float()` validates every float operation result - [x] Returns error for NaN or Infinity --- ## Phase 3: Usability — COMPLETE ### 3a. `once/1` and `call/1` - [x] `once/1` — solve goal, take first solution, cut (uses `try_solve_once` + choice stack truncation) - [x] `call/1` — execute a term as a goal (basic meta-call) - Files: `builtins.rs`, `solver.rs` ### 3b. Atom/string predicates - [x] `atom_length/2` - [x] `atom_concat/3` - [x] `atom_chars/2` - Files: `builtins.rs` (BuiltinResult variants), `solver.rs` (execution with mutable interner) ### 3c. Additional arithmetic functions Added to `is/2` evaluation: - [x] `abs/1` - [x] `max/2` - [x] `min/2` - [x] `sign/1` - File: `builtins.rs` --- ## Phase 4: Testing — COMPLETE - [x] **4a. Integration tests** (`tests/integration.rs`) - [x] Full pipeline tests (family relationships, factorial, list ops) - [x] Type-checking, if-then-else, disjunction, findall tests - [x] Error case tests (depth limit, overflow, div-by-zero, unbound vars) - [x] Edge case tests (empty KB, no matching predicate, parse errors, ground queries) - [x] **4b. Edge case tests** - [x] Deeply recursive rules (verify depth limit works) - [x] Integer overflow in arithmetic - [x] Empty knowledge base with queries - [x] Division by zero - [x] Unbound variable in arithmetic - [x] **4c. Stdlib tests** - [x] List predicates tested end-to-end in integration tests ### Test counts - 132 unit tests in prolog-core - 71 integration tests in tests/integration.rs - **203 total** --- ## Phase 5: Nice-to-have — COMPLETE - [x] `write/1`, `writeln/1`, `nl/0` — I/O for debugging rules - [x] `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` — term ordering (ISO standard order) - [x] `functor/3`, `arg/3`, `=../2` — term introspection/decomposition - [x] `between/3` — integer enumeration (with backtracking, works inside findall) - [x] `copy_term/2` — term copying with fresh variables - [x] `succ/2`, `plus/3` — Peano arithmetic (bidirectional) - [x] `msort/2`, `sort/2` — list sorting (sort removes duplicates) - [x] `number_chars/2`, `number_codes/2` — number/string conversion (bidirectional) - [ ] `assert/1`, `retract/1` — dynamic predicates (future) - [ ] REPL mode (future) --- ## Architecture Notes - **Workspace**: root `patch-prolog` binary + `crates/prolog-core` library - **Build-time compilation**: `build.rs` compiles `knowledge/*.pl` into binary via bincode - **Core modules**: term, tokenizer, parser, unify, builtins, solver, database, index - **`[]` and `!` always interned**: `CompiledDatabase::new()` ensures this (required for findall, list ops, once/1) - **Solver runtime interner**: Solver clones db.interner for atom predicates that create new atoms at runtime (atom_concat, atom_chars) - **BuiltinResult enum**: handles control flow — solver processes Disjunction, IfThenElse, Conjunction, FindAll variants - **Disjunction choice points**: use `disjunction: bool` flag on ChoicePoint to distinguish from clause alternatives - **Parenthesized expressions**: `parse_paren_body()` handles `;`, `->`, and `,` as control flow operators ## Current Built-in Predicates (~55 total) | Category | Predicates | |----------|-----------| | Core | `true`, `fail`, `false`, `!`, `=`, `\=`, `is`, `<`, `>`, `=<`, `>=`, `=:=`, `=\=`, `\+` | | Type checking | `var/1`, `nonvar/1`, `atom/1`, `number/1`, `integer/1`, `float/1`, `compound/1`, `is_list/1` | | Control flow | `;/2` (disjunction), `->/2` (if-then), `,/2` (conjunction) | | Meta-call | `once/1`, `call/1` | | Collection | `findall/3` | | Atom/string | `atom_length/2`, `atom_concat/3`, `atom_chars/2` | | Arithmetic ops | `+`, `-`, `*`, `/`, `mod`, unary `-`, `abs/1`, `max/2`, `min/2`, `sign/1` | | I/O | `write/1`, `writeln/1`, `nl/0` | | Term ordering | `compare/3`, `@</2`, `@>/2`, `@=</2`, `@>=/2` | | Term introspection | `functor/3`, `arg/3`, `=../2` | | Enumeration | `between/3` | | Copying | `copy_term/2` | | Peano arithmetic | `succ/2`, `plus/3` | | Sorting | `msort/2`, `sort/2` | | Number conversion | `number_chars/2`, `number_codes/2` | ## Stdlib (knowledge/stdlib.pl) `member/2`, `append/3`, `length/2`, `last/2`, `reverse/2`, `nth0/3`, `nth1/3`
claude[bot] commented 2026-03-02 05:08:23 +00:00 (Migrated from github.com)

PR Review: Phase 5 Builtins

Overall the implementation is solid. Several correctness issues and one performance issue need attention.


Bug: negation-as-failure with between/3 gives wrong result when X is bound

File: crates/prolog-core/src/solver.rs, lines 1586-1595 (try_exec_misc)

try_exec_misc for Between only tries low and ignores the rest of the range. try_solve_once (used by + and once/1) dispatches here and never iterates further values.

Concrete failure: + between(1, 5, 3) incorrectly succeeds:

  1. exec_builtin returns NegationAsFailure(between(1,5,3))
  2. try_solve_once([between(1,5,3)]) falls through to try_exec_misc
  3. try_exec_misc attempts unify(3, 1) which fails, returns Some(false)
  4. try_solve_once returns false, so negation-as-failure succeeds -- wrong

Similarly once(between(2, 5, X)), X = 3 fails when it should succeed.

Fix: try_exec_misc for Between must iterate the full range the same way try_solve_collecting does (lines 1389-1400).


Bug: compare/3, @</2, @>/2 etc. -- shallow walk misses bound variables inside compound args

Files:

  • crates/prolog-core/src/builtins.rs, lines 330-360
  • crates/prolog-core/src/solver.rs, lines 399-413

Both call subst.walk() (shallow) before passing to term_compare. For a compound like f(X) where X=1, walk(f(X)) returns f(X) unchanged; term_compare then compares raw VarIds instead of bound values.

Concrete failure: X = 1, Y = 2, compare(Order, f(X), f(Y)) -- Expected Order = <; actual result depends on which VarId is numerically larger.

Fix: Use subst.apply() instead of subst.walk() at these call sites.


Bug: succ/2 -- unchecked integer overflow at i64::MAX

File: crates/prolog-core/src/solver.rs, lines 678-679 and 1607

Release mode wraps silently; debug mode panics. Every other arithmetic op in this codebase uses checked_add. Same issue in try_exec_misc at line 1607.

Fix: Use x.checked_add(1).ok_or_else(...) consistent with arithmetic evaluator.


Bug: plus/3 -- unchecked integer arithmetic

File: crates/prolog-core/src/solver.rs, lines 712-729 and 1619-1629

All three modes use raw + / - without overflow checks, unlike is/2 which uses checked_add/checked_sub throughout: Term::Integer(x + y) at line 713, Term::Integer(z - x) at line 719, Term::Integer(z - y) at line 725.

Fix: Apply checked_add/checked_sub and return an error on overflow.


Correctness: if-then-else evaluates condition twice

File: crates/prolog-core/src/solver.rs, lines 231-282

The IfThenElse and IfThen handlers run cond via try_solve_once, discard its bindings with undo_to(mark), then re-insert cond into the goal list to run again in the main loop. Consequences:

  • I/O fires twice: ( write(hello) -> true ; fail ) prints hello twice.
  • Nondeterministic conditions may pick a different binding on re-execution.

Since try_solve_once leaves bindings on success, the fix is to skip undo_to(mark) when the condition succeeds, truncate only the choice stack, and continue with just then (not cond, then).


Correctness: term_compare List vs arity-2 Compound hardcodes Less

File: crates/prolog-core/src/builtins.rs, lines 686-691

When comparing Term::List against a Term::Compound with arity 2, the tiebreak after the arity comparison is hardcoded Ordering::Less. A Term::Compound { functor: ".", args: [H,T] } (structurally identical to Term::List) compares as Less instead of Equal, breaking sort/2 and compare/3. This can arise from =.. reconstruction of list terms.


Performance: goals.remove(0) is O(n) -- quadratic goal processing

File: crates/prolog-core/src/solver.rs, lines 163, 1032, 1245

All three solve loops call .remove(0) on a Vec, shifting every remaining element on each step. For deeply nested conjunctions this is O(n^2) in goal-list length.

Fix: Replace Vec with VecDeque; .remove(0) becomes .pop_front() and .insert(0, x) becomes .push_front(x).


Missing: atom_chars/2 reverse direction (char list to atom)

File: crates/prolog-core/src/solver.rs, lines 359-382

When the first argument is unbound and the second is a ground char list, the implementation errors instead of building the atom. ISO 8.16.4 requires both directions. number_chars/2 already handles the reverse case (lines 805-843).


Summary

Severity Issue Location
Bug + between/3 incorrect with bound X solver.rs:1586
Bug compare/3 / @< etc. shallow walk inside compounds builtins.rs:330, solver.rs:400
Bug succ/2 overflow at i64::MAX solver.rs:679, 1607
Bug plus/3 unchecked overflow solver.rs:712-729, 1619-1629
Correctness if-then-else runs condition twice solver.rs:238-251
Correctness term_compare List vs arity-2 Compound builtins.rs:686
Performance goals.remove(0) O(n), quadratic total solver.rs:163, 1032, 1245
Missing atom_chars/2 reverse direction solver.rs:377
## PR Review: Phase 5 Builtins Overall the implementation is solid. Several correctness issues and one performance issue need attention. --- ### Bug: negation-as-failure with between/3 gives wrong result when X is bound **File:** crates/prolog-core/src/solver.rs, lines 1586-1595 (try_exec_misc) try_exec_misc for Between only tries low and ignores the rest of the range. try_solve_once (used by \+ and once/1) dispatches here and never iterates further values. **Concrete failure:** \+ between(1, 5, 3) incorrectly **succeeds**: 1. exec_builtin returns NegationAsFailure(between(1,5,3)) 2. try_solve_once([between(1,5,3)]) falls through to try_exec_misc 3. try_exec_misc attempts unify(3, 1) which fails, returns Some(false) 4. try_solve_once returns false, so negation-as-failure succeeds -- wrong Similarly once(between(2, 5, X)), X = 3 fails when it should succeed. **Fix:** try_exec_misc for Between must iterate the full range the same way try_solve_collecting does (lines 1389-1400). --- ### Bug: compare/3, @</2, @>/2 etc. -- shallow walk misses bound variables inside compound args **Files:** - crates/prolog-core/src/builtins.rs, lines 330-360 - crates/prolog-core/src/solver.rs, lines 399-413 Both call subst.walk() (shallow) before passing to term_compare. For a compound like f(X) where X=1, walk(f(X)) returns f(X) unchanged; term_compare then compares raw VarIds instead of bound values. **Concrete failure:** X = 1, Y = 2, compare(Order, f(X), f(Y)) -- Expected Order = <; actual result depends on which VarId is numerically larger. **Fix:** Use subst.apply() instead of subst.walk() at these call sites. --- ### Bug: succ/2 -- unchecked integer overflow at i64::MAX **File:** crates/prolog-core/src/solver.rs, lines 678-679 and 1607 Release mode wraps silently; debug mode panics. Every other arithmetic op in this codebase uses checked_add. Same issue in try_exec_misc at line 1607. **Fix:** Use x.checked_add(1).ok_or_else(...) consistent with arithmetic evaluator. --- ### Bug: plus/3 -- unchecked integer arithmetic **File:** crates/prolog-core/src/solver.rs, lines 712-729 and 1619-1629 All three modes use raw + / - without overflow checks, unlike is/2 which uses checked_add/checked_sub throughout: Term::Integer(x + y) at line 713, Term::Integer(z - x) at line 719, Term::Integer(z - y) at line 725. **Fix:** Apply checked_add/checked_sub and return an error on overflow. --- ### Correctness: if-then-else evaluates condition twice **File:** crates/prolog-core/src/solver.rs, lines 231-282 The IfThenElse and IfThen handlers run cond via try_solve_once, discard its bindings with undo_to(mark), then re-insert cond into the goal list to run again in the main loop. Consequences: - I/O fires twice: ( write(hello) -> true ; fail ) prints hello twice. - Nondeterministic conditions may pick a different binding on re-execution. Since try_solve_once leaves bindings on success, the fix is to skip undo_to(mark) when the condition succeeds, truncate only the choice stack, and continue with just then (not cond, then). --- ### Correctness: term_compare List vs arity-2 Compound hardcodes Less **File:** crates/prolog-core/src/builtins.rs, lines 686-691 When comparing Term::List against a Term::Compound with arity 2, the tiebreak after the arity comparison is hardcoded Ordering::Less. A Term::Compound { functor: ".", args: [H,T] } (structurally identical to Term::List) compares as Less instead of Equal, breaking sort/2 and compare/3. This can arise from =.. reconstruction of list terms. --- ### Performance: goals.remove(0) is O(n) -- quadratic goal processing **File:** crates/prolog-core/src/solver.rs, lines 163, 1032, 1245 All three solve loops call .remove(0) on a Vec<Term>, shifting every remaining element on each step. For deeply nested conjunctions this is O(n^2) in goal-list length. **Fix:** Replace Vec<Term> with VecDeque<Term>; .remove(0) becomes .pop_front() and .insert(0, x) becomes .push_front(x). --- ### Missing: atom_chars/2 reverse direction (char list to atom) **File:** crates/prolog-core/src/solver.rs, lines 359-382 When the first argument is unbound and the second is a ground char list, the implementation errors instead of building the atom. ISO 8.16.4 requires both directions. number_chars/2 already handles the reverse case (lines 805-843). --- ### Summary | Severity | Issue | Location | |----------|-------|----------| | Bug | \+ between/3 incorrect with bound X | solver.rs:1586 | | Bug | compare/3 / @< etc. shallow walk inside compounds | builtins.rs:330, solver.rs:400 | | Bug | succ/2 overflow at i64::MAX | solver.rs:679, 1607 | | Bug | plus/3 unchecked overflow | solver.rs:712-729, 1619-1629 | | Correctness | if-then-else runs condition twice | solver.rs:238-251 | | Correctness | term_compare List vs arity-2 Compound | builtins.rs:686 | | Performance | goals.remove(0) O(n), quadratic total | solver.rs:163, 1032, 1245 | | Missing | atom_chars/2 reverse direction | solver.rs:377 |
claude[bot] commented 2026-03-02 06:08:36 +00:00 (Migrated from github.com)

PR Review — Phase 5 Builtins

Reviewed: builtins.rs, solver.rs, tokenizer.rs, parser.rs, integration tests.


Bug 1 — write/1 and writeln/1 use shallow walk instead of deep apply

solver.rs main loop (~line 374) and try_exec_misc (~lines 1570–1578)

walk only follows a top-level variable chain. If term resolves to a compound, it is returned unchanged with its argument variables unresolved. term_to_string then formats those as _N.

Concrete failure:

X = hello, write(f(X))   % prints f(_5) instead of f(hello)

Fix: replace self.subst.walk(&term) with self.subst.apply(&term) in both the main-loop Write/Writeln handlers and in try_exec_misc. (compare/3 and @< already correctly use apply.)


Bug 2 — number_chars/2 and number_codes/2 reverse mode absent from try_exec_misc

solver.rs try_exec_misc (~lines 1750–1808)

The main solve loop handles reverse mode (unbound number, bound char/code list) correctly. But try_exec_misc — which handles these predicates when they appear inside once/1, \+, if-then conditions, or findall goals — hits the catch-all _ => Some(false) when num_arg is an unbound variable.

Concrete failures:

once(number_chars(X, ['1','2','3']))   % fails — X never bound
\+ number_chars(_, ['9'])              % succeeds incorrectly

The reverse-mode parse logic from the main loop needs to also appear (or be extracted and shared) in try_exec_misc for both NumberChars and NumberCodes.


Bug 3 — term_compare NaN makes sort/msort non-total

builtins.rs lines 656–660

(Term::Float(a), Term::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),

f64::NAN.partial_cmp(x) always returns None, so unwrap_or(Ordering::Equal) makes NaN compare equal to every value. sort_by requires a strict total order; violating this gives undefined sort output in release mode. The same pattern appears in the mixed Float/Integer arms.

Recommend: detect NaN at comparison time and return an evaluation error (ISO: undefined), or use a deterministic canonical fallback (e.g. NaN sorts last, bit-pattern tiebreak).


Non-compliance — ISO standard order for equal-valued float/integer

builtins.rs lines 657–660

ISO 8.4.2.1 specifies that when a float and an integer have the same arithmetic value, the float is less in standard order. SWI-Prolog: compare(Order, 1.0, 1)Order = <.

The current value-based partial_cmp returns Equal for 1.0 vs 1. This affects compare/3, @</@>, sort/2, and msort/2.

Fix: in the mixed-type arms, after value comparison yields Equal, tiebreak: return Less when left is Float and right is Integer, Greater for the converse.


Minor — try_exec_misc Between cannot backtrack inside conjunctions

solver.rs ~lines 1677–1688

When x_arg is unbound, try_exec_misc binds it to low on the first successful unify and returns immediately. This is intentional for once and NAF callers that hold their own trail mark. However, between inside a try_solve_once conjunction cannot backtrack to try higher values:

\+ (between(1, 5, X), X > 3)

Inside NAF, try_solve_once binds X=1, then 1 > 3 fails, so the conjunction fails and NAF succeeds — incorrectly (X=4 and X=5 would satisfy the constraint). The main-loop implementation is correct here via choice points. No test currently covers this case.


Positive observations

  • VecDeque migration eliminates O(n) front-removes on goal lists — correct throughout.
  • succ/2 and plus/3 use checked_add/checked_sub consistently — good.
  • arg/3 correctly backtracks (not errors) for out-of-range N.
  • copy_term_fresh correctly walks at each recursion level, handling full variable chains.
  • Trail marks are correctly saved/restored for IfThenElse and NAF across all three solver paths.
  • Occurs check is active in unify.rs.
  • Main-loop between/3 is correct: creates one choice point at low+1 rather than O(n) choice points.
  • @=< tokenization (peek_at(1) guard after consuming @) is correct.
## PR Review — Phase 5 Builtins Reviewed: `builtins.rs`, `solver.rs`, `tokenizer.rs`, `parser.rs`, integration tests. --- ### Bug 1 — `write/1` and `writeln/1` use shallow `walk` instead of deep `apply` **`solver.rs` main loop (~line 374) and `try_exec_misc` (~lines 1570–1578)** `walk` only follows a top-level variable chain. If `term` resolves to a compound, it is returned unchanged with its argument variables unresolved. `term_to_string` then formats those as `_N`. Concrete failure: ```prolog X = hello, write(f(X)) % prints f(_5) instead of f(hello) ``` Fix: replace `self.subst.walk(&term)` with `self.subst.apply(&term)` in both the main-loop `Write`/`Writeln` handlers and in `try_exec_misc`. (`compare/3` and `@<` already correctly use `apply`.) --- ### Bug 2 — `number_chars/2` and `number_codes/2` reverse mode absent from `try_exec_misc` **`solver.rs` `try_exec_misc` (~lines 1750–1808)** The main solve loop handles reverse mode (unbound number, bound char/code list) correctly. But `try_exec_misc` — which handles these predicates when they appear inside `once/1`, `\+`, if-then conditions, or `findall` goals — hits the catch-all `_ => Some(false)` when `num_arg` is an unbound variable. Concrete failures: ```prolog once(number_chars(X, ['1','2','3'])) % fails — X never bound \+ number_chars(_, ['9']) % succeeds incorrectly ``` The reverse-mode parse logic from the main loop needs to also appear (or be extracted and shared) in `try_exec_misc` for both `NumberChars` and `NumberCodes`. --- ### Bug 3 — `term_compare` NaN makes sort/msort non-total **`builtins.rs` lines 656–660** ```rust (Term::Float(a), Term::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal), ``` `f64::NAN.partial_cmp(x)` always returns `None`, so `unwrap_or(Ordering::Equal)` makes NaN compare equal to every value. `sort_by` requires a strict total order; violating this gives undefined sort output in release mode. The same pattern appears in the mixed `Float`/`Integer` arms. Recommend: detect NaN at comparison time and return an evaluation error (ISO: `undefined`), or use a deterministic canonical fallback (e.g. NaN sorts last, bit-pattern tiebreak). --- ### Non-compliance — ISO standard order for equal-valued float/integer **`builtins.rs` lines 657–660** ISO 8.4.2.1 specifies that when a float and an integer have the same arithmetic value, the **float is less** in standard order. SWI-Prolog: `compare(Order, 1.0, 1)` → `Order = <`. The current value-based `partial_cmp` returns `Equal` for `1.0` vs `1`. This affects `compare/3`, `@<`/`@>`, `sort/2`, and `msort/2`. Fix: in the mixed-type arms, after value comparison yields `Equal`, tiebreak: return `Less` when left is `Float` and right is `Integer`, `Greater` for the converse. --- ### Minor — `try_exec_misc` `Between` cannot backtrack inside conjunctions **`solver.rs` ~lines 1677–1688** When `x_arg` is unbound, `try_exec_misc` binds it to `low` on the first successful unify and returns immediately. This is intentional for `once` and NAF callers that hold their own trail mark. However, `between` inside a `try_solve_once` conjunction **cannot backtrack** to try higher values: ```prolog \+ (between(1, 5, X), X > 3) ``` Inside NAF, `try_solve_once` binds X=1, then `1 > 3` fails, so the conjunction fails and NAF *succeeds* — incorrectly (X=4 and X=5 would satisfy the constraint). The main-loop implementation is correct here via choice points. No test currently covers this case. --- ### Positive observations - `VecDeque` migration eliminates O(n) front-removes on goal lists — correct throughout. - `succ/2` and `plus/3` use `checked_add`/`checked_sub` consistently — good. - `arg/3` correctly backtracks (not errors) for out-of-range N. - `copy_term_fresh` correctly walks at each recursion level, handling full variable chains. - Trail marks are correctly saved/restored for `IfThenElse` and NAF across all three solver paths. - Occurs check is active in `unify.rs`. - Main-loop `between/3` is correct: creates one choice point at `low+1` rather than O(n) choice points. - `@=<` tokenization (`peek_at(1)` guard after consuming `@`) is correct.
claude[bot] commented 2026-03-02 22:44:04 +00:00 (Migrated from github.com)

Code Review — PR #2 (Phase 5 builtins)

The VecDeque migration is clean and the if-then/if-then-else fix (dropping the double-evaluation of the condition) is correct. Arithmetic uses checked_add/checked_sub throughout. Several real bugs below.


Bug 1 — functor/3 in try_exec_misc drops Term::List and Term::Var cases

File: crates/prolog-core/src/solver.rs:1618-1636

The main solve() loop handles both Term::List (~line 437) and Term::Var (construction, ~line 475) correctly. try_exec_misc is called when functor/3 appears inside once/1, \+, or findall/3. The _ => Some(false) catch-all silently returns failure for both cases:

  • once(functor([a,b], F, A)) fails — should bind F = '.', A = 2
  • once(functor(T, foo, 2)) fails — should construct foo(_,_)
  • findall(A, functor([_|_], _, A), As) gives [] instead of [2]

Bug 2 — =../2 in try_exec_misc drops Term::List case

File: crates/prolog-core/src/solver.rs:1657-1697

Same asymmetry. The main solve() loop handles the list case at ~line 593 (decomposing [H|T] to ['.', H, T]). The _ => Some(false) catch-all in try_exec_misc silently fails for Term::List:

  • once([a,b] =.. L) silently fails — should bind L = ['.', a, [b]]
  • findall(L, ([a] =.. L), Ls) gives []

Both bugs 1 and 2 stem from the same root: try_exec_misc was designed as a shared helper but the Functor and Univ match arms were not kept in sync with the main solve() match.


Bug 3 — atom_chars/2 reverse mode uses byte-length for single-character test

File: crates/prolog-core/src/solver.rs:376, 1218, ~1462

ch.len() == 1 tests byte length, not character count. A single-character atom consisting of a multi-byte UTF-8 codepoint (e.g., a 2-byte accented letter) has ch.len() == 2 and is silently dropped from the assembled string. atom_chars(X, [AccentedChar]) binds X to the empty atom instead of the correct atom. The same pattern appears in the try_solve_once and try_solve_collecting copies of this arm.

Fix: ch.chars().count() == 1


Bug 4 — functor/3 construction: *arity as u32 silently truncates large arities

File: crates/prolog-core/src/solver.rs:501

The guard *arity > 0 passes for any positive i64. The as u32 cast wraps silently:

  • functor(T, f, 4294967296) — arity wraps to 0, constructs f() with wrong arity
  • functor(T, f, 1000000000) — tries to allocate ~1B Term::Var entries, causing OOM

A bounds check before the cast (e.g., if *arity < 0 || *arity > MAX_ARITY) would prevent both.


Bug 5 — number_codes/2 reverse mode: negative code values wrap via as u32

File: crates/prolog-core/src/solver.rs:1866 (and matching arm in try_exec_misc ~line 1862)

*code as u32 for a negative i64 wraps to a large value (e.g., -1i64 as u32 = 4294967295). char::from_u32(4294967295) returns None, so the element is silently dropped rather than producing an error. ISO requires type_error(character_code, Code) for invalid codes.


Minor — BuiltinResult::Between arm in try_exec_misc is dead code

File: crates/prolog-core/src/solver.rs:1699-1711

Both try_solve_once (line 1236) and try_solve_collecting (~line 1527) have explicit BuiltinResult::Between arms that match before the Ok(other) fallthrough. The Between arm inside try_exec_misc is therefore unreachable. No correctness impact, but the stale code is misleading.


Positive observations

  • VecDeque migration is mechanically correct throughout; pop_front() replaces remove(0) with O(1) semantics.
  • if-then/if-then-else fix correctly keeps bindings from the condition's first solution without re-evaluating it.
  • term_compare handles Float vs Integer cross-comparison (ISO 8.4.2.1: float < integer for same value), NaN ordering, and compound arity-first ordering correctly.
  • succ/2 and plus/3 use checked_add/checked_sub and raise proper overflow errors in the main solve() path.
  • copy_term/2 correctly walks through the substitution before copying.
## Code Review — PR #2 (Phase 5 builtins) The VecDeque migration is clean and the if-then/if-then-else fix (dropping the double-evaluation of the condition) is correct. Arithmetic uses checked_add/checked_sub throughout. Several real bugs below. --- ### Bug 1 — functor/3 in try_exec_misc drops Term::List and Term::Var cases **File:** `crates/prolog-core/src/solver.rs:1618-1636` The main `solve()` loop handles both `Term::List` (~line 437) and `Term::Var` (construction, ~line 475) correctly. `try_exec_misc` is called when `functor/3` appears inside `once/1`, `\+`, or `findall/3`. The `_ => Some(false)` catch-all silently returns failure for both cases: - `once(functor([a,b], F, A))` fails — should bind `F = '.', A = 2` - `once(functor(T, foo, 2))` fails — should construct `foo(_,_)` - `findall(A, functor([_|_], _, A), As)` gives `[]` instead of `[2]` --- ### Bug 2 — =../2 in try_exec_misc drops Term::List case **File:** `crates/prolog-core/src/solver.rs:1657-1697` Same asymmetry. The main `solve()` loop handles the list case at ~line 593 (decomposing `[H|T]` to `['.', H, T]`). The `_ => Some(false)` catch-all in `try_exec_misc` silently fails for `Term::List`: - `once([a,b] =.. L)` silently fails — should bind `L = ['.', a, [b]]` - `findall(L, ([a] =.. L), Ls)` gives `[]` Both bugs 1 and 2 stem from the same root: `try_exec_misc` was designed as a shared helper but the `Functor` and `Univ` match arms were not kept in sync with the main `solve()` match. --- ### Bug 3 — atom_chars/2 reverse mode uses byte-length for single-character test **File:** `crates/prolog-core/src/solver.rs:376, 1218, ~1462` `ch.len() == 1` tests byte length, not character count. A single-character atom consisting of a multi-byte UTF-8 codepoint (e.g., a 2-byte accented letter) has `ch.len() == 2` and is silently dropped from the assembled string. `atom_chars(X, [AccentedChar])` binds `X` to the empty atom instead of the correct atom. The same pattern appears in the `try_solve_once` and `try_solve_collecting` copies of this arm. Fix: `ch.chars().count() == 1` --- ### Bug 4 — functor/3 construction: *arity as u32 silently truncates large arities **File:** `crates/prolog-core/src/solver.rs:501` The guard `*arity > 0` passes for any positive `i64`. The `as u32` cast wraps silently: - `functor(T, f, 4294967296)` — arity wraps to 0, constructs `f()` with wrong arity - `functor(T, f, 1000000000)` — tries to allocate ~1B `Term::Var` entries, causing OOM A bounds check before the cast (e.g., `if *arity < 0 || *arity > MAX_ARITY`) would prevent both. --- ### Bug 5 — number_codes/2 reverse mode: negative code values wrap via as u32 **File:** `crates/prolog-core/src/solver.rs:1866` (and matching arm in `try_exec_misc` ~line 1862) `*code as u32` for a negative `i64` wraps to a large value (e.g., `-1i64 as u32 = 4294967295`). `char::from_u32(4294967295)` returns `None`, so the element is silently dropped rather than producing an error. ISO requires `type_error(character_code, Code)` for invalid codes. --- ### Minor — BuiltinResult::Between arm in try_exec_misc is dead code **File:** `crates/prolog-core/src/solver.rs:1699-1711` Both `try_solve_once` (line 1236) and `try_solve_collecting` (~line 1527) have explicit `BuiltinResult::Between` arms that match before the `Ok(other)` fallthrough. The `Between` arm inside `try_exec_misc` is therefore unreachable. No correctness impact, but the stale code is misleading. --- ### Positive observations - VecDeque migration is mechanically correct throughout; `pop_front()` replaces `remove(0)` with O(1) semantics. - if-then/if-then-else fix correctly keeps bindings from the condition's first solution without re-evaluating it. - `term_compare` handles Float vs Integer cross-comparison (ISO 8.4.2.1: float < integer for same value), NaN ordering, and compound arity-first ordering correctly. - `succ/2` and `plus/3` use `checked_add`/`checked_sub` and raise proper overflow errors in the main `solve()` path. - `copy_term/2` correctly walks through the substitution before copying.
claude[bot] commented 2026-03-02 23:21:02 +00:00 (Migrated from github.com)

PR Review - Phase 5 Builtins

All 203 tests pass, and the VecDeque conversion for goal lists is a clean O(1) win. A few correctness bugs I am confident about:


Bug 1 - between/3: unchecked integer overflow in solve() [solver.rs:665]

The choice-point path does:

let new_low = Term::Integer(low + 1);

low + 1 is an unchecked i64 addition. When low == i64::MAX this panics in debug mode or silently wraps in release. The other two between paths (try_solve_once and try_solve_collecting) use a range loop which avoids that specific panic, but see Bug 4.

Fix: low.checked_add(1) and propagate as a runtime error.


Bug 2 - once/1 inside findall collects all solutions instead of one [solver.rs:1422-1426]

Ok(BuiltinResult::Once(inner_goal)) => {
    let walked = self.subst.walk(&inner_goal);
    goal_list.push_front(walked);  // wrong
    return self.try_solve_collecting(goal_list, template, results);
}

try_solve_collecting enumerates ALL solutions. Pushing inner_goal directly onto the goal list means once(member(X,[a,b,c])) behaves identically to member(X,[a,b,c]) inside a findall, collecting [a,b,c] instead of the correct [a].

The fix is to call try_solve_once(vec![walked]), keep its bindings on success, and then continue collecting:

Ok(BuiltinResult::Once(inner_goal)) => {
    let walked = self.subst.walk(&inner_goal);
    if self.try_solve_once(vec![walked]) {
        return self.try_solve_collecting(goal_list, template, results);
    }
    return false;
}

There is no test for findall(X, once(member(X,[a,b,c])), L).


Bug 3 - var_counter not restored after NAF inner-goal in try_solve_once and try_solve_collecting [solver.rs:1098-1106, 1360-1368]

In solve() the NAF branch saves and restores var_counter (lines 190, 198):

let saved_counter = self.var_counter;
let inner_result = self.try_solve_once(vec![inner_goal]);
self.subst.undo_to(mark);
self.var_counter = saved_counter;  // restored

In try_solve_once (line 1098) and try_solve_collecting (line 1360) the restore is missing:

let mark = self.subst.trail_mark();
let inner_result = self.try_solve_once(vec![inner]);
self.subst.undo_to(mark);
// var_counter NOT restored

try_solve_once calls rename_clause which increments var_counter. Each + call permanently inflates the counter. While exhausting the VarId space in practice is unlikely, the missing restore is inconsistent with the save/restore invariant used everywhere else in the solver.


Bug 4 - between/3 in try_solve_once / try_solve_collecting: no iteration bound [solver.rs:1251, 1507]

for val in *low..=*high {

+ between(0, 1_000_000_000, _) or findall(X, between(0, 1_000_000_000, X), _) iterates a billion times with no guard. The main solve() path avoids this because it creates recursive choice points bounded by max_depth, but these helper paths bypass that guard entirely.

A simple mitigation is to check high - low against a reasonable cap before entering the loop.


Minor - sort/2 dedup closure semantics [solver.rs:808-810]

elems.dedup_by(|a, b| term_compare(a, b, &self.interner) == std::cmp::Ordering::Equal);

dedup_by(|a, b|) removes a (the later element) and retains b (the earlier one). The comparison is symmetric so this is correct today, but the argument naming convention is easy to confuse with dedup_by_key. Worth a clarifying comment.


No issues found in

  • copy_term_impl: correctly walks the substitution recursively at each level before copying.
  • Tokenizer for @< / @> / @=< / @>=: lookahead order correctly handles all four operators without ambiguity.
  • succ/2 bounds checks: correctly rejects negative predecessors (succ(_, 0)) and both-unbound.
  • If-then-else binding fix: truncating only the choice stack (not undoing the substitution) for the then-branch is the correct approach.
  • arith_mod: pre-existing truncated vs. floored sign mismatch ((-7) mod 3 gives -1 not ISO 2); not introduced by this PR.
## PR Review - Phase 5 Builtins All 203 tests pass, and the VecDeque conversion for goal lists is a clean O(1) win. A few correctness bugs I am confident about: --- ### Bug 1 - between/3: unchecked integer overflow in solve() [solver.rs:665] The choice-point path does: ```rust let new_low = Term::Integer(low + 1); ``` low + 1 is an unchecked i64 addition. When low == i64::MAX this panics in debug mode or silently wraps in release. The other two between paths (try_solve_once and try_solve_collecting) use a range loop which avoids that specific panic, but see Bug 4. **Fix:** low.checked_add(1) and propagate as a runtime error. --- ### Bug 2 - once/1 inside findall collects all solutions instead of one [solver.rs:1422-1426] ```rust Ok(BuiltinResult::Once(inner_goal)) => { let walked = self.subst.walk(&inner_goal); goal_list.push_front(walked); // wrong return self.try_solve_collecting(goal_list, template, results); } ``` try_solve_collecting enumerates ALL solutions. Pushing inner_goal directly onto the goal list means once(member(X,[a,b,c])) behaves identically to member(X,[a,b,c]) inside a findall, collecting [a,b,c] instead of the correct [a]. The fix is to call try_solve_once(vec![walked]), keep its bindings on success, and then continue collecting: ```rust Ok(BuiltinResult::Once(inner_goal)) => { let walked = self.subst.walk(&inner_goal); if self.try_solve_once(vec![walked]) { return self.try_solve_collecting(goal_list, template, results); } return false; } ``` There is no test for findall(X, once(member(X,[a,b,c])), L). --- ### Bug 3 - var_counter not restored after NAF inner-goal in try_solve_once and try_solve_collecting [solver.rs:1098-1106, 1360-1368] In solve() the NAF branch saves and restores var_counter (lines 190, 198): ```rust let saved_counter = self.var_counter; let inner_result = self.try_solve_once(vec![inner_goal]); self.subst.undo_to(mark); self.var_counter = saved_counter; // restored ``` In try_solve_once (line 1098) and try_solve_collecting (line 1360) the restore is missing: ```rust let mark = self.subst.trail_mark(); let inner_result = self.try_solve_once(vec![inner]); self.subst.undo_to(mark); // var_counter NOT restored ``` try_solve_once calls rename_clause which increments var_counter. Each \+ call permanently inflates the counter. While exhausting the VarId space in practice is unlikely, the missing restore is inconsistent with the save/restore invariant used everywhere else in the solver. --- ### Bug 4 - between/3 in try_solve_once / try_solve_collecting: no iteration bound [solver.rs:1251, 1507] ```rust for val in *low..=*high { ``` \+ between(0, 1_000_000_000, _) or findall(X, between(0, 1_000_000_000, X), _) iterates a billion times with no guard. The main solve() path avoids this because it creates recursive choice points bounded by max_depth, but these helper paths bypass that guard entirely. A simple mitigation is to check high - low against a reasonable cap before entering the loop. --- ### Minor - sort/2 dedup closure semantics [solver.rs:808-810] ```rust elems.dedup_by(|a, b| term_compare(a, b, &self.interner) == std::cmp::Ordering::Equal); ``` dedup_by(|a, b|) removes a (the later element) and retains b (the earlier one). The comparison is symmetric so this is correct today, but the argument naming convention is easy to confuse with dedup_by_key. Worth a clarifying comment. --- ### No issues found in - copy_term_impl: correctly walks the substitution recursively at each level before copying. - Tokenizer for @< / @> / @=< / @>=: lookahead order correctly handles all four operators without ambiguity. - succ/2 bounds checks: correctly rejects negative predecessors (succ(_, 0)) and both-unbound. - If-then-else binding fix: truncating only the choice stack (not undoing the substitution) for the then-branch is the correct approach. - arith_mod: pre-existing truncated vs. floored sign mismatch ((-7) mod 3 gives -1 not ISO 2); not introduced by this PR.
claude[bot] commented 2026-03-03 01:55:29 +00:00 (Migrated from github.com)

PR #2 Review — Phase 5 Built-ins

Reviewed builtins.rs, solver.rs, and tests/integration.rs. The implementation is well-structured overall. Issues below are ordered by severity. Only confirmed bugs/semantic gaps are listed.


Bugs

1. Float formatting in number_chars/2 and number_codes/2 drops .0
solver.rs lines 849 and 931:

let s = format!("{}", f);

Rust's default float display prints 1.0_f64 as "1", not "1.0". ISO number_chars(1.0, X) must produce ['1','.','0']. As-is, number_chars(1.0, X), number_chars(Y, X) gives Y = 1 (integer), not Y = 1.0. Fix: use format!("{:e}", f) or check f.fract() == 0.0 and append ".0" explicitly. Same bug at solver.rs:1846 and 1904 in the try_exec_misc mirror.

2. mod uses truncated remainder instead of ISO floored modulo
builtins.rs line 580:

(ArithVal::Int(a), ArithVal::Int(b)) => Ok(ArithVal::Int(a % b)),

ISO defines mod as floored division remainder. Rust % is truncated. For (-7) mod 2, ISO requires 1; this returns -1. Fix: ((a % b) + b) % b (using checked variants), or use i64::rem_euclid and adjust sign to match the dividend's sign convention for ISO floor-mod.

3. self.depth is a step counter, not a call-stack depth counter
solver.rs lines 156–160:

self.depth += 1;
if self.depth > self.max_depth { ... }

depth is incremented every goal-resolution step and never decremented (confirmed: it is only written at line 156). A query like findall(X, between(1, 10001, X), L) will hit the 10,000-step error without being infinitely recursive. The name depth and the error message "Maximum recursion depth exceeded" are both misleading — this is really a fuel/step limit. Either rename it steps and document it as such, or implement a proper call-stack depth counter that decrements on return.


Safety

4. try_solve_once / try_solve_collecting have no recursion depth limit
solver.rs lines 1090 and 1345: neither function reads or updates self.depth. Both are mutually recursive with themselves and with user-defined clause matching. A left-recursive rule called inside \+ or findall will recurse into native Rust stack frames without any limit, eventually causing a stack overflow panic. The existing test_depth_limit unit test only exercises the main solve loop.

5. write/1 does not flush stdout
solver.rs lines 403–407:

print!("{}", s);
continue;

print! is line-buffered; output is not flushed until a newline or explicit flush. A sequence like write(hello), nl may appear fine in a terminal but will silently drop output in piped or redirected contexts. Add use std::io::Write; let _ = std::io::stdout().flush(); after the print!.


Prolog Semantics

6. succ/2 raises an error in solve but silently fails in try_exec_misc
solver.rs line 729 (main path, negative first arg): SolveResult::Error("succ/2: argument must be non-negative").
solver.rs line 1785 (try_exec_misc, same case): Some(false) (silent failure).

So succ(-1, X) errors, but once(succ(-1, X)) silently fails. ISO should raise type_error uniformly. At minimum the two paths should agree.

7. number_chars/2 reverse mode accepts bare Term::Integer(0..=9) — non-ISO
solver.rs lines 882–884:

Term::Integer(n) if *n >= 0 && *n <= 9 => Some(n.to_string())

ISO number_chars/2 requires all list elements to be single-character atoms. Accepting bare integer terms 09 is a non-standard extension that silently widens the contract. The test test_number_chars_reverse at integration.rs line 717 exercises this non-standard path with [4, 5, 6] (integers), not ['4','5','6'] (atoms). This extension also creates a round-trip inconsistency: the forward direction produces atoms, so number_chars(456, X), number_chars(Y, X) goes through the atom path, but a hand-constructed list [4,5,6] takes the integer path.

8. =../2 silently backtracks instead of raising type_error when functor is a non-atom with args
solver.rs around line 610: when the list in T =.. [42, a, b] has length > 1 and the head is not an atom, the code falls through to return self.backtrack(). ISO 8.5.3 requires type_error(atom, F) in this case.

9. functor/3 with negative arity gives wrong error message
solver.rs line 523: A call like functor(T, f, -1) reaches the fall-through at return SolveResult::Error("functor/3: insufficient arguments") rather than ISO's type_error(integer, Arity) (or at least a message that says "arity must be non-negative").


Test Coverage Gaps

  • No test for number_chars or number_codes with a float argument (the float formatting bug in issue #1 above would be caught here).
  • test_number_chars_reverse (integration.rs:717) uses [4, 5, 6] (integers) — should use ['4', '5', '6'] to test the ISO-standard path.
  • No test for functor(T, f, -1) (negative arity error).
  • No test for compare(Order, X, Y) where X and Y are distinct unbound variables (unbound variables have a defined ISO standard order based on internal identity).
## PR #2 Review — Phase 5 Built-ins Reviewed `builtins.rs`, `solver.rs`, and `tests/integration.rs`. The implementation is well-structured overall. Issues below are ordered by severity. Only confirmed bugs/semantic gaps are listed. --- ### Bugs **1. Float formatting in `number_chars/2` and `number_codes/2` drops `.0`** `solver.rs` lines 849 and 931: ```rust let s = format!("{}", f); ``` Rust's default float display prints `1.0_f64` as `"1"`, not `"1.0"`. ISO `number_chars(1.0, X)` must produce `['1','.','0']`. As-is, `number_chars(1.0, X), number_chars(Y, X)` gives `Y = 1` (integer), not `Y = 1.0`. Fix: use `format!("{:e}", f)` or check `f.fract() == 0.0` and append `".0"` explicitly. Same bug at `solver.rs:1846` and `1904` in the `try_exec_misc` mirror. **2. `mod` uses truncated remainder instead of ISO floored modulo** `builtins.rs` line 580: ```rust (ArithVal::Int(a), ArithVal::Int(b)) => Ok(ArithVal::Int(a % b)), ``` ISO defines `mod` as floored division remainder. Rust `%` is truncated. For `(-7) mod 2`, ISO requires `1`; this returns `-1`. Fix: `((a % b) + b) % b` (using `checked` variants), or use `i64::rem_euclid` and adjust sign to match the dividend's sign convention for ISO floor-mod. **3. `self.depth` is a step counter, not a call-stack depth counter** `solver.rs` lines 156–160: ```rust self.depth += 1; if self.depth > self.max_depth { ... } ``` `depth` is incremented every goal-resolution step and **never decremented** (confirmed: it is only written at line 156). A query like `findall(X, between(1, 10001, X), L)` will hit the 10,000-step error without being infinitely recursive. The name `depth` and the error message `"Maximum recursion depth exceeded"` are both misleading — this is really a fuel/step limit. Either rename it `steps` and document it as such, or implement a proper call-stack depth counter that decrements on return. --- ### Safety **4. `try_solve_once` / `try_solve_collecting` have no recursion depth limit** `solver.rs` lines 1090 and 1345: neither function reads or updates `self.depth`. Both are mutually recursive with themselves and with user-defined clause matching. A left-recursive rule called inside `\+` or `findall` will recurse into native Rust stack frames without any limit, eventually causing a stack overflow panic. The existing `test_depth_limit` unit test only exercises the main `solve` loop. **5. `write/1` does not flush stdout** `solver.rs` lines 403–407: ```rust print!("{}", s); continue; ``` `print!` is line-buffered; output is not flushed until a newline or explicit flush. A sequence like `write(hello), nl` may appear fine in a terminal but will silently drop output in piped or redirected contexts. Add `use std::io::Write; let _ = std::io::stdout().flush();` after the `print!`. --- ### Prolog Semantics **6. `succ/2` raises an error in `solve` but silently fails in `try_exec_misc`** `solver.rs` line 729 (main path, negative first arg): `SolveResult::Error("succ/2: argument must be non-negative")`. `solver.rs` line 1785 (`try_exec_misc`, same case): `Some(false)` (silent failure). So `succ(-1, X)` errors, but `once(succ(-1, X))` silently fails. ISO should raise `type_error` uniformly. At minimum the two paths should agree. **7. `number_chars/2` reverse mode accepts bare `Term::Integer(0..=9)` — non-ISO** `solver.rs` lines 882–884: ```rust Term::Integer(n) if *n >= 0 && *n <= 9 => Some(n.to_string()) ``` ISO `number_chars/2` requires all list elements to be single-character atoms. Accepting bare integer terms `0`–`9` is a non-standard extension that silently widens the contract. The test `test_number_chars_reverse` at integration.rs line 717 exercises this non-standard path with `[4, 5, 6]` (integers), not `['4','5','6']` (atoms). This extension also creates a round-trip inconsistency: the forward direction produces atoms, so `number_chars(456, X), number_chars(Y, X)` goes through the atom path, but a hand-constructed list `[4,5,6]` takes the integer path. **8. `=../2` silently backtracks instead of raising `type_error` when functor is a non-atom with args** `solver.rs` around line 610: when the list in `T =.. [42, a, b]` has length > 1 and the head is not an atom, the code falls through to `return self.backtrack()`. ISO 8.5.3 requires `type_error(atom, F)` in this case. **9. `functor/3` with negative arity gives wrong error message** `solver.rs` line 523: A call like `functor(T, f, -1)` reaches the fall-through at `return SolveResult::Error("functor/3: insufficient arguments")` rather than ISO's `type_error(integer, Arity)` (or at least a message that says "arity must be non-negative"). --- ### Test Coverage Gaps - No test for `number_chars` or `number_codes` with a float argument (the float formatting bug in issue #1 above would be caught here). - `test_number_chars_reverse` (integration.rs:717) uses `[4, 5, 6]` (integers) — should use `['4', '5', '6']` to test the ISO-standard path. - No test for `functor(T, f, -1)` (negative arity error). - No test for `compare(Order, X, Y)` where `X` and `Y` are distinct unbound variables (unbound variables have a defined ISO standard order based on internal identity).
claude[bot] commented 2026-03-03 02:52:37 +00:00 (Migrated from github.com)

test comment

test comment
claude[bot] commented 2026-03-03 02:52:57 +00:00 (Migrated from github.com)

test

test
claude[bot] commented 2026-03-03 02:53:33 +00:00 (Migrated from github.com)

line1

line1
claude[bot] commented 2026-03-03 02:54:11 +00:00 (Migrated from github.com)

test with solve_fn reference

test with solve_fn reference
claude[bot] commented 2026-03-03 02:54:32 +00:00 (Migrated from github.com)

Bug 1: succ/2 succ_X_0 errors instead of failing. solver.rs lines 745-766. When succ_X_0 is called with X unbound, match arm 4 of shape '(, Term::Integer())' matches and returns SolveResult::Error. ISO semantics say this should FAIL rather than error.

Bug 1: succ/2 succ_X_0 errors instead of failing. solver.rs lines 745-766. When succ_X_0 is called with X unbound, match arm 4 of shape '(_, Term::Integer(_))' matches and returns SolveResult::Error. ISO semantics say this should FAIL rather than error.
claude[bot] commented 2026-03-03 02:54:35 +00:00 (Migrated from github.com)

test: try_solve_once returns false on step limit

test: try_solve_once returns false on step limit
claude[bot] commented 2026-03-03 02:54:38 +00:00 (Migrated from github.com)

test: map().collect approach fix

test: map().collect approach fix
claude[bot] commented 2026-03-03 02:54:42 +00:00 (Migrated from github.com)

test: checked_add(1) approach

test: checked_add(1) approach
claude[bot] commented 2026-03-03 02:54:45 +00:00 (Migrated from github.com)

test: solve() and collect_list() and is_proper_list()

test: solve() and collect_list() and is_proper_list()
claude[bot] commented 2026-03-03 02:54:48 +00:00 (Migrated from github.com)

test newline
second line
third line

  • list item 1
  • list item 2
test newline second line third line - list item 1 - list item 2
claude[bot] commented 2026-03-03 02:55:07 +00:00 (Migrated from github.com)

PR Review - Phase 5 Builtins

Reviewed builtins.rs, solver.rs, unify.rs, parser.rs, and tests/integration.rs.

The overall structure is sound: trail-based backtracking is correct, the occurs check is present, cut semantics look right, and arithmetic uses checked ops throughout. Issues below.


Bug 1: succ/2 - succ(X, 0) errors instead of failing

solver.rs lines 745-766

When succ(X, 0) is called with X unbound, the match arms evaluate as follows:

  • Arm 1 (Term::Integer(x), _) with guard x >= 0: no match since X is a Var
  • Arm 2 (_, Term::Integer(s)) with guard s > 0: pattern matches but 0 > 0 is false
  • Arm 3 (Term::Integer(_), _): no match since X is a Var
  • Arm 4 (, Term::Integer()): matches, returns SolveResult::Error('succ/2: successor must be positive')

ISO/Edinburgh semantics: succ(X, 0) should FAIL (there is no non-negative predecessor of 0), not produce a runtime error. The equivalent case in try_exec_misc at line 1810 correctly returns Some(false) (failure), so the two handlers are inconsistent.


Bug 2: number_chars/2 and number_codes/2 silently drop non-conforming list elements

solver.rs lines 891-913 (main solve), 966-990 (codes), 1887-1913 (try_exec_misc)

In the reverse direction (list to number), filter_map silently drops any element that is not a single-character atom for number_chars, or not a valid Unicode codepoint integer for number_codes.

Concrete consequence: number_chars(X, ['1', bad_atom, '2']) succeeds with X = 12 instead of failing. Similarly number_codes(X, [49, foo, 50]) gives X = 12 instead of failing because foo is not an integer and gets silently skipped.

Fix: replace filter_map with map().collect() returning Option so that any non-conforming element causes the predicate to fail. This bug affects all three execution paths: the main solve loop, try_solve_once (used for negation-as-failure and if-then conditions), and try_exec_misc (used inside findall).


Bug 3: negation-as-failure silently succeeds when inner goal hits step limit

solver.rs lines 1114-1117

The main solve loop correctly returns SolveResult::Error on step-limit at lines 168-173. However try_solve_once returns false, making an aborted goal (step limit exceeded) indistinguishable from a genuinely failing goal. Since try_solve_once evaluates the inner goal of negation-as-failure, this means negation of an expensive true goal can produce a wrong answer when the proof tree is cut short by the step counter.


Safety: is_proper_list and term_compare use recursive list traversal

builtins.rs lines 808-813

is_proper_list recurses once per list element. A list of roughly 8000 elements can stack-overflow on typical systems. Unlike collect_list which is already iterative, is_proper_list needs to be rewritten as an iterative loop.

builtins.rs lines 724-726 show the same pattern inside term_compare for list-vs-list comparison. sort/2 and msort/2 call term_compare, so sorting a list whose elements are themselves long lists triggers recursion proportional to inner list lengths.


Missing test coverage

  • succ(X, 0) should fail; no test covers this code path
  • number_chars(X, ['1', bad_atom, '2']) should fail
  • number_codes(X, [49, foo, 50]) should fail
  • copy_term(f(X, X), Y) aliasing: two occurrences of X should map to the same fresh variable in the copy
  • between(5, 3, X) should fail; regression guard for the low > high check at line 680

Minor notes (not blocking)

  • arg/3 with n <= 0 silently fails at solver.rs:562-571 rather than throwing a type error as ISO specifies
  • functor/3 with arity < 0: returns an error in the main solve handler at lines 512-515 but returns silent failure in try_exec_misc at line 1726; harmless inconsistency
  • var_counter starts at 1000 at solver.rs:84; a query with 1000 or more distinct variables would collide with renamed clause variables; not a practical concern but worth documenting

Generated with Claude Code

## PR Review - Phase 5 Builtins Reviewed builtins.rs, solver.rs, unify.rs, parser.rs, and tests/integration.rs. The overall structure is sound: trail-based backtracking is correct, the occurs check is present, cut semantics look right, and arithmetic uses checked ops throughout. Issues below. --- ### Bug 1: succ/2 - succ(X, 0) errors instead of failing solver.rs lines 745-766 When succ(X, 0) is called with X unbound, the match arms evaluate as follows: - Arm 1 (Term::Integer(x), _) with guard x >= 0: no match since X is a Var - Arm 2 (_, Term::Integer(s)) with guard s > 0: pattern matches but 0 > 0 is false - Arm 3 (Term::Integer(_), _): no match since X is a Var - Arm 4 (_, Term::Integer(_)): matches, returns SolveResult::Error('succ/2: successor must be positive') ISO/Edinburgh semantics: succ(X, 0) should FAIL (there is no non-negative predecessor of 0), not produce a runtime error. The equivalent case in try_exec_misc at line 1810 correctly returns Some(false) (failure), so the two handlers are inconsistent. --- ### Bug 2: number_chars/2 and number_codes/2 silently drop non-conforming list elements solver.rs lines 891-913 (main solve), 966-990 (codes), 1887-1913 (try_exec_misc) In the reverse direction (list to number), filter_map silently drops any element that is not a single-character atom for number_chars, or not a valid Unicode codepoint integer for number_codes. Concrete consequence: number_chars(X, ['1', bad_atom, '2']) succeeds with X = 12 instead of failing. Similarly number_codes(X, [49, foo, 50]) gives X = 12 instead of failing because foo is not an integer and gets silently skipped. Fix: replace filter_map with map().collect() returning Option so that any non-conforming element causes the predicate to fail. This bug affects all three execution paths: the main solve loop, try_solve_once (used for negation-as-failure and if-then conditions), and try_exec_misc (used inside findall). --- ### Bug 3: negation-as-failure silently succeeds when inner goal hits step limit solver.rs lines 1114-1117 The main solve loop correctly returns SolveResult::Error on step-limit at lines 168-173. However try_solve_once returns false, making an aborted goal (step limit exceeded) indistinguishable from a genuinely failing goal. Since try_solve_once evaluates the inner goal of negation-as-failure, this means negation of an expensive true goal can produce a wrong answer when the proof tree is cut short by the step counter. --- ### Safety: is_proper_list and term_compare use recursive list traversal builtins.rs lines 808-813 is_proper_list recurses once per list element. A list of roughly 8000 elements can stack-overflow on typical systems. Unlike collect_list which is already iterative, is_proper_list needs to be rewritten as an iterative loop. builtins.rs lines 724-726 show the same pattern inside term_compare for list-vs-list comparison. sort/2 and msort/2 call term_compare, so sorting a list whose elements are themselves long lists triggers recursion proportional to inner list lengths. --- ### Missing test coverage - succ(X, 0) should fail; no test covers this code path - number_chars(X, ['1', bad_atom, '2']) should fail - number_codes(X, [49, foo, 50]) should fail - copy_term(f(X, X), Y) aliasing: two occurrences of X should map to the same fresh variable in the copy - between(5, 3, X) should fail; regression guard for the low > high check at line 680 --- ### Minor notes (not blocking) - arg/3 with n <= 0 silently fails at solver.rs:562-571 rather than throwing a type error as ISO specifies - functor/3 with arity < 0: returns an error in the main solve handler at lines 512-515 but returns silent failure in try_exec_misc at line 1726; harmless inconsistency - var_counter starts at 1000 at solver.rs:84; a query with 1000 or more distinct variables would collide with renamed clause variables; not a practical concern but worth documenting Generated with Claude Code
claude[bot] commented 2026-03-04 01:25:40 +00:00 (Migrated from github.com)

Phase 5 Code Review

Reviewed the Phase 5 built-in predicate additions. Four issues found, ranging from a crash-level bug to semantic correctness issues.


1. PANIC: arith_mod crashes on i64::MIN divisor

File: crates/prolog-core/src/builtins.rs lines 585–587

let r = a.rem_euclid(b.abs());  // panics in debug if b = i64::MIN
if *b < 0 && r != 0 {
    Ok(ArithVal::Int(r - b.abs()))  // second panic site
}

i64::MIN.abs() overflows i64 and panics in debug builds (wraps to i64::MIN in release, corrupting the result). Any query like X is 5 mod -9223372036854775808 will crash the process in debug mode.

The zero-divisor guard on line 581 only catches b = 0, not b = i64::MIN. Fix: add a case (ArithVal::Int(_), ArithVal::Int(i64::MIN)) => Err(...) before the general integer arm, or replace both .abs() calls with .checked_abs().unwrap_or(...).


2. atom_chars/2 reverse mode silently corrupts output

File: crates/prolog-core/src/solver.rs lines 383–396 (also duplicated in try_solve_once ~line 1263 and try_solve_collecting ~line 1397)

let s: String = elems
    .iter()
    .filter_map(|e| {
        if let Term::Atom(id) = e {
            let ch = self.interner.resolve(*id);
            if ch.chars().count() == 1 {
                Some(ch.to_string())
            } else {
                None  // silently dropped
            }
        } else {
            None  // silently dropped
        }
    })
    .collect();

Non-single-character atoms and non-atoms in the list are silently discarded rather than causing failure. atom_chars(X, [a, bc, d]) (where bc is a two-character atom) succeeds with X = ad instead of failing or throwing a type error. ISO 8.16.7 requires type_error(character, bc). The filter_map should be replaced with a plain map that short-circuits on any invalid element.

This bug is present in all three copies of the reverse-direction code: the main solve handler (~line 380), try_solve_once (~line 1260), and try_solve_collecting.


3. succ/2 overflow error silently becomes failure inside findall/\+

File: crates/prolog-core/src/solver.rssolve loop lines 739–743 vs try_exec_misc lines 1816–1819

In the main solve loop, succ(9223372036854775807, X) returns SolveResult::Error("succ/2: integer overflow"). In try_exec_misc (used by findall and negation-as-failure contexts), the same case returns Some(false):

// try_exec_misc — overflow silently becomes failure
(Term::Integer(x), _) if *x >= 0 => match x.checked_add(1) {
    Some(result) => Some(self.subst.unify(&s_arg, &Term::Integer(result))),
    None => Some(false),   // swallowed
},

So findall(X, succ(9223372036854775807, X), Xs) silently gives Xs = [] instead of propagating an error. The same asymmetry applies to plus/3 overflow in try_exec_misc (~lines 1831–1843).


4. format_float produces non-round-trippable strings for NaN and Infinity

File: crates/prolog-core/src/solver.rs lines 13–20

fn format_float(f: f64) -> String {
    let s = format!("{}", f);
    if s.contains('.') || s.contains('e') || s.contains('E') {
        s
    } else {
        format!("{}.0", s)
    }
}

format!("{}", f64::NAN) produces "NaN" and format!("{}", f64::INFINITY) produces "inf". Neither contains '.' or 'e', so the function appends .0, yielding "NaN.0" and "inf.0". Rust's f64 parser does not accept "NaN.0" or "inf.0", so number_chars(X, Chars) followed by number_chars(N, Chars) will silently fail to round-trip for these special values. Add explicit guards before the suffix logic.


Positive observations

  • The VecDeque migration correctly eliminates the O(n) remove(0)/insert(0) costs from the old Vec-based approach.
  • The IfThenElse/IfThen fix (keeping cond's bindings instead of undoing and re-running) is semantically correct and avoids double-execution of cond side-effects.
  • arith_mod ISO-sign semantics (result sign matches divisor) are correctly implemented for all non-i64::MIN inputs.
  • copy_term_fresh correctly deep-walks the substitution at each level via the per-node self.subst.walk() inside copy_term_impl.
  • between/3 choice-point chaining (bumping low+1 in the alternative goal) is the right approach for backtracking across solutions in the main solve loop.
  • term_compare correctly handles the list-as-./2 representation for cross-type comparisons.
## Phase 5 Code Review Reviewed the Phase 5 built-in predicate additions. Four issues found, ranging from a crash-level bug to semantic correctness issues. --- ### 1. PANIC: `arith_mod` crashes on `i64::MIN` divisor **File:** `crates/prolog-core/src/builtins.rs` lines 585–587 ```rust let r = a.rem_euclid(b.abs()); // panics in debug if b = i64::MIN if *b < 0 && r != 0 { Ok(ArithVal::Int(r - b.abs())) // second panic site } ``` `i64::MIN.abs()` overflows `i64` and panics in debug builds (wraps to `i64::MIN` in release, corrupting the result). Any query like `X is 5 mod -9223372036854775808` will crash the process in debug mode. The zero-divisor guard on line 581 only catches `b = 0`, not `b = i64::MIN`. Fix: add a case `(ArithVal::Int(_), ArithVal::Int(i64::MIN)) => Err(...)` before the general integer arm, or replace both `.abs()` calls with `.checked_abs().unwrap_or(...)`. --- ### 2. `atom_chars/2` reverse mode silently corrupts output **File:** `crates/prolog-core/src/solver.rs` lines 383–396 (also duplicated in `try_solve_once` ~line 1263 and `try_solve_collecting` ~line 1397) ```rust let s: String = elems .iter() .filter_map(|e| { if let Term::Atom(id) = e { let ch = self.interner.resolve(*id); if ch.chars().count() == 1 { Some(ch.to_string()) } else { None // silently dropped } } else { None // silently dropped } }) .collect(); ``` Non-single-character atoms and non-atoms in the list are silently discarded rather than causing failure. `atom_chars(X, [a, bc, d])` (where `bc` is a two-character atom) succeeds with `X = ad` instead of failing or throwing a type error. ISO 8.16.7 requires `type_error(character, bc)`. The `filter_map` should be replaced with a plain `map` that short-circuits on any invalid element. This bug is present in all three copies of the reverse-direction code: the main `solve` handler (~line 380), `try_solve_once` (~line 1260), and `try_solve_collecting`. --- ### 3. `succ/2` overflow error silently becomes failure inside `findall`/`\+` **File:** `crates/prolog-core/src/solver.rs` — `solve` loop lines 739–743 vs `try_exec_misc` lines 1816–1819 In the main `solve` loop, `succ(9223372036854775807, X)` returns `SolveResult::Error("succ/2: integer overflow")`. In `try_exec_misc` (used by `findall` and negation-as-failure contexts), the same case returns `Some(false)`: ```rust // try_exec_misc — overflow silently becomes failure (Term::Integer(x), _) if *x >= 0 => match x.checked_add(1) { Some(result) => Some(self.subst.unify(&s_arg, &Term::Integer(result))), None => Some(false), // swallowed }, ``` So `findall(X, succ(9223372036854775807, X), Xs)` silently gives `Xs = []` instead of propagating an error. The same asymmetry applies to `plus/3` overflow in `try_exec_misc` (~lines 1831–1843). --- ### 4. `format_float` produces non-round-trippable strings for NaN and Infinity **File:** `crates/prolog-core/src/solver.rs` lines 13–20 ```rust fn format_float(f: f64) -> String { let s = format!("{}", f); if s.contains('.') || s.contains('e') || s.contains('E') { s } else { format!("{}.0", s) } } ``` `format!("{}", f64::NAN)` produces `"NaN"` and `format!("{}", f64::INFINITY)` produces `"inf"`. Neither contains `'.'` or `'e'`, so the function appends `.0`, yielding `"NaN.0"` and `"inf.0"`. Rust's `f64` parser does not accept `"NaN.0"` or `"inf.0"`, so `number_chars(X, Chars)` followed by `number_chars(N, Chars)` will silently fail to round-trip for these special values. Add explicit guards before the suffix logic. --- ### Positive observations - The `VecDeque` migration correctly eliminates the O(n) `remove(0)`/`insert(0)` costs from the old `Vec`-based approach. - The `IfThenElse`/`IfThen` fix (keeping cond's bindings instead of undoing and re-running) is semantically correct and avoids double-execution of cond side-effects. - `arith_mod` ISO-sign semantics (result sign matches divisor) are correctly implemented for all non-`i64::MIN` inputs. - `copy_term_fresh` correctly deep-walks the substitution at each level via the per-node `self.subst.walk()` inside `copy_term_impl`. - `between/3` choice-point chaining (bumping low+1 in the alternative goal) is the right approach for backtracking across solutions in the main solve loop. - `term_compare` correctly handles the list-as-`./2` representation for cross-type comparisons.
claude[bot] commented 2026-03-04 04:54:36 +00:00 (Migrated from github.com)

Phase 5 Code Review

Reviewed: builtins.rs, solver.rs, tokenizer.rs, parser.rs, tests/integration.rs.

Overall the phase is solid — the arith_mod floored-division fix, VecDeque goal list, and the if-then-else bindings rewrite are all correct. Several real bugs follow.


Bug 1 — between/3 silently fails for ranges larger than max_depth in negation and findall contexts

Files: crates/prolog-core/src/solver.rs:1299-1301 (try_solve_once) and solver.rs:1569-1571 (try_solve_collecting)

When range_size > max_depth the code returns false as if between has no solutions. This is wrong in two ways:

Negation-as-failure: \+ between(1, 20001, 1) calls try_solve_once for the inner goal. The range check fires (20001 > 10000), returns false, so \+ succeeds — but between(1, 20001, 1) is provable, so the negation should fail.

findall: findall(X, between(1, 20001, X), L) calls try_solve_collecting. Same early return gives L = [] instead of [1, 2, ..., 20001].

The existing test suite only exercises between with ranges of 5-10 elements (test_between_with_findall, test_between_conjunction_in_negation), so the default max_depth = 10_000 hides this. The fix is to drop the range guard and rely on the step counter, or special-case the already-bound X scenario (low <= X <= high is O(1) and needs no iteration).


Bug 2 — copy_term_impl is unbounded recursion; will stack-overflow on deep terms

File: crates/prolog-core/src/solver.rs:2004-2028

A list with N elements is represented as N nested List { head, tail } nodes. copy_term_impl recurses once per node via the Term::List arm (tail is a recursive call). A 5000-element list uses ~5000 stack frames. On the default 8 MB Rust thread stack this overflows around 50 000-100 000 elements, but even a few thousand can be reached in normal Prolog programs (msort or sort results fed into copy_term). There is no depth limit. The list-tail traversal should use an iterative loop, consistent with the iterative approach already used in is_proper_list and term_compare's list path.


Bug 3 — term_compare recurses into compound arguments without depth protection

File: crates/prolog-core/src/builtins.rs:719-726

The list-vs-list path is correctly made iterative (lines 728-754), but in the (Compound, Compound) arm the per-argument comparison at line 720 (term_compare(x, y, interner)) is still recursive. For f(f(f(...))) nested N levels deep this is O(N) Rust stack frames. sort/2, msort/2, and compare/3 all call term_compare, so a user can trigger this with sort([f(f(f(...))), ...], X). Consider an explicit stack or depth counter, consistent with the iterative approach already used for the list case.


Bug 4 — arg/3 type check is inconsistent between solve and try_exec_misc

Files: solver.rs:561-566 (main loop) vs. solver.rs:~1790-1810 (try_exec_misc)

In the main solve loop, arg(1, atom, X) when the second argument is neither Compound nor List falls through to return SolveResult::Error("arg/3: second argument must be compound"). In try_exec_misc (reached from try_solve_once and try_solve_collecting, i.e. inside \+ and findall), there is no explicit error arm so it falls through to Some(false) — a silent failure.

ISO 8.5.2 requires type_error(compound, atom) in all contexts. As-is, \+ arg(1, atom, X) succeeds (failure inverts to success) but direct arg(1, atom, X) errors. The try_exec_misc Arg branch should propagate an error for the non-compound, non-list case.


Verified correct (no action needed)

  • arith_mod ISO floored semantics: all four sign combinations produce correct results. The i64::MIN divisor guard is correct.
  • term_compare Float/Integer mixed ordering matches ISO 8.4.2.1 (float < integer at equal arithmetic value).
  • @=< / @>= tokenizer: peek_at(1) correctly addresses one position past the current cursor; all four @ operators tokenize correctly.
  • collect_list always called after subst.apply(); no unresolved-variable escape.
  • If-then-else binding preservation: try_solve_once modifies self.subst in place; bindings from the condition are correctly retained on success without undo_to. The previous double-execution of cond is correctly eliminated.
  • between/3 overflow guard in the main solve loop: checked_add(1) on line 692 is used; the choice-point-based implementation is correct.
  • sort/2 dedup: sort_by + dedup_by with term_compare == Equal correctly removes duplicates by ISO standard order.
  • copy_term variable aliasing (f(X,X) -> fresh f(A,A)): the var_map correctly shares one fresh id per original variable id, confirmed by test_copy_term_aliasing.
## Phase 5 Code Review Reviewed: `builtins.rs`, `solver.rs`, `tokenizer.rs`, `parser.rs`, `tests/integration.rs`. Overall the phase is solid — the `arith_mod` floored-division fix, `VecDeque` goal list, and the if-then-else bindings rewrite are all correct. Several real bugs follow. --- ### Bug 1 — `between/3` silently fails for ranges larger than `max_depth` in negation and findall contexts **Files:** `crates/prolog-core/src/solver.rs:1299-1301` (`try_solve_once`) and `solver.rs:1569-1571` (`try_solve_collecting`) When `range_size > max_depth` the code returns `false` as if `between` has no solutions. This is wrong in two ways: **Negation-as-failure:** `\+ between(1, 20001, 1)` calls `try_solve_once` for the inner goal. The range check fires (`20001 > 10000`), returns `false`, so `\+` succeeds — but `between(1, 20001, 1)` is provable, so the negation should **fail**. **findall:** `findall(X, between(1, 20001, X), L)` calls `try_solve_collecting`. Same early return gives `L = []` instead of `[1, 2, ..., 20001]`. The existing test suite only exercises `between` with ranges of 5-10 elements (`test_between_with_findall`, `test_between_conjunction_in_negation`), so the default `max_depth = 10_000` hides this. The fix is to drop the range guard and rely on the step counter, or special-case the already-bound `X` scenario (`low <= X <= high` is O(1) and needs no iteration). --- ### Bug 2 — `copy_term_impl` is unbounded recursion; will stack-overflow on deep terms **File:** `crates/prolog-core/src/solver.rs:2004-2028` A list with N elements is represented as N nested `List { head, tail }` nodes. `copy_term_impl` recurses once per node via the `Term::List` arm (tail is a recursive call). A 5000-element list uses ~5000 stack frames. On the default 8 MB Rust thread stack this overflows around 50 000-100 000 elements, but even a few thousand can be reached in normal Prolog programs (`msort` or `sort` results fed into `copy_term`). There is no depth limit. The list-tail traversal should use an iterative loop, consistent with the iterative approach already used in `is_proper_list` and `term_compare`'s list path. --- ### Bug 3 — `term_compare` recurses into compound arguments without depth protection **File:** `crates/prolog-core/src/builtins.rs:719-726` The list-vs-list path is correctly made iterative (lines 728-754), but in the `(Compound, Compound)` arm the per-argument comparison at line 720 (`term_compare(x, y, interner)`) is still recursive. For `f(f(f(...)))` nested N levels deep this is O(N) Rust stack frames. `sort/2`, `msort/2`, and `compare/3` all call `term_compare`, so a user can trigger this with `sort([f(f(f(...))), ...], X)`. Consider an explicit stack or depth counter, consistent with the iterative approach already used for the list case. --- ### Bug 4 — `arg/3` type check is inconsistent between `solve` and `try_exec_misc` **Files:** `solver.rs:561-566` (main loop) vs. `solver.rs:~1790-1810` (`try_exec_misc`) In the main `solve` loop, `arg(1, atom, X)` when the second argument is neither Compound nor List falls through to `return SolveResult::Error("arg/3: second argument must be compound")`. In `try_exec_misc` (reached from `try_solve_once` and `try_solve_collecting`, i.e. inside `\+` and `findall`), there is no explicit error arm so it falls through to `Some(false)` — a silent failure. ISO 8.5.2 requires `type_error(compound, atom)` in all contexts. As-is, `\+ arg(1, atom, X)` succeeds (failure inverts to success) but direct `arg(1, atom, X)` errors. The `try_exec_misc` `Arg` branch should propagate an error for the non-compound, non-list case. --- ### Verified correct (no action needed) - `arith_mod` ISO floored semantics: all four sign combinations produce correct results. The `i64::MIN` divisor guard is correct. - `term_compare` Float/Integer mixed ordering matches ISO 8.4.2.1 (float < integer at equal arithmetic value). - `@=<` / `@>=` tokenizer: `peek_at(1)` correctly addresses one position past the current cursor; all four `@` operators tokenize correctly. - `collect_list` always called after `subst.apply()`; no unresolved-variable escape. - If-then-else binding preservation: `try_solve_once` modifies `self.subst` in place; bindings from the condition are correctly retained on success without `undo_to`. The previous double-execution of `cond` is correctly eliminated. - `between/3` overflow guard in the main `solve` loop: `checked_add(1)` on line 692 is used; the choice-point-based implementation is correct. - `sort/2` dedup: `sort_by` + `dedup_by` with `term_compare == Equal` correctly removes duplicates by ISO standard order. - `copy_term` variable aliasing (`f(X,X)` -> fresh `f(A,A)`): the `var_map` correctly shares one fresh id per original variable id, confirmed by `test_copy_term_aliasing`.
claude[bot] commented 2026-03-04 21:05:50 +00:00 (Migrated from github.com)

Code Review Phase 5 Builtins

Issue 1 Occurs check enabled by default in =/2 ISO deviation [Medium]

crates/prolog-core/src/unify.rs lines 97-103

The standard unify method runs a full occurs check on every variable binding. ISO and Edinburgh Prolog both specify that plain =/2 does NOT perform an occurs check - that is reserved for unify_with_occurs_check/2. Enabling it by default means X = f(X) fails where standard Prolog succeeds, breaking difference-list idioms and any program that intentionally unifies a variable with a term containing it. The occurs check also adds O(n) overhead to every variable binding.


Issue 2 succ/2 and plus/3 behave differently inside negation/once/findall [Medium]

crates/prolog-core/src/solver.rs lines 771-775 vs 1843-1844
crates/prolog-core/src/solver.rs lines 822-825 vs 1864

In the main solve loop, calling succ(X, Y) or plus(X, Y, Z) with all arguments unbound hits the wildcard arm and returns SolveResult::Error. But in try_exec_misc (used by +, once, findall, and ->) the equivalent arm is Some(false) - a silent failure. The error is swallowed.

Consequence: + succ(X, Y) incorrectly succeeds because the inner instantiation error is treated as inner-goal failure, so negation-as-failure flips it. The same query at top level produces an error. This violates transparent + semantics.


Issue 3 try_exec_misc catch-all silently suppresses unhandled builtins [Medium]

crates/prolog-core/src/solver.rs line 2006, lines 1325-1334, lines 1601-1609

try_exec_misc ends with _ => None, and every call site treats None as failure. Any BuiltinResult variant that exists in exec_builtin but is not handled in try_exec_misc will silently fail (rather than execute) inside +, once, findall, and ->. Adding a new builtin to exec_builtin and forgetting try_exec_misc produces silent wrong answers with no compile-time warning.


Issue 4 copy_term_fresh variable counter not guaranteed disjoint from query variables [Low]

crates/prolog-core/src/solver.rs lines 87, 101

The solver var_counter is initialised to 1000. The query parser assigns variable IDs starting from 0. The separation relies entirely on the assumption that no single query has >= 1000 distinct variables. This is not enforced: there is no assertion or mechanism to initialise var_counter to max(query_max_var_id) + 1. A pathological query or a large generated term could cause a fresh copy-term variable to alias a live query variable.


Issue 5 copy_term_impl iterative list traversal falls back to recursion for variable-threaded lists [Low]

crates/prolog-core/src/solver.rs lines 2040-2063

The iterative traversal for Term::List only advances the loop cursor to the un-walked tail (current = tail at line 2063). On the next iteration, if tail is Term::Var(id) bound to another Term::List, the match at line 2040 sees Term::Var(id) not Term::List, and falls into the wildcard arm, calling copy_term_impl recursively. The iterative path is only taken for immediately-concrete list cells; any variable-indirected spine reverts to deep recursion. A list of 10000 elements threaded through variables could stack-overflow.


Issue 6 number_chars/2 silently fails on non-character list elements instead of type_error [Low]

crates/prolog-core/src/solver.rs lines 900-926

When the char list contains non-atom elements (e.g. number_chars(X, [1, 2, 3]) with unquoted integers), the wildcard None arm at line 912 causes the string accumulation to return None, and execution falls through to self.backtrack() - a silent failure. ISO requires type_error(character, 1). The existing test at integration.rs lines 1098-1103 covers bad_atom but not integer elements.


Issue 7 number_chars(123, X) test uses ambiguous expected value [Low]

tests/integration.rs lines 711-712

assert_eq!(result, Some("[1, 2, 3]".to_string()));

term_to_string renders Term::Atom without quotes, so the char-atom '1' and the integer Term::Integer(1) produce the same display string. The test passes even if the implementation returns integers instead of character atoms. A quoting-aware renderer or direct Term inspection would make the test meaningful.


Summary

Issue 1: unify.rs:97-103 - Occurs check on every unification, ISO =/2 must not occurs-check - Severity: Medium
Issue 2: solver.rs:771-775 vs 1843-1844 and 822-825 vs 1864 - succ/2 and plus/3 errors swallowed as failure inside +/once/findall - Severity: Medium
Issue 3: solver.rs:2006, 1325-1334, 1601-1609 - try_exec_misc catch-all silently fails any builtin not present in that function - Severity: Medium
Issue 4: solver.rs:87,101 - copy_term_fresh counter relies on magic constant 1000 being greater than max query vars - Severity: Low
Issue 5: solver.rs:2040-2063 - copy_term_impl iterative list traversal degrades to recursion on var-threaded spines - Severity: Low
Issue 6: solver.rs:900-926 - number_chars/2 silently fails on integer-typed list elements instead of type_error - Severity: Low
Issue 7: integration.rs:711-712 - number_chars test expected value is ambiguous (atom vs integer look identical) - Severity: Low

Arithmetic overflow guards (checked_*) and float NaN/Infinity detection (check_float) are correct. between/3 backtracking, arg/3 1-based indexing, =../2 on atomic terms, and compare/3 substitution application are all implemented correctly.

## Code Review Phase 5 Builtins ### Issue 1 Occurs check enabled by default in =/2 ISO deviation [Medium] crates/prolog-core/src/unify.rs lines 97-103 The standard unify method runs a full occurs check on every variable binding. ISO and Edinburgh Prolog both specify that plain =/2 does NOT perform an occurs check - that is reserved for unify_with_occurs_check/2. Enabling it by default means X = f(X) fails where standard Prolog succeeds, breaking difference-list idioms and any program that intentionally unifies a variable with a term containing it. The occurs check also adds O(n) overhead to every variable binding. --- ### Issue 2 succ/2 and plus/3 behave differently inside negation/once/findall [Medium] crates/prolog-core/src/solver.rs lines 771-775 vs 1843-1844 crates/prolog-core/src/solver.rs lines 822-825 vs 1864 In the main solve loop, calling succ(X, Y) or plus(X, Y, Z) with all arguments unbound hits the wildcard arm and returns SolveResult::Error. But in try_exec_misc (used by \+, once, findall, and ->) the equivalent arm is Some(false) - a silent failure. The error is swallowed. Consequence: \+ succ(X, Y) incorrectly succeeds because the inner instantiation error is treated as inner-goal failure, so negation-as-failure flips it. The same query at top level produces an error. This violates transparent \+ semantics. --- ### Issue 3 try_exec_misc catch-all silently suppresses unhandled builtins [Medium] crates/prolog-core/src/solver.rs line 2006, lines 1325-1334, lines 1601-1609 try_exec_misc ends with _ => None, and every call site treats None as failure. Any BuiltinResult variant that exists in exec_builtin but is not handled in try_exec_misc will silently fail (rather than execute) inside \+, once, findall, and ->. Adding a new builtin to exec_builtin and forgetting try_exec_misc produces silent wrong answers with no compile-time warning. --- ### Issue 4 copy_term_fresh variable counter not guaranteed disjoint from query variables [Low] crates/prolog-core/src/solver.rs lines 87, 101 The solver var_counter is initialised to 1000. The query parser assigns variable IDs starting from 0. The separation relies entirely on the assumption that no single query has >= 1000 distinct variables. This is not enforced: there is no assertion or mechanism to initialise var_counter to max(query_max_var_id) + 1. A pathological query or a large generated term could cause a fresh copy-term variable to alias a live query variable. --- ### Issue 5 copy_term_impl iterative list traversal falls back to recursion for variable-threaded lists [Low] crates/prolog-core/src/solver.rs lines 2040-2063 The iterative traversal for Term::List only advances the loop cursor to the un-walked tail (current = tail at line 2063). On the next iteration, if tail is Term::Var(id) bound to another Term::List, the match at line 2040 sees Term::Var(id) not Term::List, and falls into the wildcard arm, calling copy_term_impl recursively. The iterative path is only taken for immediately-concrete list cells; any variable-indirected spine reverts to deep recursion. A list of 10000 elements threaded through variables could stack-overflow. --- ### Issue 6 number_chars/2 silently fails on non-character list elements instead of type_error [Low] crates/prolog-core/src/solver.rs lines 900-926 When the char list contains non-atom elements (e.g. number_chars(X, [1, 2, 3]) with unquoted integers), the wildcard None arm at line 912 causes the string accumulation to return None, and execution falls through to self.backtrack() - a silent failure. ISO requires type_error(character, 1). The existing test at integration.rs lines 1098-1103 covers bad_atom but not integer elements. --- ### Issue 7 number_chars(123, X) test uses ambiguous expected value [Low] tests/integration.rs lines 711-712 assert_eq!(result, Some("[1, 2, 3]".to_string())); term_to_string renders Term::Atom without quotes, so the char-atom '1' and the integer Term::Integer(1) produce the same display string. The test passes even if the implementation returns integers instead of character atoms. A quoting-aware renderer or direct Term inspection would make the test meaningful. --- ### Summary Issue 1: unify.rs:97-103 - Occurs check on every unification, ISO =/2 must not occurs-check - Severity: Medium Issue 2: solver.rs:771-775 vs 1843-1844 and 822-825 vs 1864 - succ/2 and plus/3 errors swallowed as failure inside \+/once/findall - Severity: Medium Issue 3: solver.rs:2006, 1325-1334, 1601-1609 - try_exec_misc catch-all silently fails any builtin not present in that function - Severity: Medium Issue 4: solver.rs:87,101 - copy_term_fresh counter relies on magic constant 1000 being greater than max query vars - Severity: Low Issue 5: solver.rs:2040-2063 - copy_term_impl iterative list traversal degrades to recursion on var-threaded spines - Severity: Low Issue 6: solver.rs:900-926 - number_chars/2 silently fails on integer-typed list elements instead of type_error - Severity: Low Issue 7: integration.rs:711-712 - number_chars test expected value is ambiguous (atom vs integer look identical) - Severity: Low Arithmetic overflow guards (checked_*) and float NaN/Infinity detection (check_float) are correct. between/3 backtracking, arg/3 1-based indexing, =../2 on atomic terms, and compare/3 substitution application are all implemented correctly.
claude[bot] commented 2026-03-04 23:11:07 +00:00 (Migrated from github.com)

PR #2 Code Review — Prolog Engine Phase 5

Reviewed: correctness, Prolog semantics, arithmetic safety, backtracking, term handling, performance, and test coverage. All findings cite specific file paths and line numbers.


🔴 High Severity

1. Cut (!) is silently ignored in try_solve_once — wrong results for \+, once, findall conditions

crates/prolog-core/src/solver.rs:1172

Ok(BuiltinResult::Cut) => continue,   // BUG: continue = proceed to next goal

When ! fires inside try_solve_once (used for \+, once/1, findall's goal, if-then conditions), it is treated as a no-op — execution just falls through to the next goal. Cut should succeed as a goal AND prevent any alternative clauses from being tried for earlier goals in the conjunction. With continue, code like \+((member(X,[a,b,c]), !, fail)) iterates all three members instead of cutting after the first, producing wrong true/false for \+. Same issue at try_solve_collecting line ~1446.

Fix: treat Cut as an immediate success and break the goal loop (return true in try_solve_once).


2. Disjunction left branch is not walked before being stored — (X ; b) fails when X is bound

crates/prolog-core/src/builtins.rs:285,300

let left = subst.walk(&args[0]);   // walked, used only for if-then check
// ...
Ok(BuiltinResult::Disjunction(args[0].clone(), args[1].clone()))  // BUG: un-walked args[0]

left is the walked version of args[0], but when the if-then check fails, the code returns the original un-walked args[0]. If args[0] is a variable bound to a goal term, the disjunction branch receives the raw variable, which the solver cannot resolve as a goal, causing it to fail instead of calling the bound goal.

Fix: Ok(BuiltinResult::Disjunction(left, subst.walk(&args[1])))


3. between/3 with unbound X in try_solve_once has no step-limit protection

crates/prolog-core/src/solver.rs:1340–1351

for val in *low..=*high {   // iterates entire range regardless of max_depth
    ...
    if self.try_solve_once(remaining) { return true; }
    ...
}

The step counter (line 1160) is incremented once before entering between, but the inner loop iterates up to high - low + 1 times with no guard. \+ between(1, 1_000_000_000, X) hangs indefinitely, bypassing the max_depth protection entirely.

Fix: add an iteration counter inside the loop and check against self.max_depth, or propagate the range as a resumable state (WAM-style).


4. parse_list_tail and parse_paren_comma_list are unboundedly recursive — stack overflow on large inputs

crates/prolog-core/src/parser.rs:406–436 and 368–383

parse_list_tail calls itself recursively for each list element (line 411). parse_paren_comma_list calls itself for each conjunction element (line 374). A list or conjunction with ~10,000 elements will overflow the Rust call stack and panic rather than returning a parse error.

Fix: convert both to iterative loops (collect elements into a Vec, then build the Term::List / Compound spine in reverse).


5. term_compare recurses into compound arguments — stack overflow on deeply nested terms

crates/prolog-core/src/builtins.rs:718–726

The list comparison is iterative (line 729), but compound term argument comparison calls term_compare recursively (line 720) with no depth limit. A term like f(f(f(...))) with sufficient nesting will overflow the stack when used with sort/2, msort/2, compare/3, or any @</@> comparison.


🟡 Medium Severity

6. Errors from succ/2, plus/3, and functor/3 in try_exec_misc are silently converted to failure

crates/prolog-core/src/solver.rs:1870, 1885, 1889, 1893

In the main solve loop, succ/2 overflow returns SolveResult::Error(...). In try_exec_misc (used by \+, once, findall):

None => Some(false),  // overflow silently becomes failure

once(succ(9223372036854775807, X)) silently fails instead of throwing an arithmetic error. Same pattern for plus/3 (lines 1885, 1889, 1893) and functor/3 with negative arity (line ~1788). Programs relying on error signals from these predicates get wrong behavior inside once/1 or \+.


7. Float NaN unification uses IEEE == — two NaN floats cannot unify with themselves

crates/prolog-core/src/unify.rs:127

(Term::Float(a), Term::Float(b)) => a == b,

In Rust (IEEE 754), f64::NAN == f64::NAN is false. If NaN ever appears as a Term::Float value (e.g. loaded from a database, or if check_float guard is relaxed), X = X would fail for X = nan. ISO Prolog requires structural/syntactic equality for unification, not arithmetic equality.

Fix: a.to_bits() == b.to_bits()


8. Float / Int(0) reports "Infinity result" instead of "Division by zero"

crates/prolog-core/src/builtins.rs:575

(ArithVal::Float(a), ArithVal::Int(b)) => check_float(a / *b as f64),

When b == 0, this arm falls through without the zero-divisor guard at line 572 (which only covers Float / Float(0.0)). check_float then returns Err("Arithmetic error: Infinity result") — a misleading message for what is clearly a division-by-zero.

Fix: add (ArithVal::Float(_), ArithVal::Int(0)) => Err("Division by zero".to_string()), before the general float/int arm.


9. Error state not preserved across next() calls

crates/prolog-core/src/solver.rs:148–156

After solve() returns SolveResult::Error, solutions_found is still 0 and the initial choice point has been consumed. The next call to next() falls into self.backtrack(). If the error occurred after partial substitution bindings were applied, backtrack() may find surviving choice points and continue producing solutions from an inconsistent solver state — silently swallowing the error. At minimum, the first error should be latched so subsequent next() calls always return Failure or re-surface the error.


10. atom_chars/2 rejects integer and float arguments (ISO violation)

crates/prolog-core/src/solver.rs:381–398

if let Term::Atom(id) = walked { ... }
else if let Term::Var(_) = walked { ... }
else { return SolveResult::Error("atom_chars/2: first argument must be an atom or variable") }

ISO Prolog 8.16.2 specifies that atom_chars/2 must accept numbers: atom_chars(42, X)X = ['4','2']. The current implementation throws an error for any non-atom/non-var first argument. The same gap applies to atom_length/2 and atom_concat/3.


11. Quadratic cost from cloning the remaining goal list in try_solve_once

crates/prolog-core/src/solver.rs:1344

let remaining: Vec<Term> = goal_list.iter().cloned().collect();
if self.try_solve_once(remaining) { ... }

This is inside the between/3 enumeration loop and also at line 1381 in try_clauses (new_goals.extend(rest_goals.iter().cloned())). Each resolution step clones the entire remaining goal list (O(n)), making deeply nested goals O(n²) overall. For programs with large conjunctions or heavy between usage this is a significant performance problem.


🟠 Missing ISO Predicate

12. unify_with_occurs_check/2 is never exposed as a builtin

crates/prolog-core/src/unify.rs:162–179

occurs_in is implemented and tagged #[allow(dead_code)], but unify_with_occurs_check/2 is not registered in is_builtin or exec_builtin. Any Prolog program calling it gets an unexpected failure (undefined predicate). ISO 8.2.2 requires this predicate.


13. call/N for N > 1 is not supported

crates/prolog-core/src/builtins.rs:310

Only call/1 is dispatched. call/2..N (appending extra arguments to a closure) is the foundation of maplist/2, maplist/3, foldl/4, and all higher-order Prolog idioms. Without it, any library predicate using these patterns will fail with an existence error or incorrect silent failure.


🔵 Test Coverage Gaps

  • \+ with a cut inside: no test for \+((member(X,[a,b,c]), !, fail)) — directly exercises bug #1.
  • Disjunction with a bound variable as the left branch: X = foo, (X ; bar) — exercises bug #2.
  • sort/2/msort/2 with compound terms: all existing tests use atoms/integers; no test exercises the compound branch of term_compare (which has the unbounded-recursion risk, bug #5).
  • succ/2 overflow inside once/1: once(succ(9223372036854775807, X)) — exercises bug #6, should produce an error not silent failure.
  • number_chars/2 and number_codes/2 inside findall: exercises the try_exec_misc code path, which has the error-suppression pattern from bug #6.
  • atom_chars(42, X): exercises the ISO numeric-atom_chars path missing in bug #10.
  • arg(1, [a,b], X) and arg(2, [a,b], X): the Term::List branch of arg/3 in the solver has no integration test.
## PR #2 Code Review — Prolog Engine Phase 5 Reviewed: correctness, Prolog semantics, arithmetic safety, backtracking, term handling, performance, and test coverage. All findings cite specific file paths and line numbers. --- ### 🔴 High Severity **1. Cut (`!`) is silently ignored in `try_solve_once` — wrong results for `\+`, `once`, findall conditions** `crates/prolog-core/src/solver.rs:1172` ```rust Ok(BuiltinResult::Cut) => continue, // BUG: continue = proceed to next goal ``` When `!` fires inside `try_solve_once` (used for `\+`, `once/1`, findall's goal, if-then conditions), it is treated as a no-op — execution just falls through to the next goal. Cut should succeed as a goal AND prevent any alternative clauses from being tried for earlier goals in the conjunction. With `continue`, code like `\+((member(X,[a,b,c]), !, fail))` iterates all three members instead of cutting after the first, producing wrong true/false for `\+`. Same issue at `try_solve_collecting` line ~1446. Fix: treat `Cut` as an immediate success and break the goal loop (return `true` in `try_solve_once`). --- **2. Disjunction left branch is not walked before being stored — `(X ; b)` fails when `X` is bound** `crates/prolog-core/src/builtins.rs:285,300` ```rust let left = subst.walk(&args[0]); // walked, used only for if-then check // ... Ok(BuiltinResult::Disjunction(args[0].clone(), args[1].clone())) // BUG: un-walked args[0] ``` `left` is the walked version of `args[0]`, but when the if-then check fails, the code returns the original un-walked `args[0]`. If `args[0]` is a variable bound to a goal term, the disjunction branch receives the raw variable, which the solver cannot resolve as a goal, causing it to fail instead of calling the bound goal. Fix: `Ok(BuiltinResult::Disjunction(left, subst.walk(&args[1])))` --- **3. `between/3` with unbound `X` in `try_solve_once` has no step-limit protection** `crates/prolog-core/src/solver.rs:1340–1351` ```rust for val in *low..=*high { // iterates entire range regardless of max_depth ... if self.try_solve_once(remaining) { return true; } ... } ``` The step counter (line 1160) is incremented once before entering `between`, but the inner loop iterates up to `high - low + 1` times with no guard. `\+ between(1, 1_000_000_000, X)` hangs indefinitely, bypassing the `max_depth` protection entirely. Fix: add an iteration counter inside the loop and check against `self.max_depth`, or propagate the range as a resumable state (WAM-style). --- **4. `parse_list_tail` and `parse_paren_comma_list` are unboundedly recursive — stack overflow on large inputs** `crates/prolog-core/src/parser.rs:406–436` and `368–383` `parse_list_tail` calls itself recursively for each list element (line 411). `parse_paren_comma_list` calls itself for each conjunction element (line 374). A list or conjunction with ~10,000 elements will overflow the Rust call stack and panic rather than returning a parse error. Fix: convert both to iterative loops (collect elements into a `Vec`, then build the `Term::List` / `Compound` spine in reverse). --- **5. `term_compare` recurses into compound arguments — stack overflow on deeply nested terms** `crates/prolog-core/src/builtins.rs:718–726` The list comparison is iterative (line 729), but compound term argument comparison calls `term_compare` recursively (line 720) with no depth limit. A term like `f(f(f(...)))` with sufficient nesting will overflow the stack when used with `sort/2`, `msort/2`, `compare/3`, or any `@<`/`@>` comparison. --- ### 🟡 Medium Severity **6. Errors from `succ/2`, `plus/3`, and `functor/3` in `try_exec_misc` are silently converted to failure** `crates/prolog-core/src/solver.rs:1870, 1885, 1889, 1893` In the main `solve` loop, `succ/2` overflow returns `SolveResult::Error(...)`. In `try_exec_misc` (used by `\+`, `once`, `findall`): ```rust None => Some(false), // overflow silently becomes failure ``` `once(succ(9223372036854775807, X))` silently fails instead of throwing an arithmetic error. Same pattern for `plus/3` (lines 1885, 1889, 1893) and `functor/3` with negative arity (line ~1788). Programs relying on error signals from these predicates get wrong behavior inside `once/1` or `\+`. --- **7. Float NaN unification uses IEEE `==` — two NaN floats cannot unify with themselves** `crates/prolog-core/src/unify.rs:127` ```rust (Term::Float(a), Term::Float(b)) => a == b, ``` In Rust (IEEE 754), `f64::NAN == f64::NAN` is `false`. If NaN ever appears as a `Term::Float` value (e.g. loaded from a database, or if `check_float` guard is relaxed), `X = X` would fail for `X = nan`. ISO Prolog requires structural/syntactic equality for unification, not arithmetic equality. Fix: `a.to_bits() == b.to_bits()` --- **8. `Float / Int(0)` reports "Infinity result" instead of "Division by zero"** `crates/prolog-core/src/builtins.rs:575` ```rust (ArithVal::Float(a), ArithVal::Int(b)) => check_float(a / *b as f64), ``` When `b == 0`, this arm falls through without the zero-divisor guard at line 572 (which only covers `Float / Float(0.0)`). `check_float` then returns `Err("Arithmetic error: Infinity result")` — a misleading message for what is clearly a division-by-zero. Fix: add `(ArithVal::Float(_), ArithVal::Int(0)) => Err("Division by zero".to_string()),` before the general float/int arm. --- **9. Error state not preserved across `next()` calls** `crates/prolog-core/src/solver.rs:148–156` After `solve()` returns `SolveResult::Error`, `solutions_found` is still `0` and the initial choice point has been consumed. The next call to `next()` falls into `self.backtrack()`. If the error occurred after partial substitution bindings were applied, `backtrack()` may find surviving choice points and continue producing solutions from an inconsistent solver state — silently swallowing the error. At minimum, the first error should be latched so subsequent `next()` calls always return `Failure` or re-surface the error. --- **10. `atom_chars/2` rejects integer and float arguments (ISO violation)** `crates/prolog-core/src/solver.rs:381–398` ```rust if let Term::Atom(id) = walked { ... } else if let Term::Var(_) = walked { ... } else { return SolveResult::Error("atom_chars/2: first argument must be an atom or variable") } ``` ISO Prolog 8.16.2 specifies that `atom_chars/2` must accept numbers: `atom_chars(42, X)` → `X = ['4','2']`. The current implementation throws an error for any non-atom/non-var first argument. The same gap applies to `atom_length/2` and `atom_concat/3`. --- **11. Quadratic cost from cloning the remaining goal list in `try_solve_once`** `crates/prolog-core/src/solver.rs:1344` ```rust let remaining: Vec<Term> = goal_list.iter().cloned().collect(); if self.try_solve_once(remaining) { ... } ``` This is inside the `between/3` enumeration loop and also at line 1381 in `try_clauses` (`new_goals.extend(rest_goals.iter().cloned())`). Each resolution step clones the entire remaining goal list (O(n)), making deeply nested goals O(n²) overall. For programs with large conjunctions or heavy `between` usage this is a significant performance problem. --- ### 🟠 Missing ISO Predicate **12. `unify_with_occurs_check/2` is never exposed as a builtin** `crates/prolog-core/src/unify.rs:162–179` `occurs_in` is implemented and tagged `#[allow(dead_code)]`, but `unify_with_occurs_check/2` is not registered in `is_builtin` or `exec_builtin`. Any Prolog program calling it gets an unexpected failure (undefined predicate). ISO 8.2.2 requires this predicate. --- **13. `call/N` for N > 1 is not supported** `crates/prolog-core/src/builtins.rs:310` Only `call/1` is dispatched. `call/2..N` (appending extra arguments to a closure) is the foundation of `maplist/2`, `maplist/3`, `foldl/4`, and all higher-order Prolog idioms. Without it, any library predicate using these patterns will fail with an existence error or incorrect silent failure. --- ### 🔵 Test Coverage Gaps - **`\+` with a cut inside**: no test for `\+((member(X,[a,b,c]), !, fail))` — directly exercises bug #1. - **Disjunction with a bound variable as the left branch**: `X = foo, (X ; bar)` — exercises bug #2. - **`sort/2`/`msort/2` with compound terms**: all existing tests use atoms/integers; no test exercises the compound branch of `term_compare` (which has the unbounded-recursion risk, bug #5). - **`succ/2` overflow inside `once/1`**: `once(succ(9223372036854775807, X))` — exercises bug #6, should produce an error not silent failure. - **`number_chars/2` and `number_codes/2` inside `findall`**: exercises the `try_exec_misc` code path, which has the error-suppression pattern from bug #6. - **`atom_chars(42, X)`**: exercises the ISO numeric-atom_chars path missing in bug #10. - **`arg(1, [a,b], X)` and `arg(2, [a,b], X)`**: the `Term::List` branch of `arg/3` in the solver has no integration test.
claude[bot] commented 2026-03-05 03:06:24 +00:00 (Migrated from github.com)

Review posted - see below

Review posted - see below
claude[bot] commented 2026-03-05 03:08:22 +00:00 (Migrated from github.com)

PR Review: Phase 5 Builtins

All 203 tests pass. The new builtins are well-structured. The arith_mod ISO semantics fix and the Float/Int(0) division-by-zero guard are correct. Pre-interning [] and ! in CompiledDatabase::new safely guards the lookup calls. Trail-based backtracking is correctly wired for all new builtins. No panics on malformed input found.

Three issues worth fixing before merge.


Issue 1: between/3 iteration bypasses max_depth step limit

solver.rs:1344-1356 (try_solve_once) and solver.rs:1633-1644 (try_solve_collecting)

The range iteration loop increments self.steps exactly once before entering (at the outer try_solve_once / try_solve_collecting entry), not once per iteration. The step-limit guard has no effect on range width.

Example: between(1, 1000000000, X) inside negation-as-failure or findall iterates 10^9 times unimpeded:

findall(X, between(1, 1000000000, X), L).

The solve() loop is safe -- each alternative is a new choice point that re-enters solve() and counts one step. The looping versions in try_solve_once and try_solve_collecting need per-iteration protection.

Minimal fix: add self.steps += 1; if self.steps > self.max_depth { return false; } at the top of each for-loop body at solver.rs:1345 and solver.rs:1633.


Issue 2: =../2 construct mode with unbound 1-element list silently unifies instead of erroring

solver.rs:647-651 (solve()) and solver.rs:1865-1868 (try_exec_misc)

T =.. [F] where F is an unbound variable: collect_list yields [Term::Var(f_id)]. The Term::Atom arm does not match, but elems.len() == 1 is true so the code calls self.subst.unify on T and the unbound variable -- T stays uninstantiated. ISO 8.5.3.3 requires instantiation_error here. The silent success can cause confusing downstream backtracking.

Fix: before the elems.len() == 1 arm, check for an unbound functor and return SolveResult::Error instead.


Issue 3: number_chars/number_codes reverse mode silently backtracks on non-numeric strings

solver.rs:944-945, solver.rs:1029-1031, and their try_exec_misc mirrors

number_chars(X, [a,b,c]) collects chars into a String, tries parse as i64 then f64. When both fail it falls through to return self.backtrack() -- silent failure. ISO 8.16.7 requires syntax_error(not_a_number). As a consequence, negation-as-failure over these predicates gives a misleadingly clean success.

Fix: replace the silent return self.backtrack() after the second failed parse with return SolveResult::Error(...). Same in try_exec_misc.


Minor (not blocking):

  • collect_list (builtins.rs:799) is pub but requires pre-applied substitution. All current callers handle this correctly; a doc comment would prevent future misuse.
  • The _ => Ordering::Equal fallthrough at builtins.rs:794 in term_compare is dead code given the type-rank guard. Not a bug today, but would silently mask a missed arm if a new Term variant is added without updating type_rank.
  • sort/2 dedup at solver.rs:866-868: dedup_by semantics are correct after sort_by. Confirmed.
PR Review: Phase 5 Builtins All 203 tests pass. The new builtins are well-structured. The arith_mod ISO semantics fix and the Float/Int(0) division-by-zero guard are correct. Pre-interning [] and ! in CompiledDatabase::new safely guards the lookup calls. Trail-based backtracking is correctly wired for all new builtins. No panics on malformed input found. Three issues worth fixing before merge. --- **Issue 1: between/3 iteration bypasses max_depth step limit** solver.rs:1344-1356 (try_solve_once) and solver.rs:1633-1644 (try_solve_collecting) The range iteration loop increments self.steps exactly once before entering (at the outer try_solve_once / try_solve_collecting entry), not once per iteration. The step-limit guard has no effect on range width. Example: between(1, 1000000000, X) inside negation-as-failure or findall iterates 10^9 times unimpeded: findall(X, between(1, 1000000000, X), L). The solve() loop is safe -- each alternative is a new choice point that re-enters solve() and counts one step. The looping versions in try_solve_once and try_solve_collecting need per-iteration protection. Minimal fix: add self.steps += 1; if self.steps > self.max_depth { return false; } at the top of each for-loop body at solver.rs:1345 and solver.rs:1633. --- **Issue 2: =../2 construct mode with unbound 1-element list silently unifies instead of erroring** solver.rs:647-651 (solve()) and solver.rs:1865-1868 (try_exec_misc) T =.. [F] where F is an unbound variable: collect_list yields [Term::Var(f_id)]. The Term::Atom arm does not match, but elems.len() == 1 is true so the code calls self.subst.unify on T and the unbound variable -- T stays uninstantiated. ISO 8.5.3.3 requires instantiation_error here. The silent success can cause confusing downstream backtracking. Fix: before the elems.len() == 1 arm, check for an unbound functor and return SolveResult::Error instead. --- **Issue 3: number_chars/number_codes reverse mode silently backtracks on non-numeric strings** solver.rs:944-945, solver.rs:1029-1031, and their try_exec_misc mirrors number_chars(X, [a,b,c]) collects chars into a String, tries parse as i64 then f64. When both fail it falls through to return self.backtrack() -- silent failure. ISO 8.16.7 requires syntax_error(not_a_number). As a consequence, negation-as-failure over these predicates gives a misleadingly clean success. Fix: replace the silent return self.backtrack() after the second failed parse with return SolveResult::Error(...). Same in try_exec_misc. --- **Minor (not blocking):** - collect_list (builtins.rs:799) is pub but requires pre-applied substitution. All current callers handle this correctly; a doc comment would prevent future misuse. - The _ => Ordering::Equal fallthrough at builtins.rs:794 in term_compare is dead code given the type-rank guard. Not a bug today, but would silently mask a missed arm if a new Term variant is added without updating type_rank. - sort/2 dedup at solver.rs:866-868: dedup_by semantics are correct after sort_by. Confirmed.
claude[bot] commented 2026-03-05 03:50:49 +00:00 (Migrated from github.com)

Phase 5 Code Review - see inline comments below. Issues found: (1) cut in findall has no effect on clause iteration solver.rs:1479-1481+1683-1701, (2) exec_findall shares self.steps causing silent partial results solver.rs:1427-1451, (3) is_list fails for bound variable tails builtins.rs:274-281+828-836, (4) cut_in_try_solve not reset after try_solve_once calls from solve solver.rs:220-241+272-303+323-336, (5) number_chars/number_codes can bind NaN/Inf solver.rs:941-950+1031-1040, (6) between clones goal list O(RK) solver.rs:1352-1366+1643-1658, (7) try_solve_collecting unbounded recursion on goal depth solver.rs:1453-1703. Verified correct: =/2 no occurs check (ISO), all checked_ arithmetic, arith_mod sign semantics, check_float on results, arith_div zero guards, trail backtracking, rename_clause freshness, copy_term iterative list spine, functor arity bounds.

Phase 5 Code Review - see inline comments below. Issues found: (1) cut in findall has no effect on clause iteration solver.rs:1479-1481+1683-1701, (2) exec_findall shares self.steps causing silent partial results solver.rs:1427-1451, (3) is_list fails for bound variable tails builtins.rs:274-281+828-836, (4) cut_in_try_solve not reset after try_solve_once calls from solve solver.rs:220-241+272-303+323-336, (5) number_chars/number_codes can bind NaN/Inf solver.rs:941-950+1031-1040, (6) between clones goal list O(R*K) solver.rs:1352-1366+1643-1658, (7) try_solve_collecting unbounded recursion on goal depth solver.rs:1453-1703. Verified correct: =/2 no occurs check (ISO), all checked_* arithmetic, arith_mod sign semantics, check_float on results, arith_div zero guards, trail backtracking, rename_clause freshness, copy_term iterative list spine, functor arity bounds.
claude[bot] commented 2026-03-05 03:51:31 +00:00 (Migrated from github.com)

Phase 5 Detailed Review (6 bugs + 2 perf/safety): BUG 1 HIGH cut in findall has no effect on clause iteration solver.rs:1479-1481 and 1683-1701. try_solve_collecting handles BuiltinResult::Cut by continuing goals but the user-predicate loop iterates ALL candidates. findall(X, (p(X), !), Xs) with p(1) p(2) p(3) returns [1,2,3] instead of [1]. try_solve_once correctly stops at cut_in_try_solve line 1410 but try_solve_collecting has no equivalent guard. BUG 2 HIGH exec_findall shares self.steps causing silent truncation solver.rs:1427-1451. exec_findall never saves or restores self.steps. When try_solve_collecting hits the step limit it returns false with partial results and exec_findall returns that partial list as Ok() with no error. findall(X, between(1, 20000, X), Xs) silently returns a short list violating ISO 8.10.1. Fix: save self.steps before and restore after, propagate as SolveResult::Error on truncation. BUG 3 HIGH is_list/1 fails for lists with bound variable tails builtins.rs:274-281 and 828-836. is_list/1 uses subst.walk (shallow) then calls is_proper_list with no substitution access. A list [1,2|T] where T is unified to [] returns false because is_proper_list sees Var(id) not []. msort and sort correctly use self.subst.apply before collect_list. Fix is_list/1 to use apply instead of walk. BUG 4 HIGH cut_in_try_solve not reset after try_solve_once called from solve, lines 220-241, 272-303, 323-336. All four call sites in solve (NAF, IfThenElse, IfThen, Once) do not reset self.cut_in_try_solve. BuiltinResult::Cut arm at line 1187 sets it without save/restore. A subsequent try_solve_once call sees saved_cut=true (dirty); if new inner goal does not fire cut, cut_in_try_solve is restored to true, causing next predicate to stop clause iteration prematurely. Fix: self.cut_in_try_solve = false before each call in solve. BUG 5 MEDIUM number_chars/2 and number_codes/2 reverse path binds NaN/Inf solver.rs:941-950 and 1031-1040. Rust str::parse succeeds for NaN inf -inf so number_chars(N, ['N','a','N']) succeeds binding N to NaN float. check_float only guards arithmetic results not inputs. NaN in comparisons silently returns false instead of evaluation_error(undefined). Fix: after parse succeeds reject if f.is_nan() or f.is_infinite(). PERF 6 MEDIUM between/3 in try_solve_once and try_solve_collecting clones goal list each iteration solver.rs:1352 1366 1643 1658. goal_list.iter().cloned().collect() is O(K) per iteration giving O(RK) total. solve avoids this with a lazy between(low+1, high, X) choice point. SAFETY 7 MEDIUM try_solve_collecting unbounded recursion on goal depth solver.rs:1453-1703. Each findall goal adds one Rust stack frame. 10000-step guard counts steps not frames so deep conjunctions inside findall can overflow the stack. Conjunction and Success arms are tail calls that could be flattened. VERIFIED CORRECT: =/2 no occurs check (ISO 8.3.2), all checked_ integer arithmetic, arith_mod sign semantics, check_float on every float result, arith_div zero guards, trail backtracking, rename_clause freshness, copy_term iterative list spine, functor/3 arity bounds.

Phase 5 Detailed Review (6 bugs + 2 perf/safety): BUG 1 HIGH cut in findall has no effect on clause iteration solver.rs:1479-1481 and 1683-1701. try_solve_collecting handles BuiltinResult::Cut by continuing goals but the user-predicate loop iterates ALL candidates. findall(X, (p(X), \!), Xs) with p(1) p(2) p(3) returns [1,2,3] instead of [1]. try_solve_once correctly stops at cut_in_try_solve line 1410 but try_solve_collecting has no equivalent guard. BUG 2 HIGH exec_findall shares self.steps causing silent truncation solver.rs:1427-1451. exec_findall never saves or restores self.steps. When try_solve_collecting hits the step limit it returns false with partial results and exec_findall returns that partial list as Ok() with no error. findall(X, between(1, 20000, X), Xs) silently returns a short list violating ISO 8.10.1. Fix: save self.steps before and restore after, propagate as SolveResult::Error on truncation. BUG 3 HIGH is_list/1 fails for lists with bound variable tails builtins.rs:274-281 and 828-836. is_list/1 uses subst.walk (shallow) then calls is_proper_list with no substitution access. A list [1,2|T] where T is unified to [] returns false because is_proper_list sees Var(id) not []. msort and sort correctly use self.subst.apply before collect_list. Fix is_list/1 to use apply instead of walk. BUG 4 HIGH cut_in_try_solve not reset after try_solve_once called from solve, lines 220-241, 272-303, 323-336. All four call sites in solve (NAF, IfThenElse, IfThen, Once) do not reset self.cut_in_try_solve. BuiltinResult::Cut arm at line 1187 sets it without save/restore. A subsequent try_solve_once call sees saved_cut=true (dirty); if new inner goal does not fire cut, cut_in_try_solve is restored to true, causing next predicate to stop clause iteration prematurely. Fix: self.cut_in_try_solve = false before each call in solve. BUG 5 MEDIUM number_chars/2 and number_codes/2 reverse path binds NaN/Inf solver.rs:941-950 and 1031-1040. Rust str::parse succeeds for NaN inf -inf so number_chars(N, ['N','a','N']) succeeds binding N to NaN float. check_float only guards arithmetic results not inputs. NaN in comparisons silently returns false instead of evaluation_error(undefined). Fix: after parse succeeds reject if f.is_nan() or f.is_infinite(). PERF 6 MEDIUM between/3 in try_solve_once and try_solve_collecting clones goal list each iteration solver.rs:1352 1366 1643 1658. goal_list.iter().cloned().collect() is O(K) per iteration giving O(R*K) total. solve avoids this with a lazy between(low+1, high, X) choice point. SAFETY 7 MEDIUM try_solve_collecting unbounded recursion on goal depth solver.rs:1453-1703. Each findall goal adds one Rust stack frame. 10000-step guard counts steps not frames so deep conjunctions inside findall can overflow the stack. Conjunction and Success arms are tail calls that could be flattened. VERIFIED CORRECT: =/2 no occurs check (ISO 8.3.2), all checked_* integer arithmetic, arith_mod sign semantics, check_float on every float result, arith_div zero guards, trail backtracking, rename_clause freshness, copy_term iterative list spine, functor/3 arity bounds.
claude[bot] commented 2026-03-05 05:40:54 +00:00 (Migrated from github.com)

Phase 5 Built-ins: Review

The overall implementation is solid — backtracking semantics for between/3, checked arithmetic, and the apply fix for is_list are all correct. Found several issues worth addressing:


1. functor/3 negative arity: inconsistent error vs. silent failure

solver.rs:543-546 (main solve loop) vs. solver.rs:1867 (try_exec_misc)

In the main solve loop, functor(X, foo, -1) returns SolveResult::Error("functor/3: arity must be non-negative"). In try_exec_misc (used by try_solve_once and try_solve_collecting), the negative-arity case falls through to _ => Some(false) — silent failure.

The guard at solver.rs:1851 only handles arity > 0 && arity <= 1024; anything outside that range falls to the final _ => Some(false) arm.

Effect: \+ functor(X, foo, -1) incorrectly succeeds (failure inside \+ = success) instead of propagating an error. findall(X, functor(X, foo, -1), Xs) gives Xs = [] instead of an error.


2. between/3 in try_solve_once clones goal continuation on every iteration

solver.rs:1385 (and matching pattern in try_solve_collecting ~line 1690)

Inside the iteration loop, goal_list.iter().cloned().collect() allocates and clones the entire remaining goal list on every iteration, giving O(range_size x |remaining goals|) allocations. For between(1, 10000, X) with a non-trivial continuation this is measurably expensive. Building the clone once before the loop would fix it.


3. apply_impl cycle detection uses Vec::contains — O(n^2)

unify.rs:78

seen is a Vec<VarId> using linear search. For a variable chain of depth n (possible after X = f(X) without occurs check), apply becomes O(n^2). A FnvHashSet<VarId> reduces this to O(n). In practice this only matters for cyclic terms, but apply runs on every solution extraction and every copy_term invocation.


4. term_compare final match arm should be unreachable!(), not Ordering::Equal

builtins.rs, last arm of the term_compare match

_ => Ordering::Equal,

This arm is dead code today — the preceding arms are exhaustive for all current Term variants. If a new Term variant is added without updating term_compare, this silently returns Equal for any comparison involving the new variant, corrupting sort order and compare/3 results with no diagnostic. Should be unreachable!("term_compare: unhandled Term variant").


5. Missing test: arith_mod(i64::MIN, -1)

builtins.rs test module

The new test_mod_i64_min_divisor covers divisor = i64::MIN. There is no test for dividend = i64::MIN with an odd negative divisor, which exercises rem_euclid on i64::MIN and the r == 0 branch. The implementation returns 0 (correct), but a regression test would lock this in:

let result = arith_mod(&ArithVal::Int(i64::MIN), &ArithVal::Int(-1));
assert_eq!(result, Ok(ArithVal::Int(0)));

Summary

Severity Issue
Bug functor/3 negative arity: error in solve, silent failure in try_exec_misc (solver.rs:1867)
Performance between/3 clones goal continuation O(range x goals) per iteration (solver.rs:1385, ~1690)
Performance apply_impl O(n^2) cycle detection via Vec::contains (unify.rs:78)
Latent term_compare wildcard arm should be unreachable!() (builtins.rs)
Test gap arith_mod(i64::MIN, -1) not covered
## Phase 5 Built-ins: Review The overall implementation is solid — backtracking semantics for `between/3`, checked arithmetic, and the `apply` fix for `is_list` are all correct. Found several issues worth addressing: --- ### 1. `functor/3` negative arity: inconsistent error vs. silent failure **`solver.rs:543-546` (main `solve` loop) vs. `solver.rs:1867` (`try_exec_misc`)** In the main `solve` loop, `functor(X, foo, -1)` returns `SolveResult::Error("functor/3: arity must be non-negative")`. In `try_exec_misc` (used by `try_solve_once` and `try_solve_collecting`), the negative-arity case falls through to `_ => Some(false)` — silent failure. The guard at `solver.rs:1851` only handles `arity > 0 && arity <= 1024`; anything outside that range falls to the final `_ => Some(false)` arm. **Effect:** `\+ functor(X, foo, -1)` incorrectly **succeeds** (failure inside `\+` = success) instead of propagating an error. `findall(X, functor(X, foo, -1), Xs)` gives `Xs = []` instead of an error. --- ### 2. `between/3` in `try_solve_once` clones goal continuation on every iteration **`solver.rs:1385`** (and matching pattern in `try_solve_collecting` ~line 1690) Inside the iteration loop, `goal_list.iter().cloned().collect()` allocates and clones the entire remaining goal list on **every iteration**, giving O(range_size x |remaining goals|) allocations. For `between(1, 10000, X)` with a non-trivial continuation this is measurably expensive. Building the clone once before the loop would fix it. --- ### 3. `apply_impl` cycle detection uses `Vec::contains` — O(n^2) **`unify.rs:78`** `seen` is a `Vec<VarId>` using linear search. For a variable chain of depth n (possible after `X = f(X)` without occurs check), `apply` becomes O(n^2). A `FnvHashSet<VarId>` reduces this to O(n). In practice this only matters for cyclic terms, but `apply` runs on every solution extraction and every `copy_term` invocation. --- ### 4. `term_compare` final match arm should be `unreachable!()`, not `Ordering::Equal` **`builtins.rs`, last arm of the `term_compare` match** ``` _ => Ordering::Equal, ``` This arm is dead code today — the preceding arms are exhaustive for all current `Term` variants. If a new `Term` variant is added without updating `term_compare`, this silently returns `Equal` for any comparison involving the new variant, corrupting sort order and `compare/3` results with no diagnostic. Should be `unreachable!("term_compare: unhandled Term variant")`. --- ### 5. Missing test: `arith_mod(i64::MIN, -1)` **`builtins.rs` test module** The new `test_mod_i64_min_divisor` covers divisor = `i64::MIN`. There is no test for dividend = `i64::MIN` with an odd negative divisor, which exercises `rem_euclid` on `i64::MIN` and the `r == 0` branch. The implementation returns `0` (correct), but a regression test would lock this in: ```rust let result = arith_mod(&ArithVal::Int(i64::MIN), &ArithVal::Int(-1)); assert_eq!(result, Ok(ArithVal::Int(0))); ``` --- ### Summary | Severity | Issue | |---|---| | Bug | `functor/3` negative arity: error in `solve`, silent failure in `try_exec_misc` (solver.rs:1867) | | Performance | `between/3` clones goal continuation O(range x goals) per iteration (solver.rs:1385, ~1690) | | Performance | `apply_impl` O(n^2) cycle detection via `Vec::contains` (unify.rs:78) | | Latent | `term_compare` wildcard arm should be `unreachable!()` (builtins.rs) | | Test gap | `arith_mod(i64::MIN, -1)` not covered |
claude[bot] commented 2026-03-05 06:01:25 +00:00 (Migrated from github.com)

Code Review - Phase 5 (PR #2)

Reviewed: solver.rs, builtins.rs, unify.rs, parser.rs, tokenizer.rs, integration.rs.


Bug 1 - Step-limit inside try_solve_once silently converts timeout to failure

File: crates/prolog-core/src/solver.rs, lines 1194-1197

try_solve_once returns bool. When the step counter is exceeded it returns false. The dangerous case is negation-as-failure: both the negation handler in solve (line 220) and in try_solve_collecting (line 1519) call try_solve_once and interpret false as "inner goal failed", so \+(expensive_goal) will incorrectly succeed when the goal hits the step limit instead of returning an error.

A minimal fix is a shared steps_exceeded: bool flag on Solver that try_solve_once sets, with all callers checking it immediately after to bubble the error up.


Bug 2 - succ/2 raises an error in the main loop but silently fails inside once/findall

File: crates/prolog-core/src/solver.rs, lines 791-800 vs. lines 1950-1958

In the main solve loop, succ(-1, S) returns SolveResult::Error("succ/2: argument must be non-negative"). But when succ/2 runs inside once/1 or findall/3, the dispatch goes through try_exec_misc, which returns Some(false) for all unrecognized cases. So once(succ(-1, _)) silently fails; the identical goal at the top level produces a runtime error.

The same inconsistency exists for plus/3 overflow and error arms (main loop lines 821-825 vs. try_exec_misc lines 1969-1973).


Bug 3 - between/3 in the main solve loop builds O(N) choice points when X is pre-bound

File: crates/prolog-core/src/solver.rs, lines 712-750

When X is already bound to an integer inside the range, the code still pushes one choice point per value from low up to X before the unification succeeds. For between(1, 1_000_000, 3) it pushes choice points for low=1 (fails), low=2 (fails), low=3 (succeeds), and then on backtrack pushes many more until the step limit fires.

try_solve_once already has an O(1) fast path for this case (lines 1368-1374): it walks x_arg first, checks if it is a bound integer, and either returns immediately or falls through to the enumeration loop. The main solve loop should mirror this before pushing any choice point.


Bug 4 - try_solve_collecting is directly recursive; Rust stack can overflow before the step counter fires

File: crates/prolog-core/src/solver.rs, lines 1715-1743

For user-defined predicates inside findall, try_solve_collecting calls itself recursively on each clause body. The step counter increments once per goal but each Rust stack frame is far larger than one tick. A deeply recursive program inside findall can exhaust the native stack before max_depth is reached, causing an uncontrolled process abort instead of a clean SolveResult::Error. try_solve_once has the same structural issue (lines 1420-1428).

Options: add a separate Rust-frame depth counter checked at every recursive entry, or convert the recursion to an explicit Vec-based worklist.


Missing test coverage

  • between/3 with pre-bound X in a top-level query (covers Bug 3): solve_all("", "between(1,10,5)") should give one solution; between(1,10,11) should fail. Currently only the unbound-X case is tested.
  • succ/2 with a negative argument inside once/1 (covers Bug 2): solve_expect_error("", "once(succ(-1, _))") should raise an error, not fail silently.
  • Step-limit interaction with negation (covers Bug 1): verify that \+(infinite_recursion_predicate) returns an error rather than succeeding.
  • =../2 decomposition of a list with a non-nil tail: T = [a|b], T =.. L should give L = ['.', a, b].
  • number_chars/2 round-trip for floats: number_chars(3.14, Cs), number_chars(X, Cs) should reconstruct 3.14.
## Code Review - Phase 5 (PR #2) Reviewed: solver.rs, builtins.rs, unify.rs, parser.rs, tokenizer.rs, integration.rs. --- ### Bug 1 - Step-limit inside try_solve_once silently converts timeout to failure **File:** crates/prolog-core/src/solver.rs, lines 1194-1197 `try_solve_once` returns `bool`. When the step counter is exceeded it returns `false`. The dangerous case is negation-as-failure: both the negation handler in `solve` (line 220) and in `try_solve_collecting` (line 1519) call `try_solve_once` and interpret `false` as "inner goal failed", so `\+(expensive_goal)` will **incorrectly succeed** when the goal hits the step limit instead of returning an error. A minimal fix is a shared `steps_exceeded: bool` flag on `Solver` that `try_solve_once` sets, with all callers checking it immediately after to bubble the error up. --- ### Bug 2 - succ/2 raises an error in the main loop but silently fails inside once/findall **File:** crates/prolog-core/src/solver.rs, lines 791-800 vs. lines 1950-1958 In the main `solve` loop, `succ(-1, S)` returns `SolveResult::Error("succ/2: argument must be non-negative")`. But when `succ/2` runs inside `once/1` or `findall/3`, the dispatch goes through `try_exec_misc`, which returns `Some(false)` for all unrecognized cases. So `once(succ(-1, _))` silently fails; the identical goal at the top level produces a runtime error. The same inconsistency exists for `plus/3` overflow and error arms (main loop lines 821-825 vs. `try_exec_misc` lines 1969-1973). --- ### Bug 3 - between/3 in the main solve loop builds O(N) choice points when X is pre-bound **File:** crates/prolog-core/src/solver.rs, lines 712-750 When X is already bound to an integer inside the range, the code still pushes one choice point per value from `low` up to X before the unification succeeds. For `between(1, 1_000_000, 3)` it pushes choice points for low=1 (fails), low=2 (fails), low=3 (succeeds), and then on backtrack pushes many more until the step limit fires. `try_solve_once` already has an O(1) fast path for this case (lines 1368-1374): it walks `x_arg` first, checks if it is a bound integer, and either returns immediately or falls through to the enumeration loop. The main solve loop should mirror this before pushing any choice point. --- ### Bug 4 - try_solve_collecting is directly recursive; Rust stack can overflow before the step counter fires **File:** crates/prolog-core/src/solver.rs, lines 1715-1743 For user-defined predicates inside `findall`, `try_solve_collecting` calls itself recursively on each clause body. The step counter increments once per goal but each Rust stack frame is far larger than one tick. A deeply recursive program inside `findall` can exhaust the native stack before `max_depth` is reached, causing an uncontrolled process abort instead of a clean `SolveResult::Error`. `try_solve_once` has the same structural issue (lines 1420-1428). Options: add a separate Rust-frame depth counter checked at every recursive entry, or convert the recursion to an explicit Vec-based worklist. --- ### Missing test coverage - `between/3` with pre-bound X in a top-level query (covers Bug 3): `solve_all("", "between(1,10,5)")` should give one solution; `between(1,10,11)` should fail. Currently only the unbound-X case is tested. - `succ/2` with a negative argument inside `once/1` (covers Bug 2): `solve_expect_error("", "once(succ(-1, _))")` should raise an error, not fail silently. - Step-limit interaction with negation (covers Bug 1): verify that `\+(infinite_recursion_predicate)` returns an error rather than succeeding. - `=../2` decomposition of a list with a non-nil tail: `T = [a|b], T =.. L` should give `L = ['.', a, b]`. - `number_chars/2` round-trip for floats: `number_chars(3.14, Cs), number_chars(X, Cs)` should reconstruct `3.14`.
claude[bot] commented 2026-03-05 14:04:33 +00:00 (Migrated from github.com)

Phase 5 PR Review

All 203 tests pass and the new predicates work correctly for the common cases. The issues below are the ones I'm confident about.


1. Overflow errors silently become failures inside findall/\+/once — semantic bug

solver.rs:2009–2035 (try_exec_misc)

In the main solve loop, succ/2 and plus/3 overflow correctly propagate as SolveResult::Error. Inside try_exec_misc (the shared handler for try_solve_once and try_solve_collecting), the same overflow cases return Some(false) — a failure — instead of surfacing the error.

// Main solve loop (solver.rs:821–824): correct — throws
None => {
    return SolveResult::Error("succ/2: integer overflow".to_string())
}

// try_exec_misc (solver.rs:2010–2011): wrong — silently fails
None => Some(false),

Consequence: findall(X, succ(9223372036854775806, X), Xs) gives Xs = [] instead of an error. Same for \+ succ(MaxInt, _) — it succeeds when it should error. This breaks the ISO requirement that arithmetic exceptions propagate regardless of context.

The same inconsistency applies to plus/3 overflow (lines 2024–2035). Consider adding a helper that returns Result<Option<bool>, String> so errors can be propagated, or surfacing them via self.steps_exceeded-style flag.


2. findall resets the step counter, creating an effective bypass of max_depth

solver.rs:1502–1514

let saved_steps = self.steps;
// ... try_solve_collecting runs up to max_depth steps ...
self.steps = saved_steps; // counter reset

After exec_findall returns, self.steps is restored to its pre-findall value. A program can use findall to run an extra max_depth steps per call, effectively multiplying the real work by however many nested findalls exist. Two nested findalls of depth 10,000 each give 30,000 total steps against a max_depth of 10,000. This lets adversarial or buggy programs run much longer than intended.

Consider accumulating (not resetting) the step count, or subtracting the used steps from the outer budget.


3. try_solve_collecting is fully stack-recursive — stack-overflow risk near step limit

solver.rs:1537–1803

try_solve_collecting calls itself recursively for every goal step (e.g. line 1561: return self.try_solve_collecting(goal_list, template, results)). With max_depth = 10_000, a deeply chained set of goals can push ~10,000 Rust call frames before the step limit fires. For a program like:

findall(X, (member(X, List), g1(X), g2(X), ), Xs)

each clause resolution adds frames. The default Rust stack is 8 MB; 10,000 frames with non-trivial locals can overflow. Either convert to an iterative approach (loop + explicit stack) or lower the default max_depth used inside try_solve_collecting.


4. between/3 in the main solver clones the full remaining goal list per backtrack — quadratic memory

solver.rs:782–784

let mut alt_goals = VecDeque::from(vec![alt_goal]);
alt_goals.extend(goals.iter().cloned());  // O(|goals|) per iteration
self.choice_stack.push(ChoicePoint { goals: alt_goals,  });

For between(1, N, X) with M remaining goals, each of the N backtracks clones the entire M-element goal list, giving O(N × M) allocations. For between(1, 1000, X) with a moderate goal tail, this is noticeable. The disjunction choice point has the same pattern (line 261–262), but between is the only builtin that generates O(range-size) choice points at runtime.

One option: store the high-bound and current value directly in the ChoicePoint struct (a BetweenState variant) instead of re-encoding as a new between(Low+1, High, X) goal.


5. Error vs. failure inconsistency for =../2 with empty list in try_exec_misc

solver.rs:672–674 (main loop) vs. solver.rs:1974–1996 (try_exec_misc)

In the main solve loop:

if elems.is_empty() {
    return SolveResult::Error("=../2: list must not be empty".to_string());
}

In try_exec_misc (used inside findall/+):

if !elems.is_empty() {
    // … handle non-empty …
}
// falls through to:
Some(false)  // silent failure

X =.. [] should be an instantiation/type error in all contexts. Same concern applies to functor/3 with a negative arity (errors in main loop, returns Some(false) in try_exec_misc lines 1925–1926).


6. arith_mod computes b.abs() but the i64::MIN guard only matches the divisor

builtins.rs:583–585

(ArithVal::Int(_), ArithVal::Int(i64::MIN)) => {
    Err("Arithmetic error: integer overflow in mod".to_string())
}
(ArithVal::Int(a), ArithVal::Int(b)) => {
    let r = a.rem_euclid(b.abs());  // b.abs() panics in debug if b = i64::MIN

The guard correctly catches divisor = i64::MIN. ✓ However, the same protection is missing for rem_euclid when the dividend a = i64::MINi64::MIN.rem_euclid(b.abs()) is well-defined (it calls wrapping_rem and wrapping_add internally for negative dividends), so no panic. The implementation is actually correct; just worth verifying the ISO mod semantics hold for all four sign combinations, which they do per the code logic.


Minor / FYI

  • copy_term_impl for compounds is still recursive (solver.rs:2204–2209). The list spine was made iterative (good), but deeply nested compound terms can still overflow the stack. This is pre-existing but worth noting since copy_term/2 is new.

  • atom_concat/3 is not reversible — requires both first arguments to be bound. Pre-existing, not introduced here.

  • \+ (negation-as-failure) inside try_solve_once doesn't save/restore the choice stack (solver.rs:1257–1270). If the inner try_solve_once call somehow pushes choice points (it currently can't since try_solve_once doesn't push them), they'd be left orphaned. This is safe today but fragile if the implementation changes.


Overall the new predicates are implemented carefully — arithmetic overflow is guarded with checked_*, float NaN/Infinity is caught by check_float, and the between/3 iterative approach with step counting is solid. The main concerns are the error-vs-failure inconsistency in try_exec_misc (issue 1) and the step-counter reset in exec_findall (issue 2), which can cause incorrect program behavior rather than just performance degradation.

## Phase 5 PR Review All 203 tests pass and the new predicates work correctly for the common cases. The issues below are the ones I'm confident about. --- ### 1. Overflow errors silently become failures inside `findall`/`\+`/`once` — semantic bug **`solver.rs:2009–2035` (`try_exec_misc`)** In the main `solve` loop, `succ/2` and `plus/3` overflow correctly propagate as `SolveResult::Error`. Inside `try_exec_misc` (the shared handler for `try_solve_once` and `try_solve_collecting`), the same overflow cases return `Some(false)` — a failure — instead of surfacing the error. ```rust // Main solve loop (solver.rs:821–824): correct — throws None => { return SolveResult::Error("succ/2: integer overflow".to_string()) } // try_exec_misc (solver.rs:2010–2011): wrong — silently fails None => Some(false), ``` Consequence: `findall(X, succ(9223372036854775806, X), Xs)` gives `Xs = []` instead of an error. Same for `\+ succ(MaxInt, _)` — it succeeds when it should error. This breaks the ISO requirement that arithmetic exceptions propagate regardless of context. The same inconsistency applies to `plus/3` overflow (lines 2024–2035). Consider adding a helper that returns `Result<Option<bool>, String>` so errors can be propagated, or surfacing them via `self.steps_exceeded`-style flag. --- ### 2. `findall` resets the step counter, creating an effective bypass of `max_depth` **`solver.rs:1502–1514`** ```rust let saved_steps = self.steps; // ... try_solve_collecting runs up to max_depth steps ... self.steps = saved_steps; // counter reset ``` After `exec_findall` returns, `self.steps` is restored to its pre-findall value. A program can use `findall` to run an extra `max_depth` steps per call, effectively multiplying the real work by however many nested findalls exist. Two nested findalls of depth 10,000 each give 30,000 total steps against a `max_depth` of 10,000. This lets adversarial or buggy programs run much longer than intended. Consider accumulating (not resetting) the step count, or subtracting the used steps from the outer budget. --- ### 3. `try_solve_collecting` is fully stack-recursive — stack-overflow risk near step limit **`solver.rs:1537–1803`** `try_solve_collecting` calls itself recursively for every goal step (e.g. line 1561: `return self.try_solve_collecting(goal_list, template, results)`). With `max_depth = 10_000`, a deeply chained set of goals can push ~10,000 Rust call frames before the step limit fires. For a program like: ```prolog findall(X, (member(X, List), g1(X), g2(X), …), Xs) ``` each clause resolution adds frames. The default Rust stack is 8 MB; 10,000 frames with non-trivial locals can overflow. Either convert to an iterative approach (loop + explicit stack) or lower the default `max_depth` used inside `try_solve_collecting`. --- ### 4. `between/3` in the main solver clones the full remaining goal list per backtrack — quadratic memory **`solver.rs:782–784`** ```rust let mut alt_goals = VecDeque::from(vec![alt_goal]); alt_goals.extend(goals.iter().cloned()); // O(|goals|) per iteration self.choice_stack.push(ChoicePoint { goals: alt_goals, … }); ``` For `between(1, N, X)` with M remaining goals, each of the N backtracks clones the entire M-element goal list, giving O(N × M) allocations. For `between(1, 1000, X)` with a moderate goal tail, this is noticeable. The disjunction choice point has the same pattern (line 261–262), but `between` is the only builtin that generates O(range-size) choice points at runtime. One option: store the high-bound and current value directly in the `ChoicePoint` struct (a `BetweenState` variant) instead of re-encoding as a new `between(Low+1, High, X)` goal. --- ### 5. Error vs. failure inconsistency for `=../2` with empty list in `try_exec_misc` **`solver.rs:672–674` (main loop) vs. `solver.rs:1974–1996` (try_exec_misc)** In the main solve loop: ```rust if elems.is_empty() { return SolveResult::Error("=../2: list must not be empty".to_string()); } ``` In `try_exec_misc` (used inside findall/\+): ```rust if !elems.is_empty() { // … handle non-empty … } // falls through to: Some(false) // silent failure ``` `X =.. []` should be an instantiation/type error in all contexts. Same concern applies to `functor/3` with a negative arity (errors in main loop, returns `Some(false)` in try_exec_misc lines 1925–1926). --- ### 6. `arith_mod` computes `b.abs()` but the `i64::MIN` guard only matches the divisor **`builtins.rs:583–585`** ```rust (ArithVal::Int(_), ArithVal::Int(i64::MIN)) => { Err("Arithmetic error: integer overflow in mod".to_string()) } (ArithVal::Int(a), ArithVal::Int(b)) => { let r = a.rem_euclid(b.abs()); // b.abs() panics in debug if b = i64::MIN ``` The guard correctly catches `divisor = i64::MIN`. ✓ However, the same protection is missing for `rem_euclid` when the dividend `a = i64::MIN` — `i64::MIN.rem_euclid(b.abs())` is well-defined (it calls `wrapping_rem` and `wrapping_add` internally for negative dividends), so no panic. The implementation is actually correct; just worth verifying the ISO mod semantics hold for all four sign combinations, which they do per the code logic. --- ### Minor / FYI - **`copy_term_impl` for compounds is still recursive** (`solver.rs:2204–2209`). The list spine was made iterative (good), but deeply nested compound terms can still overflow the stack. This is pre-existing but worth noting since `copy_term/2` is new. - **`atom_concat/3` is not reversible** — requires both first arguments to be bound. Pre-existing, not introduced here. - **`\+` (negation-as-failure) inside `try_solve_once` doesn't save/restore the choice stack** (`solver.rs:1257–1270`). If the inner `try_solve_once` call somehow pushes choice points (it currently can't since `try_solve_once` doesn't push them), they'd be left orphaned. This is safe today but fragile if the implementation changes. --- Overall the new predicates are implemented carefully — arithmetic overflow is guarded with `checked_*`, float NaN/Infinity is caught by `check_float`, and the `between/3` iterative approach with step counting is solid. The main concerns are the error-vs-failure inconsistency in `try_exec_misc` (issue 1) and the step-counter reset in `exec_findall` (issue 2), which can cause incorrect program behavior rather than just performance degradation.
claude[bot] commented 2026-03-06 00:06:50 +00:00 (Migrated from github.com)

Code Review: PR 2 (Phase 5 builtins)

Overall the implementation is solid: checked arithmetic throughout, NaN/Infinity detection, proper trail-based undo, and try_solve_collecting correctly enumerating between/3 inside findall. Issues below are real bugs I am confident about.


1. Integer precision loss in term_compare for large integers vs floats

crates/prolog-core/src/builtins.rs:685 (and symmetric arm ~697)

(*a as f64) is a lossy cast for |a| > 2^53. For example, 9007199254740993_i64 as f64 == 9007199254740992.0, so term_compare(Integer(9007199254740993), Float(9007199254740992.0)) reports Equal (then Greater) when the values are numerically different. ISO 8.4.2.1 requires the numeric comparison to be exact before applying the float-before-integer tiebreak. A correct fix checks whether the i64 is exactly representable as f64 (i.e. *a == (*a as f64) as i64) and falls back to sign/magnitude ordering when it is not.


2. succ/2 overflow is a hard error in solve() but silent failure in try_exec_misc()

crates/prolog-core/src/solver.rs:822-825 (main loop returns SolveResult::Error)

crates/prolog-core/src/solver.rs:2008-2010 (try_exec_misc returns Some(false))

The overflow arm in try_exec_misc:

None => Some(false),  // silently fails instead of propagating error

succ(9223372036854775807, S) errors when called directly, but silently fails when called inside \+, once/1, or findall/3 (all dispatched through try_exec_misc). A guard like \+ succ(max_int, _) would incorrectly succeed rather than raise an error.

The same error-vs-failure inconsistency exists in two other builtins:

  • functor/3 arity > 1024: solver.rs:~1924 returns Some(false) vs SolveResult::Error in the main loop at lines ~586-590
  • arg/3 non-compound second argument: solver.rs:~1946 returns Some(false) vs SolveResult::Error at line ~626

All three should return None from try_exec_misc (causing callers to treat the result as an error) rather than silently failing.


3. between/3 overflow protection is implicit in try_solve_collecting

crates/prolog-core/src/solver.rs:1739

The main solve() loop guards with low.checked_add(1) (lines 769-776). In try_solve_collecting, the range *low..=*high is formed directly and the only protection is the per-iteration step counter. If low = i64::MIN and high = i64::MAX, the range spans 2^64 values; correct termination depends entirely on max_depth. The same explicit overflow check used in the main loop should be applied here.


4. Missing test: cut inside findall goal

The new integration tests cover between inside findall, sort/msort, copy_term, succ, and plus, but no test exercises ! inside a findall goal body. For example:

findall(X, (member(X, [1,2,3]), X > 1, !), L).

should give L = [2]. The cut_in_try_solve flag in try_solve_collecting is intended to handle this, but the path is unexercised by the test suite.


Summary

Severity File Line Issue
Bug builtins.rs 685, ~697 int as f64 precision loss in term_compare for large integers vs floats
Bug solver.rs 2010 succ/2 overflow silently fails in try_exec_misc instead of erroring
Bug solver.rs ~1924 functor/3 arity > 1024 silently fails in try_exec_misc
Bug solver.rs ~1946 arg/3 non-compound silently fails in try_exec_misc
Safety solver.rs 1739 between/3 in try_solve_collecting relies on step limit for overflow semantics
Test gap tests/integration.rs -- Cut inside findall goal is untested
## Code Review: PR 2 (Phase 5 builtins) Overall the implementation is solid: checked arithmetic throughout, NaN/Infinity detection, proper trail-based undo, and `try_solve_collecting` correctly enumerating `between/3` inside `findall`. Issues below are real bugs I am confident about. --- ### 1. Integer precision loss in `term_compare` for large integers vs floats **`crates/prolog-core/src/builtins.rs:685`** (and symmetric arm ~697) `(*a as f64)` is a lossy cast for `|a| > 2^53`. For example, `9007199254740993_i64 as f64 == 9007199254740992.0`, so `term_compare(Integer(9007199254740993), Float(9007199254740992.0))` reports Equal (then Greater) when the values are numerically different. ISO 8.4.2.1 requires the numeric comparison to be exact before applying the float-before-integer tiebreak. A correct fix checks whether the i64 is exactly representable as f64 (i.e. `*a == (*a as f64) as i64`) and falls back to sign/magnitude ordering when it is not. --- ### 2. `succ/2` overflow is a hard error in `solve()` but silent failure in `try_exec_misc()` **`crates/prolog-core/src/solver.rs:822-825`** (main loop returns `SolveResult::Error`) **`crates/prolog-core/src/solver.rs:2008-2010`** (`try_exec_misc` returns `Some(false)`) The overflow arm in `try_exec_misc`: ``` None => Some(false), // silently fails instead of propagating error ``` `succ(9223372036854775807, S)` errors when called directly, but silently fails when called inside `\+`, `once/1`, or `findall/3` (all dispatched through `try_exec_misc`). A guard like `\+ succ(max_int, _)` would incorrectly **succeed** rather than raise an error. The same error-vs-failure inconsistency exists in two other builtins: - **`functor/3` arity > 1024**: `solver.rs:~1924` returns `Some(false)` vs `SolveResult::Error` in the main loop at lines ~586-590 - **`arg/3` non-compound second argument**: `solver.rs:~1946` returns `Some(false)` vs `SolveResult::Error` at line ~626 All three should return `None` from `try_exec_misc` (causing callers to treat the result as an error) rather than silently failing. --- ### 3. `between/3` overflow protection is implicit in `try_solve_collecting` **`crates/prolog-core/src/solver.rs:1739`** The main `solve()` loop guards with `low.checked_add(1)` (lines 769-776). In `try_solve_collecting`, the range `*low..=*high` is formed directly and the only protection is the per-iteration step counter. If `low = i64::MIN` and `high = i64::MAX`, the range spans 2^64 values; correct termination depends entirely on `max_depth`. The same explicit overflow check used in the main loop should be applied here. --- ### 4. Missing test: cut inside `findall` goal The new integration tests cover `between` inside `findall`, `sort`/`msort`, `copy_term`, `succ`, and `plus`, but no test exercises `!` inside a `findall` goal body. For example: ```prolog findall(X, (member(X, [1,2,3]), X > 1, !), L). ``` should give `L = [2]`. The `cut_in_try_solve` flag in `try_solve_collecting` is intended to handle this, but the path is unexercised by the test suite. --- ### Summary | Severity | File | Line | Issue | |----------|------|------|-------| | Bug | builtins.rs | 685, ~697 | `int as f64` precision loss in `term_compare` for large integers vs floats | | Bug | solver.rs | 2010 | `succ/2` overflow silently fails in `try_exec_misc` instead of erroring | | Bug | solver.rs | ~1924 | `functor/3` arity > 1024 silently fails in `try_exec_misc` | | Bug | solver.rs | ~1946 | `arg/3` non-compound silently fails in `try_exec_misc` | | Safety | solver.rs | 1739 | `between/3` in `try_solve_collecting` relies on step limit for overflow semantics | | Test gap | tests/integration.rs | -- | Cut inside `findall` goal is untested |
claude[bot] commented 2026-03-06 00:31:26 +00:00 (Migrated from github.com)

Phase 5 review posted below as separate comment due to formatting constraints.

Phase 5 review posted below as separate comment due to formatting constraints.
claude[bot] commented 2026-03-06 00:31:41 +00:00 (Migrated from github.com)

Code Review: Phase 5 Built-ins

Reviewed the diff. Issues in order of severity.


Bug 1 (Critical): copy_term/2 infinite recursion on circular terms

File: crates/prolog-core/src/solver.rs, line 2207

This PR removes the occurs check from =/2, which is correct per ISO — X = f(X) now succeeds, creating a circular binding Var(0) -> Compound(f, [Var(0)]). But copy_term_impl has no cycle guard. Walking Var(0) yields Compound(f, [Var(0)]), the Compound arm at line 2207 recurses on arg Var(0), which walks back to Compound again — unbounded stack recursion.

apply_impl in unify.rs handles this correctly via a seen: Vec stack (push before following a binding, pop after). copy_term_impl needs the same treatment: if a VarId is already in seen, emit a fresh unbound variable to break the cycle instead of recursing.

Reproducer: X = f(X), copy_term(X, Y). — stack-overflows today with no test catching it.

## Code Review: Phase 5 Built-ins Reviewed the diff. Issues in order of severity. --- **Bug 1 (Critical): copy_term/2 infinite recursion on circular terms** File: crates/prolog-core/src/solver.rs, line 2207 This PR removes the occurs check from =/2, which is correct per ISO — X = f(X) now succeeds, creating a circular binding Var(0) -> Compound(f, [Var(0)]). But copy_term_impl has no cycle guard. Walking Var(0) yields Compound(f, [Var(0)]), the Compound arm at line 2207 recurses on arg Var(0), which walks back to Compound again — unbounded stack recursion. apply_impl in unify.rs handles this correctly via a seen: Vec<VarId> stack (push before following a binding, pop after). copy_term_impl needs the same treatment: if a VarId is already in seen, emit a fresh unbound variable to break the cycle instead of recursing. Reproducer: X = f(X), copy_term(X, Y). — stack-overflows today with no test catching it.
claude[bot] commented 2026-03-06 00:32:08 +00:00 (Migrated from github.com)

Bug 2 (Medium): term_compare gives wrong ordering for large integers vs. floats

File: crates/prolog-core/src/builtins.rs, line 685

let cmp = (*a as f64).partial_cmp(b).unwrap_or(Ordering::Less);

For integers with |n| > 2^53, the i64->f64 cast loses precision. The equal-value tie-break then fires on values that appear equal but differ. Concrete failing case:

compare(Order, -9007199254740993, -9007199254740992.0).
Expected Order = less-than (integer is strictly more negative).
Actual   Order = greater-than (both collapse to same f64).

-9007199254740993 cast to f64 rounds to -9007199254740992.0. partial_cmp returns Equal; the tie-break claims integer greater-than float — but the integer is actually less.

Affects compare/3, the @-ordering predicates, sort/2, and msort/2 when mixing large-magnitude integers with nearby floats. Fix: detect |a| >= 2^53 and resolve ordering from the rounding direction rather than the lossy cast.

Bug 2 (Medium): term_compare gives wrong ordering for large integers vs. floats File: crates/prolog-core/src/builtins.rs, line 685 let cmp = (*a as f64).partial_cmp(b).unwrap_or(Ordering::Less); For integers with |n| > 2^53, the i64->f64 cast loses precision. The equal-value tie-break then fires on values that appear equal but differ. Concrete failing case: compare(Order, -9007199254740993, -9007199254740992.0). Expected Order = less-than (integer is strictly more negative). Actual Order = greater-than (both collapse to same f64). -9007199254740993 cast to f64 rounds to -9007199254740992.0. partial_cmp returns Equal; the tie-break claims integer greater-than float — but the integer is actually less. Affects compare/3, the @-ordering predicates, sort/2, and msort/2 when mixing large-magnitude integers with nearby floats. Fix: detect |a| >= 2^53 and resolve ordering from the rounding direction rather than the lossy cast.
claude[bot] commented 2026-03-06 00:32:19 +00:00 (Migrated from github.com)

Bug 3 (Low / architectural): Cut removes innermost barrier CP only, not the enclosing predicate's

File: crates/prolog-core/src/solver.rs, lines 215-219

Ok(BuiltinResult::Cut) => {
    while let Some(cp) = self.choice_stack.pop() {
        if cp.cut_barrier { break; }
    }
    continue;
}

The new code is better than the old (which left the first barrier CP on the stack). For a simple one-level predicate with ! it is now correct. For nested predicates it still fails:

foo :- bar, !, baz.
foo :- qux.
bar :- a.
bar :- b.

When ! fires, stack = [foo_CP(barrier, untried=[qux]), bar_CP(barrier, untried=[b])]. The loop pops bar_CP and stops. foo_CP survives, so backtracking from baz can retry qux. ISO requires ! to cut all choice points created since foo was called, including foo_CP.

The correct fix is to track cut-depth as an integer rather than a boolean flag (architectural change). A regression test for this nested pattern would at least document the limitation.

Bug 3 (Low / architectural): Cut removes innermost barrier CP only, not the enclosing predicate's File: crates/prolog-core/src/solver.rs, lines 215-219 Ok(BuiltinResult::Cut) => { while let Some(cp) = self.choice_stack.pop() { if cp.cut_barrier { break; } } continue; } The new code is better than the old (which left the first barrier CP on the stack). For a simple one-level predicate with ! it is now correct. For nested predicates it still fails: foo :- bar, !, baz. foo :- qux. bar :- a. bar :- b. When ! fires, stack = [foo_CP(barrier, untried=[qux]), bar_CP(barrier, untried=[b])]. The loop pops bar_CP and stops. foo_CP survives, so backtracking from baz can retry qux. ISO requires ! to cut all choice points created since foo was called, including foo_CP. The correct fix is to track cut-depth as an integer rather than a boolean flag (architectural change). A regression test for this nested pattern would at least document the limitation.
claude[bot] commented 2026-03-06 00:32:32 +00:00 (Migrated from github.com)

Safety: var_counter not checked for overflow in functor/3 construction mode

File: crates/prolog-core/src/solver.rs, around line 1030 and in try_exec_misc

functor/3 in construction mode increments var_counter (a u32) by up to 1024 per call using plain addition. Wrapping silently aliases freshly-generated variables with existing ones, causing incorrect unification with no diagnostic. Using checked_add and returning a SolveResult::Error on overflow would prevent silent corruption.


Coverage gaps (the copy_term circular case is a confirmed crash today):

  • copy_term/2: circular input X = f(X), copy_term(X, _) -- stack-overflows
  • =../2: operator functor as list head, e.g. T =.. [+, 1, 2]
  • number_chars/2: negative float round-trip
  • between/3: empty range between(5, 3, _) must fail immediately
  • compare/3: mixed integer/float near the 2^53 precision boundary
  • sort/2: list containing unbound variables
  • mod/2: negative dividend and divisor (-7 mod -3 should give -1)

Minor: In try_solve_collecting, Ok(BuiltinResult::Failure) appears after the atom-chars block rather than grouped with Success and Cut at the top of the match. No correctness impact.

Safety: var_counter not checked for overflow in functor/3 construction mode File: crates/prolog-core/src/solver.rs, around line 1030 and in try_exec_misc functor/3 in construction mode increments var_counter (a u32) by up to 1024 per call using plain addition. Wrapping silently aliases freshly-generated variables with existing ones, causing incorrect unification with no diagnostic. Using checked_add and returning a SolveResult::Error on overflow would prevent silent corruption. --- Coverage gaps (the copy_term circular case is a confirmed crash today): - copy_term/2: circular input X = f(X), copy_term(X, _) -- stack-overflows - =../2: operator functor as list head, e.g. T =.. [+, 1, 2] - number_chars/2: negative float round-trip - between/3: empty range between(5, 3, _) must fail immediately - compare/3: mixed integer/float near the 2^53 precision boundary - sort/2: list containing unbound variables - mod/2: negative dividend and divisor (-7 mod -3 should give -1) --- Minor: In try_solve_collecting, Ok(BuiltinResult::Failure) appears after the atom-chars block rather than grouped with Success and Cut at the top of the match. No correctness impact.
Commenting is not possible because the repository is archived.
No description provided.