query: represent bindings as alists (O(1) extension) - major join speedup

ober

6702e43a64e26008a832d52e576fdf764ac4db17

diff --git a/lib/jerboa-db/query/engine.ss b/lib/jerboa-db/query/engine.ss
index 3fbec9d..94ed63e 100644
--- a/lib/jerboa-db/query/engine.ss
+++ b/lib/jerboa-db/query/engine.ss
@@ -64,15 +64,17 @@
   ;; overall for mixed workloads. Vector-indexed bindings would be ideal (O(1)
   ;; both ways) but require query-compile-time var-index assignment.
 
-  (def (make-empty-bindings) (make-hashtable symbol-hash eq?))
+  ;; A binding-set is an immutable alist ((var . val) ...). Extension is O(1)
+  ;; cons (no whole-map copy) — the dominant cost in a deep join that builds many
+  ;; intermediate bindings — and lookup is assq over the (few) query variables.
+  ;; Unification only adds *new* vars, so no key ever appears twice.
+  (def (make-empty-bindings) '())
 
   (def (binding-ref bindings var)
-    (hashtable-ref bindings var #f))
+    (let ([p (assq var bindings)]) (and p (cdr p))))
 
   (def (binding-set bindings var val)
-    (let ([new-ht (hashtable-copy bindings #t)])
-      (hashtable-set! new-ht var val)
-      new-ht))
+    (cons (cons var val) bindings))
 
   (def (resolve-in-bindings bindings x)
     (if (logic-var? x)
@@ -83,17 +85,10 @@
   ;; Strip dead variables from bindings after each clause to reduce allocation.
 
   (def (project-bindings bindings live-vars n-live)
-    ;; Return a hashtable containing only keys in live-vars.
-    ;; Fast path: if count matches, bindings are already minimal — return as-is.
-    ;; Uses hashtable-size (O(1)) instead of hashtable-keys (allocates vector).
-    (if (= (hashtable-size bindings) n-live)
-        bindings   ;; fast path: already minimal, no allocation
-        (let ([new-ht (make-hashtable symbol-hash eq?)])
-          (for-each (lambda (v)
-                      (let ([val (hashtable-ref bindings v #f)])
-                        (when val (hashtable-set! new-ht v val))))
-                    live-vars)
-          new-ht)))
+    ;; Keep only entries whose var is live. Fast path: already minimal.
+    (if (= (length bindings) n-live)
+        bindings
+        (filter (lambda (p) (memq (car p) live-vars)) bindings)))
 
   (def (project-bindings-list bindings-list live-vars)
     ;; Project every binding in the list to live-vars.
@@ -568,13 +563,12 @@
     (let* ([join-vars   (cadr clause)]
            [sub-clauses (cddr clause)]
            ;; Build restricted bindings containing ONLY the join vars
-           [restricted  (let ([new-ht (make-hashtable symbol-hash eq?)])
-                          (for-each
-                            (lambda (v)
-                              (let ([val (binding-ref bindings v)])
-                                (when val (hashtable-set! new-ht v val))))
-                            join-vars)
-                          new-ht)]
+           [restricted  (let loop ([vs join-vars] [acc '()])
+                          (if (null? vs)
+                              acc
+                              (let ([val (binding-ref bindings (car vs))])
+                                (loop (cdr vs)
+                                      (if val (cons (cons (car vs) val) acc) acc)))))]
            ;; Evaluate sub-clauses from the restricted binding set only
            [results (evaluate-where-clauses db sub-clauses
                       (list restricted) schema rules-ht)])
@@ -953,12 +947,8 @@
   ;; ---- Hash-join execution ----
 
   (def (merge-bindings b1 b2)
-    ;; Merge two binding hashtables; combined keys from both.
-    ;; Values in b2 override b1 on conflict (shouldn't conflict on non-join vars).
-    (let ([new-ht (hashtable-copy b1 #t)])
-      (let-values ([(keys vals) (hashtable-entries b2)])
-        (vector-for-each (lambda (k v) (hashtable-set! new-ht k v)) keys vals))
-      new-ht))
+    ;; Combine two binding alists; b2 wins on conflict. Dedup so no var repeats.
+    (append b2 (filter (lambda (p) (not (assq (car p) b2))) b1)))
 
   (def (evaluate-hash-join db ca cb join-vars bindings-list schema)
     ;; Step 1: evaluate ca from current bindings → side-A
@@ -1405,7 +1395,7 @@
            ;; Reorder clauses for optimal execution
            ;; input-bindings is now a list of binding-sets
            [bound-at-start (if (null? input-bindings) '()
-                               (vector->list (hashtable-keys (car input-bindings))))]
+                               (map car (car input-bindings)))]
            [db-stats (db-value-stats db)]
            [card-fn (make-card-fn db)]
            [ordered-clauses (reorder-clauses where-clauses bound-at-start schema db-stats card-fn)]