diff --git a/Examples/README.md b/Examples/README.md index c59df37..fffe8ad 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -26,6 +26,8 @@ dotnet run --project IronKernel -- run hello.ikc | `contracts.ikr` | Operative/applicative contracts and guarded folding | Prints validated results | | `coroutines.ikr` | Cooperative scheduling experiment | Historical example; not yet covered by the compatibility suite | | `zipper.ikr` | Delimited continuations | Functional zipper traversal | +| `constant-width.ikr` | Mutable vectors, CPS backtracking, `args` | Enumerates constant-width chessboard figures; takes `n k w` | +| `constant-width-amb.ikr` | `shift`/`reset` as an `amb` layer, `vau` search strategies, encapsulation types | Same problem, direct style; also solves n-queens and builds width 3/4/8 figures | | `sqrt.ikr` | Operative-based numeric procedure | Defines `sqrt`; intentionally produces no output | | `yingyang.ikr` | Classic yin-yang continuation loop | Intentionally non-terminating; do not use it to test packaging | diff --git a/Examples/constant-width-amb.ikr b/Examples/constant-width-amb.ikr new file mode 100644 index 0000000..7ac23cb --- /dev/null +++ b/Examples/constant-width-amb.ikr @@ -0,0 +1,434 @@ +; Figures of constant width, second take: nondeterminism and algebra +; ================================================================== +; +; `constant-width.ikr` transcribes the backtracking search from +; +; https://ademar.name/blog/2009/04/finding-figures-of-constant-wi.html +; +; faithfully, continuation-passing style and all. This file attacks the same +; problem -- see that file, or the Hernandez-Robert paper it cites, for what a +; figure of constant width is -- from two directions that suit IronKernel +; rather than F#. +; +; 1. Backtracking is not a program shape here, it is a library. +; +; `shift`/`reset` in IronKernel are multi-shot and re-delimiting, which is +; exactly what a nondeterministic `amb` needs: a choice point invokes the +; captured continuation once per alternative. So the solver is written in +; *direct style* -- `(let ((c (pick-column x lo hi))) ...)` reads as "let c +; be a column", and the enumeration, the undo of the board state, and the +; pruning all happen inside `pick-column`. There is not one explicit +; continuation argument in the search. +; +; The other half of that split is that the *strategy* becomes a value. The +; solver only ever calls `emit`; two operatives decide what that means: +; `collect` gathers every solution, `first-of` escapes with the first one +; through `call/cc`. Same solver, different searches, chosen at the call +; site. Adding a "first 10" or a randomized strategy is a handler, not a +; rewrite. +; +; A consequence worth stating: this solver enumerates figures of *exactly* +; type (n,k,w), disconnected ones included. The original algorithm grows a +; figure outward from a seed square and so can only ever find connected +; figures -- it explicitly cannot solve the n-queens problem, which is the +; w=1 case. This one can, and `(figures 8 8 1)` returning 92 is the check. +; +; 2. For width 3 and up, do not search at all -- build. +; +; The paper's Lemma 1 says that composing an *extended* constant width +; figure with any constant width figure multiplies the widths, and that a +; figure decomposable into transversals stays decomposable. That is a +; constructive recipe, and it is far cheaper than search: the original +; program needed about 31 days to exhaust width 3 on an 11x11 board, whereas +; composing the paper's Figure 3 with its Figure 2 and deleting a transversal +; produces a width 3 figure in milliseconds. Widths 4 and 8 come out of the +; same handful of lines, and peeling the width 8 one twice reaches width 6 -- +; which the paper reports its authors never managed to find by search. +; +; `transversal` is itself written against the `amb` layer from part 1, so +; the same three-line nondeterminism library drives both halves of the file. +; +; Run it: +; +; dotnet run --project IronKernel -- Examples/constant-width-amb.ikr +; +; Takes about half a minute, nearly all of it in the 8-queens enumeration -- +; the constructive half of the file runs in milliseconds. Requires the default +; `unrestricted` profile (it prints to the host console). +; +; Everything printed is checked by `fig-type`, which shares no code with the +; searcher or the builders. The searcher's counts also agree with an +; independent exhaustive row-by-row search: 92 and 4 for 8- and 6-queens, and +; 1, 5, 2, 24, 30 figures of type (4,4,2), (5,4,2), (6,6,2), (7,6,2), (8,8,2). + +; --------------------------------------------------------------------------- +; 1. Nondeterminism as a library +; --------------------------------------------------------------------------- + +(defn (reverse xs) + (letrec ((loop (lambda (l acc) + (if (null? l) acc (loop (cdr l) (cons (car l) acc)))))) + (loop xs ()))) + +; The solution sink. Nondeterministic code calls `emit`; what that does is +; whatever the enclosing strategy installed. +(define sink (make-vector 1 (lambda (s) #inert))) +(defn (emit s) ((vector-ref sink 0) s)) + +; Abandon this branch: capture the continuation up to the delimiter and drop +; it, so control returns to the most recent choice point. +(defn (fail) (shift (lambda (k) #inert))) + +; Yield each element of xs in turn. `k` is multi-shot and re-delimits, so the +; whole remaining computation runs once per alternative. +(defn (amb xs) (shift (lambda (k) (for-each xs k)))) + +; Strategy 1: run body, return the list of everything it emitted. +(define collect + (vau body env + (let ((acc (make-vector 1 ())) (saved (vector-ref sink 0))) + (vector-set! sink 0 + (lambda (s) (vector-set! acc 0 (cons s (vector-ref acc 0))))) + (reset (eval env (cons sequence body))) + (vector-set! sink 0 saved) + (reverse (vector-ref acc 0))))) + +; Strategy 2: stop at the first emission and escape with it, or () if there is +; none. The whole search tree below the first hit is never explored. +(define first-of + (vau body env + (let ((saved (vector-ref sink 0))) + (let ((r (call/cc (lambda (return) + (vector-set! sink 0 (lambda (s) (return s))) + (reset (eval env (cons sequence body))) + ())))) + (vector-set! sink 0 saved) + r)))) + +; --------------------------------------------------------------------------- +; 2. The solver: emit every figure of exactly type (n, k, w) +; +; Rows are decided in order. Each row is either skipped or given exactly w +; columns, and a row's squares are chosen left to right. Every line carries a +; count of squares placed on it so far; a line is legal at the end when that +; count is 0 or w, and legal in the middle when the shortfall still fits in the +; rows below. +; --------------------------------------------------------------------------- + +(defn (figures n k w) + (define col (make-vector n 0)) + (define pd (make-vector (- (* 2 n) 1) 0)) + (define nd (make-vector (- (* 2 n) 1) 0)) + (define fig (make-vector 1 ())) + + (defn (pdx x y) (+ x y)) + (defn (ndx x y) (- (+ n (- x y)) 1)) + + (defn (free? x y) + (if (< (vector-ref col y) w) + (if (< (vector-ref pd (pdx x y)) w) + (< (vector-ref nd (ndx x y)) w) + #f) + #f)) + + (defn (bump! x y d) + (vector-set! col y (+ (vector-ref col y) d)) + (vector-set! pd (pdx x y) (+ (vector-ref pd (pdx x y)) d)) + (vector-set! nd (ndx x y) (+ (vector-ref nd (ndx x y)) d))) + + (defn (place! x y) + (bump! x y 1) + (vector-set! fig 0 (cons (cons x y) (vector-ref fig 0)))) + + (defn (unplace! x y) + (bump! x y -1) + (vector-set! fig 0 (cdr (vector-ref fig 0)))) + + ; The one choice point of the search. Each legal column in [lo,hi) is placed, + ; offered to the continuation, and taken back once that continuation has run + ; to exhaustion -- so the board is always consistent with the caller's view, + ; and the caller never sees a continuation at all. + (defn (pick-column x lo hi) + (shift (lambda (k) + (letrec ((loop (lambda (c) + (if (< c hi) + (begin + (if (free? x c) + (begin (place! x c) (k c) (unplace! x c)) + #inert) + (loop (+ c 1))) + #inert)))) + (loop lo))))) + + ; `need` more squares in row x, all in columns >= y. Reads as a loop; is a + ; tree. + (defn (fill-row x y need) + (if (zero? need) + #inert + (let ((c (pick-column x y (+ (- n need) 1)))) + (fill-row x (+ c 1) (- need 1))))) + + ; How many cells of diagonal `d` lie in rows x..n-1. Both diagonal families + ; are indexed so that this one formula serves for each. + (defn (cells-from d x) + (let ((lo (if (> x (- d (- n 1))) x (- d (- n 1)))) + (hi (if (< (- n 1) d) (- n 1) d))) + (if (< hi lo) 0 (+ (- hi lo) 1)))) + + ; A line is still viable if it is untouched, already full, or short by no + ; more than the cells it has left. At x = n this is the 0-or-w test. + (defn (line-ok? used avail) + (if (zero? used) #t (if (eq? used w) #t (<= (- w used) avail)))) + + (define ndiags (- (* 2 n) 1)) + + (defn (feasible? x) + (let ((rowsleft (- n x))) + (letrec + ((cols (lambda (c) + (if (eq? c n) #t + (if (line-ok? (vector-ref col c) rowsleft) + (cols (+ c 1)) #f)))) + ; both diagonal families share an index, so one `cells-from` serves + ; the pair + (diags (lambda (d) + (if (eq? d ndiags) #t + (let ((a (cells-from d x))) + (if (line-ok? (vector-ref pd d) a) + (if (line-ok? (vector-ref nd d) a) + (diags (+ d 1)) #f) + #f)))))) + (if (cols 0) (diags 0) #f)))) + + (defn (solve x placed) + (if (eq? x n) + ; every row decided: the figure must have k filled rows and no line may + ; be left part-way + (if (eq? placed k) + (if (feasible? n) (emit (vector-ref fig 0)) (fail)) + (fail)) + (begin + (if (< (- n x) (- k placed)) (fail) #inert) ; too few rows left + (if (feasible? x) #inert (fail)) + (if (amb (list #t #f)) + (begin (if (< placed k) #inert (fail)) + (fill-row x 0 w) + (solve (+ x 1) (+ placed 1))) + (solve (+ x 1) placed))))) + + (solve 0 0)) + +; --------------------------------------------------------------------------- +; 3. Figures as an abstract type, and the constant width test +; --------------------------------------------------------------------------- + +(provide! (figure figure? fig-n fig-squares) + (define (enc figure? dec) (make-encapsulation-type)) + (define figure (lambda (n squares) (enc (list n squares)))) + (define fig-n (lambda (f) (car (dec f)))) + (define fig-squares (lambda (f) (cadr (dec f))))) + +(defn (tally! v i) (vector-set! v i (+ (vector-ref v i) 1))) + +; (n k w) when f has constant width, () otherwise. Everything this file builds +; is checked with this, and it shares no code with the builders. +(defn (fig-type f) + (let* ((n (fig-n f)) (s (fig-squares f)) (m (- (* 2 n) 1)) + (rw (make-vector n 0)) (cl (make-vector n 0)) + (pd (make-vector m 0)) (nd (make-vector m 0))) + (for-each s (lambda (p) + (tally! rw (car p)) + (tally! cl (cdr p)) + (tally! pd (+ (car p) (cdr p))) + (tally! nd (- (+ n (- (car p) (cdr p))) 1)))) + ; fold the four families together: every non-zero count must agree + (letrec ((scan (lambda (v i len w) + (if (eq? i len) w + (let ((c (vector-ref v i))) + (if (zero? c) (scan v (+ i 1) len w) + (if (zero? w) (scan v (+ i 1) len c) + (if (eq? c w) (scan v (+ i 1) len w) -1)))))))) + (let* ((w1 (scan rw 0 n 0)) (w2 (scan cl 0 n w1)) + (w3 (scan pd 0 m w2)) (w (scan nd 0 m w3))) + (if (< w 1) () (list n (/ (length s) w) w)))))) + +; `/` truncates toward zero, so fold the negative case back by hand. +(defn (modulo a m) (let ((r (- a (* m (/ a m))))) (if (< r 0) (+ r m) r))) + +; Extended constant width: the diagonals wrap around the board, so the lines +; are i+j and i-j taken mod n. Lemma 1 needs this of its left operand. +(defn (extended? f) + (let* ((n (fig-n f)) (s (fig-squares f)) + (p (make-vector n 0)) (q (make-vector n 0))) + (for-each s (lambda (sq) + (tally! p (modulo (+ (car sq) (cdr sq)) n)) + (tally! q (modulo (- (car sq) (cdr sq)) n)))) + (let ((t (fig-type f))) + (if (null? t) #f + (let ((w (car (cddr t)))) + (letrec ((ok (lambda (v i) + (if (eq? i n) #t + (let ((c (vector-ref v i))) + (if (zero? c) (ok v (+ i 1)) + (if (eq? c w) (ok v (+ i 1)) #f))))))) + (if (ok p 0) (ok q 0) #f))))))) + +; --------------------------------------------------------------------------- +; 4. The algebra +; --------------------------------------------------------------------------- + +; Lemma 1: drop a copy of f1 into every square of f2. If f1 has extended +; constant width w1 and f2 constant width w2, the result has constant width +; w1*w2 on a board (n1*n2) wide. +(defn (compose f1 f2) + (let ((m (fig-n f1)) (acc (make-vector 1 ()))) + (for-each (fig-squares f2) (lambda (q) + (for-each (fig-squares f1) (lambda (p) + (vector-set! acc 0 + (cons (cons (+ (car p) (* m (car q))) (+ (cdr p) (* m (cdr q)))) + (vector-ref acc 0))))))) + (figure (* m (fig-n f2)) (vector-ref acc 0)))) + +(defn (same-square? p q) (if (eq? (car p) (car q)) (eq? (cdr p) (cdr q)) #f)) + +(defn (member-of? p s) + (if (null? s) #f (if (same-square? p (car s)) #t (member-of? p (cdr s))))) + +; A transversal is a subset meeting every line of f exactly once. Because f has +; constant width w and kw squares, it touches exactly k lines of each of the +; four families, so a transversal is one square per filled row whose columns +; and diagonals are all distinct -- a perfect matching, found here by the same +; `amb` machinery as the figure search. Nondeterministic: wrap in `first-of`. +(defn (transversal f) + (let* ((n (fig-n f)) (s (fig-squares f)) (m (- (* 2 n) 1)) + (byrow (make-vector n ())) + (uc (make-vector n 0)) (up (make-vector m 0)) (un (make-vector m 0))) + (for-each s (lambda (p) + (vector-set! byrow (car p) (cons p (vector-ref byrow (car p)))))) + (letrec + ((pdx (lambda (p) (+ (car p) (cdr p)))) + (ndx (lambda (p) (- (+ n (- (car p) (cdr p))) 1))) + (avail? (lambda (p) + (if (zero? (vector-ref uc (cdr p))) + (if (zero? (vector-ref up (pdx p))) + (zero? (vector-ref un (ndx p))) + #f) + #f))) + (mark! (lambda (p d) + (vector-set! uc (cdr p) (+ (vector-ref uc (cdr p)) d)) + (vector-set! up (pdx p) (+ (vector-ref up (pdx p)) d)) + (vector-set! un (ndx p) (+ (vector-ref un (ndx p)) d)))) + (pick (lambda (cands) + (shift (lambda (k) + (for-each cands (lambda (p) + (if (avail? p) + (begin (mark! p 1) (k p) (mark! p -1)) + #inert))))))) + (go (lambda (r acc) + (if (eq? r n) + (emit (reverse acc)) + (if (null? (vector-ref byrow r)) + (go (+ r 1) acc) + (let ((p (pick (vector-ref byrow r)))) + (go (+ r 1) (cons p acc)))))))) + (go 0 ())))) + +; Deleting a transversal from a figure of constant width w leaves one of +; constant width w-1. +(defn (fig-remove f t) + (let ((acc (make-vector 1 ()))) + (for-each (fig-squares f) (lambda (p) + (if (member-of? p t) + #inert + (vector-set! acc 0 (cons p (vector-ref acc 0)))))) + (figure (fig-n f) (vector-ref acc 0)))) + +(defn (peel f) + (let ((t (first-of (transversal f)))) + (if (null? t) () (fig-remove f t)))) + +; --------------------------------------------------------------------------- +; 5. Reporting +; --------------------------------------------------------------------------- + +(defn (describe name f) + (let ((t (fig-type f))) + (if (null? t) + (printf " {0}: NOT of constant width\n" name) + (printf " {0}: {1} squares on {2}x{2}, type ({2},{3},{4}){5}\n" + name (length (fig-squares f)) (car t) (cadr t) (car (cddr t)) + (if (extended? f) ", extended" ""))))) + +(defn (draw f) + (let ((n (fig-n f)) (s (fig-squares f))) + (letrec ((loop (lambda (x y) + (if (eq? x n) #inert + (if (eq? y n) + (begin (print "\n") (loop (+ x 1) 0)) + (begin + (print (if (member-of? (cons x y) s) "Q" ".")) + (loop x (+ y 1)))))))) + (loop 0 0)))) + +(defn (draw-squares n s) (draw (figure n s))) + +; --------------------------------------------------------------------------- +; 6. Demonstration +; --------------------------------------------------------------------------- + +(print "searching -- one solver, two strategies\n\n") + +(printf " (8,8,1) queens : {0} solutions\n" + (length (collect (figures 8 8 1)))) +(printf " (6,6,1) queens : {0} solutions\n" + (length (collect (figures 6 6 1)))) +(printf " (4,4,2) : {0} figure(s)\n" + (length (collect (figures 4 4 2)))) +(printf " (5,4,2) : {0} figure(s)\n" + (length (collect (figures 5 4 2)))) +(printf " (6,6,2), all : {0} figure(s)\n" + (length (collect (figures 6 6 2)))) +(printf " (6,6,2), first only : {0} squares\n" + (length (first-of (figures 6 6 2)))) +(print "\n") +(draw-squares 5 (car (collect (figures 5 4 2)))) + +(print "\nbuilding -- Lemma 1 and transversal deletion\n\n") + +; The paper's Figure 2 and Figure 3, the two smallest width 2 figures. Both +; come straight out of the search above; Figure 3 is the one with the wrapping +; diagonals, which is what makes the composition below legal. +(define fig2 (figure 4 '((0 & 1) (0 & 2) (1 & 0) (1 & 3) + (2 & 0) (2 & 3) (3 & 1) (3 & 2)))) +(define fig3 (figure 5 '((0 & 1) (0 & 3) (1 & 0) (1 & 4) + (3 & 0) (3 & 4) (4 & 1) (4 & 3)))) + +(describe "Figure 2" fig2) +(describe "Figure 3" fig3) + +(define w4 (compose fig3 fig2)) +(define w3 (peel w4)) +(define w2 (peel w3)) +(define w1 (peel w2)) + +(describe "fig3 . fig2" w4) +(describe " peeled once" w3) +(describe " peeled twice" w2) +(describe " peeled thrice" w1) + +(print "\n constant width 3 on a 20x20 board (the paper's Figure 6):\n\n") +(draw w3) + +; One more composition doubles the width again, and peeling that reaches the +; widths in between. The paper reports that its authors never found a figure of +; width 6 with a computer search; this one is built and checked in under a +; second. +(define w8 (compose fig3 w4)) +(define w7 (peel w8)) +(define w6 (peel w7)) + +(print "\n") +(describe "fig3 . (fig3 . fig2)" w8) +(describe " peeled once" w7) +(describe " peeled twice" w6) +(print "\n") diff --git a/Examples/constant-width.ikr b/Examples/constant-width.ikr new file mode 100644 index 0000000..814d45d --- /dev/null +++ b/Examples/constant-width.ikr @@ -0,0 +1,304 @@ +; Figures of constant width on a chessboard +; ========================================= +; +; A *figure* is any set of squares on an n x n board. It has *constant width w* +; when every row, every column and every diagonal (slope +1 and slope -1) meets +; it in either 0 or exactly w squares. A figure of constant width w occupying +; k*w squares is said to be of type (n, k, w); k is then also its number of +; non-empty rows. Figures of type (n, n, 1) are exactly the solutions of the +; n-queens problem. +; +; Janko Hernandez, Leonel Robert, "Figures of Constant Width on a Chessboard", +; American Mathematical Monthly 112 (2005) 42-50. +; https://userweb.ucs.louisiana.edu/~C00254569/hernandez-robert.pdf +; +; This program is an IronKernel transcription of the exhaustive backtracking +; search described in +; +; https://ademar.name/blog/2009/04/finding-figures-of-constant-wi.html +; +; It enumerates the *connected* figures of type (n, _, w). Two squares are +; adjacent when they share a row, a column, or a main diagonal; a figure is +; connected when every pair of its squares is joined by a chain of such steps. +; Constant width 1 figures (the n-queens solutions) are totally disconnected, +; so they are never reported. +; +; How the search works +; -------------------- +; For each of the 2n + 2(2n-1) lines of the board we keep the number of squares +; that still have to be placed on it: w for an untouched line, 0 for a saturated +; one. Committing to a square decrements the four counters running through it. +; +; The frontier ("buffer") holds squares that are already committed but whose +; local constraints have not been discharged yet. Popping a square p off the +; frontier means choosing, right there, *every* square that still has to share a +; line with p: exactly (cap-r p) more in its row, (cap-c p) in its column, and +; likewise on both diagonals. Those choices join the frontier, and every other +; square on p's four lines is permanently out of play -- which is what makes the +; search converge instead of wandering the board. +; +; When the frontier drains, every committed square has all four of its lines +; saturated, so the accumulated set is a figure of constant width w. It is +; connected, because the frontier only ever grew along lines of squares already +; in it. +; +; Running it +; ---------- +; dotnet run --project IronKernel -- Examples/constant-width.ikr ; 5 4 2 +; dotnet run --project IronKernel -- Examples/constant-width.ikr 4 4 2 +; dotnet run --project IronKernel -- Examples/constant-width.ikr 6 6 2 +; dotnet run --project IronKernel -- Examples/constant-width.ikr 11 10 3 +; +; The arguments are n k w : the board size, the width, and k as a pruning +; bound. Give all three or none. k*w is not a filter on the output but a cutoff +; on the search: a branch is abandoned as soon as it can no longer reach k*w +; squares, yet a branch that survives still reports whatever figure it lands on, +; which may be a smaller one. So every figure of type (n, k', w) with k' >= k is +; guaranteed to be found, some figures below k appear as a bonus, and raising k +; only makes the search faster. Each figure is printed with the type it really +; has. +; +; The default 5 4 2 run finds five figures in about three seconds: the paper's +; Figure 3, of type (5,4,2), plus the four placements of its Figure 2 -- the +; unique connected figure of type (4,4,2) -- inside the 5x5 board. +; +; args time figures found +; ------ ------ ----------------------------------------------- +; 4 4 2 0.4 s 1 x (4,4,2) the paper's Figure 2 +; 5 4 2 3 s 5 x (5,4,2) the default run +; 6 6 2 16 s 2 x (6,6,2) +; 6 4 2 28 s 15 x (6,4,2), 2 x (6,6,2) +; 7 6 2 5.6 min 24 x (7,6,2), 4 x (7,4,2) +; +; These counts agree with an independent exhaustive row-by-row search. Width 3 +; is a different world: the smallest figure of constant width 3 lives on an +; 11x11 board, and the original F# program needed some 31 days to exhaust +; (11, _, 3), so treat that last command line as a long-running job rather than +; a demo. +; +; The default `unrestricted` profile is required: the program prints through the +; host console and parses its arguments with System.Int32.Parse. +; +; Hot paths below use nested `if` rather than `cond`, `and?` and `or?`. Those +; three are compound operatives from the bootstrap library and cost an `eval` +; per clause; `if` is a primitive. Avoiding them in the innermost loops makes +; the whole search about three times faster. + +; --------------------------------------------------------------------------- +; Small list helpers (the bootstrap library deliberately stays minimal) +; --------------------------------------------------------------------------- + +(defn (append xs ys) + (if (null? xs) ys (cons (car xs) (append (cdr xs) ys)))) + +(defn (>=? a b) (not? (< a b))) + +; --------------------------------------------------------------------------- +; The search +; +; n board size +; w the constant width +; k pruning bound: branches that can no longer reach k*w squares are cut +; report applied to every figure found, as a list of (x . y) squares +; --------------------------------------------------------------------------- + +(defn (find-figures n k w report) + + ; Remaining capacity of every line. Rows and columns are indexed by + ; coordinate; the diagonal of slope -1 through (x, y) is indexed by x + y and + ; the one of slope +1 by n + (x - y) - 1, so both fit in 2n-1 slots. + (define rows (make-vector n w)) + (define columns (make-vector n w)) + (define diag1 (make-vector (- (* 2 n) 1) w)) + (define diag2 (make-vector (- (* 2 n) 1) w)) + + (defn (pdiag p) (+ (car p) (cdr p))) + (defn (ndiag p) (- (+ n (- (car p) (cdr p))) 1)) + + (defn (cap-r p) (vector-ref rows (car p))) + (defn (cap-c p) (vector-ref columns (cdr p))) + (defn (cap-pd p) (vector-ref diag1 (pdiag p))) + (defn (cap-nd p) (vector-ref diag2 (ndiag p))) + + ; Add d to each of the four counters running through p. + (defn (alter! p d) + (let ((r (car p)) (c (cdr p)) (i1 (pdiag p)) (i2 (ndiag p))) + (vector-set! rows r (+ (vector-ref rows r) d)) + (vector-set! columns c (+ (vector-ref columns c) d)) + (vector-set! diag1 i1 (+ (vector-ref diag1 i1) d)) + (vector-set! diag2 i2 (+ (vector-ref diag2 i2) d)) + #inert)) + + (defn (take! p) (alter! p -1)) + (defn (restore! p) (alter! p 1)) + + ; A square becomes unusable as soon as any one of its four lines is full. + (defn (open? p) + (if (zero? (cap-r p)) #f + (if (zero? (cap-c p)) #f + (if (zero? (cap-pd p)) #f + (if (zero? (cap-nd p)) #f #t))))) + + ; The board minus the corner regions, whose diagonals are shorter than w and + ; so can never carry a full width. + (defn (playable-board) + (let ((lo (- w 1)) (hi (- (- (* 2 n) w) 1))) + (letrec + ((keep? (lambda (p) + (and? (<= lo (pdiag p)) (<= (pdiag p) hi) + (<= lo (ndiag p)) (<= (ndiag p) hi)))) + ; built back to front, so the result comes out in row-major order + (loop (lambda (x y acc) + (cond ((< x 0) acc) + ((< y 0) (loop (- x 1) (- n 1) acc)) + (#t (let ((p (cons x y))) + (loop x (- y 1) + (if (keep? p) (cons p acc) acc)))))))) + (loop (- n 1) (- n 1) ())))) + + ; Split the candidate squares by how they see q: same row, same column, same + ; diagonal of slope -1, same diagonal of slope +1, or unrelated. Two distinct + ; squares share at most one of the four lines, so the tests are disjoint. + ; Returns (r rws c cls d1 dg1 d2 dg2 rest), each count beside its list. + (defn (collect q avail) + (let ((qx (car q)) (qy (cdr q)) (qp (pdiag q)) (qn (- (car q) (cdr q)))) + (letrec + ((loop + (lambda (av r rws c cls d1 dg1 d2 dg2 rest) + (if (null? av) + (list r rws c cls d1 dg1 d2 dg2 rest) + (let* ((p (car av)) (tail (cdr av)) + (x (car p)) (y (cdr p))) + (if (eq? x qx) + (loop tail (+ r 1) (cons p rws) c cls d1 dg1 d2 dg2 rest) + (if (eq? y qy) + (loop tail r rws (+ c 1) (cons p cls) d1 dg1 d2 dg2 rest) + (if (eq? (+ x y) qp) + (loop tail r rws c cls (+ d1 1) (cons p dg1) d2 dg2 rest) + (if (eq? (- x y) qn) + (loop tail r rws c cls d1 dg1 (+ d2 1) (cons p dg2) rest) + (loop tail r rws c cls d1 dg1 d2 dg2 (cons p rest))))))))))) + (loop avail 0 () 0 () 0 () 0 () ())))) + + ; Drop the squares that have gone dead, and count what is left. + (defn (count-open avail) + (letrec + ((loop (lambda (av len kept) + (if (null? av) + (list len kept) + (if (open? (car av)) + (loop (cdr av) (+ len 1) (cons (car av) kept)) + (loop (cdr av) len kept)))))) + (loop avail 0 ()))) + + ; Commit to exactly m of the still-open squares of `avail`, in every possible + ; way, appending each choice to the frontier before invoking k*. Every square + ; is restored on the way out, so the counters are unchanged on return. + (defn (choose avail m bufflen buff k*) + (letrec + ((loop (lambda (av chosen j) + (if (zero? j) + (k* (+ bufflen m) (append buff chosen)) + (if (null? av) + #inert + (let ((p (car av)) (tail (cdr av))) + (if (open? p) + (begin (take! p) + (loop tail (cons p chosen) (- j 1)) + (restore! p)) + #inert) + (loop tail chosen j))))))) + (loop avail () m))) + + ; sol squares whose constraints are already discharged + ; len how many more squares this branch still has to reach + ; avail candidate squares, avlen of them + ; buff committed squares awaiting their turn, bufflen of them + (defn (search sol len avlen avail bufflen buff) + (if (< (+ avlen bufflen) len) + #inert ; too few squares left + (if (null? buff) + (report sol) ; frontier drained: a figure + (let* ((p (car buff)) (tail (cdr buff))) + (let (((r rws c cls d1 dg1 d2 dg2 rest) (collect p avail)) + ; The capacities are read before any of the four groups is + ; touched. No square lies on two of p's lines, so choosing + ; within one group cannot disturb the other three counters. + (need-r (cap-r p)) (need-c (cap-c p)) + (need-pd (cap-pd p)) (need-nd (cap-nd p))) + ; not enough candidates left on one of p's lines to fill it + (if (if (< r need-r) #t + (if (< c need-c) #t + (if (< d1 need-pd) #t (< d2 need-nd)))) + #inert + (choose rws need-r (- bufflen 1) tail + (lambda (b1 buf1) + (choose cls need-c b1 buf1 + (lambda (b2 buf2) + (choose dg1 need-pd b2 buf2 + (lambda (b3 buf3) + (choose dg2 need-nd b3 buf3 + (lambda (b4 buf4) + (let (((avlen* avail*) (count-open rest))) + (search (cons p sol) (- len 1) + avlen* avail* b4 buf4)))))))))))))))) + + ; Seed the search at every square in turn. Each figure is found exactly once, + ; from its first square in row-major order: a seed only ever sees the squares + ; that follow it. + (letrec + ((loop (lambda (l avlen) + (if (null? l) + #inert + (let ((p (car l)) (tail (cdr l))) + (take! p) + (search () (* k w) (- avlen 1) tail 1 (list p)) + (restore! p) + (loop tail (- avlen 1))))))) + (let (((len board) (count-open (playable-board)))) + (loop board len)))) + +; --------------------------------------------------------------------------- +; Reporting +; --------------------------------------------------------------------------- + +(defn (occupied? p sol) + (cond ((null? sol) #f) + ((and? (eq? (car p) (car (car sol))) + (eq? (cdr p) (cdr (car sol)))) #t) + (#t (occupied? p (cdr sol))))) + +(defn (draw n sol) + (letrec ((loop (lambda (x y) + (cond ((>=? x n) #inert) + ((>=? y n) (print "\n") (loop (+ x 1) 0)) + (#t (print (if (occupied? (cons x y) sol) "Q " ". ")) + (loop x (+ y 1))))))) + (loop 0 0))) + +(defn (report-figures n k w) + (let ((found (make-vector 1 0))) + (printf "connected figures of constant width {0} on a {1}x{1} board\n" w n) + (printf "(pruning branches that cannot reach {0} squares)\n\n" (* k w)) + (find-figures n k w + (lambda (sol) + (vector-set! found 0 (+ (vector-ref found 0) 1)) + (printf "#{0}: type ({1}, {2}, {3})\n" + (vector-ref found 0) n (/ (length sol) w) w) + (draw n sol) + (print "\n"))) + (printf "{0} figure(s)\n" (vector-ref found 0)))) + +; --------------------------------------------------------------------------- +; Entry point +; --------------------------------------------------------------------------- + +(defn (arg i default) + (letrec ((nth (lambda (xs j) + (cond ((null? xs) default) + ((zero? j) (. System.Int32 Parse (car xs))) + (#t (nth (cdr xs) (- j 1))))))) + (nth args i))) + +(let ((n (arg 0 5)) (k (arg 1 4)) (w (arg 2 2))) + (time (report-figures n k w))) diff --git a/README.md b/README.md index a0cd69b..edc6100 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,15 @@ Public packages use NuGet.org initially. See [`ADR 0001`](docs/adr/0001-source-project-and-package-conventions.md) for the extension and ecosystem decision. +### Libraries in this repository + +[`lib/`](lib/) holds first-party IronKernel packages, each an ordinary +`.ikproj` built with `ik test` / `ik pack`. + +| Package | Provides | +|---|---| +| [`IronKernel.Amb`](lib/IronKernel.Amb/) | Nondeterministic search: `amb`, `require`, bracketed choice, and pluggable search strategies over multi-shot delimited continuations | + ## REPL ```bash diff --git a/lib/IronKernel.Amb/.gitignore b/lib/IronKernel.Amb/.gitignore new file mode 100644 index 0000000..b262931 --- /dev/null +++ b/lib/IronKernel.Amb/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +*.ikc diff --git a/lib/IronKernel.Amb/IronKernel.Amb.ikproj b/lib/IronKernel.Amb/IronKernel.Amb.ikproj new file mode 100644 index 0000000..de85efe --- /dev/null +++ b/lib/IronKernel.Amb/IronKernel.Amb.ikproj @@ -0,0 +1,18 @@ + + + net10.0 + false + true + IronKernel.Amb + 0.1.0 + IronKernel + Nondeterministic search for IronKernel: amb, require, and pluggable search strategies built on multi-shot delimited continuations. + ironkernel + src/amb.ikr + unrestricted + + + + + + diff --git a/lib/IronKernel.Amb/README.md b/lib/IronKernel.Amb/README.md new file mode 100644 index 0000000..49aba5f --- /dev/null +++ b/lib/IronKernel.Amb/README.md @@ -0,0 +1,96 @@ +# IronKernel.Amb + +Nondeterministic search for IronKernel. Choice points are ordinary +expressions, the search strategy is a value, and the code doing the searching +never mentions a continuation. + +```scheme +(collect + (let ((a (amb (list 1 2)))) + (let ((b (amb (list 10 20)))) + (emit (cons a b))))) +; => ((1 . 10) (1 . 20) (2 . 10) (2 . 20)) +``` + +Built on IronKernel's `shift`/`reset`, which are multi-shot and re-delimit when +resumed — so a choice point can invoke its captured continuation once per +alternative. + +## Use + +```bash +ik add IronKernel.Amb 0.1.0 +``` + +Or, from a checkout of this repository: + +```bash +cd lib/IronKernel.Amb +ik test +ik pack +``` + +## API + +### Choice points + +| Form | Meaning | +|---|---| +| `(amb xs)` | Yield each element of `xs`. An empty list fails. | +| `(amb-range lo hi)` | Yield each integer in `[lo, hi)` without building a list. | +| `(require test)` | Continue only if `test` holds, otherwise fail. | +| `(fail)` | Abandon the current branch. | +| `(amb-bracket xs ok? enter! leave!)` | Yield each `x` satisfying `ok?`, with `enter!` applied before the continuation runs and `leave!` after it finishes. | +| `(amb-bracket-range lo hi ok? enter! leave!)` | The same over `[lo, hi)`. | + +### Reporting + +| Form | Meaning | +|---|---| +| `(emit s)` | Report a solution. A no-op outside any strategy. | + +### Strategies + +Operatives. Each installs a delimiter, runs the body, and restores the +previous handler. + +| Form | Returns | +|---|---| +| `(collect body …)` | List of every solution, in the order found. | +| `(first-of body …)` | The first solution, or `()`. Escapes through `call/cc`, so the rest of the tree is never explored. | +| `(count-of body …)` | How many solutions, retaining none of them. | +| `(search handler body …)` | `#inert`; applies `handler` to each solution. Use for strategies of your own. | + +## Mutable state and backtracking + +A search that mutates a board or a set of used lines has to undo that mutation +when a branch is abandoned, and doing it at the call site does not work: by the +time a choice expression returns, the rest of the search has already run inside +the continuation. The undo belongs to the choice point, which is what +`amb-bracket` is for. + +```scheme +(let ((y (amb-bracket-range 0 n + (lambda (c) (free? x c)) ; guard + (lambda (c) (mark! x c 1)) ; before the continuation runs + (lambda (c) (mark! x c -1))))) ; after it has been exhausted + (place-queen (+ x 1))) +``` + +State stays consistent with the caller's view of it, and the caller still never +sees a continuation. `test/amb_test.ikr` builds n-queens this way in about +twenty lines. + +## Notes + +- Strategies nest: an inner search runs to completion inside one branch of an + outer one. +- The library performs no host I/O. +- `amb` and friends must run inside a strategy — that is what installs the + `reset` their `shift` needs. + +## Origin + +Extracted from [`Examples/constant-width-amb.ikr`](../../Examples/constant-width-amb.ikr), +which enumerates figures of constant width on a chessboard and, as the width-1 +case, solves the n-queens problem. diff --git a/lib/IronKernel.Amb/src/amb.ikr b/lib/IronKernel.Amb/src/amb.ikr new file mode 100644 index 0000000..9689d71 --- /dev/null +++ b/lib/IronKernel.Amb/src/amb.ikr @@ -0,0 +1,163 @@ +; IronKernel.Amb -- nondeterministic search as a library +; ====================================================== +; +; Backtracking, expressed so that the code doing the searching never mentions +; a continuation. IronKernel's `shift`/`reset` are multi-shot and re-delimit +; when resumed, so a choice point can simply invoke its captured continuation +; once per alternative: +; +; (collect +; (let ((a (amb (list 1 2)))) +; (let ((b (amb (list 10 20)))) +; (emit (cons a b))))) +; ; => ((1 . 10) (1 . 20) (2 . 10) (2 . 20)) +; +; Two ideas carry the library. +; +; Choice points are ordinary expressions. `amb` returns each element of a +; list, `amb-range` each integer of a half-open interval, and `require` prunes +; the current branch. Search code reads as if it were straight-line code that +; happens to be run once per candidate. +; +; The strategy is a value. Nondeterministic code only ever calls `emit`; what +; that means is decided by the operative wrapping it. `collect` gathers every +; solution, `first-of` escapes with the first, `count-of` tallies without +; retaining anything, and `search` takes an arbitrary handler so you can add +; your own. The searcher does not change when the strategy does. +; +; Mutable state and backtracking +; ------------------------------ +; A search that mutates a board, a counter or a set of used lines has to undo +; that mutation when a branch is abandoned. Doing it at the call site does not +; work: by the time a choice expression returns, the rest of the search has +; already run inside the continuation. The undo belongs to the choice point, +; which is what `amb-bracket` provides -- it enters a candidate, hands it to +; the continuation, and leaves it only once that continuation has run to +; exhaustion: +; +; (let ((c (amb-bracket-range 0 n free? place! unplace!))) +; (place-rest (+ c 1))) +; +; State is therefore always consistent with the caller's view of it, and the +; caller still never sees a continuation. +; +; Notes +; ----- +; * `emit` outside any strategy is a no-op, so a nondeterministic procedure is +; safe to call anywhere. +; * Strategies nest. Each installs its own delimiter, so an inner search runs +; to completion inside one branch of an outer one, and restores the previous +; handler when it is done. +; * Nothing here performs host I/O. + +(provide! (fail require amb amb-range amb-bracket amb-bracket-range + emit search collect first-of count-of) + + ; --- private ------------------------------------------------------------ + + (define list-reverse + (lambda (xs) + (letrec ((loop (lambda (l acc) + (if (null? l) acc (loop (cdr l) (cons (car l) acc)))))) + (loop xs ())))) + + ; The handler in force. Held in a vector so that the strategy operatives can + ; swap it dynamically and put it back afterwards. + (define sink (make-vector 1 (lambda (s) #inert))) + + (define with-sink + (lambda (handler thunk) + (let ((saved (vector-ref sink 0))) + (vector-set! sink 0 handler) + (reset (thunk)) + (vector-set! sink 0 saved)))) + + ; --- choice points ------------------------------------------------------ + + ; Abandon this branch: capture the continuation up to the delimiter and drop + ; it, returning control to the most recent choice point. + (define fail (lambda () (shift (lambda (k) #inert)))) + + ; Keep going only if the test holds. + (define require (lambda (test) (if test #inert (fail)))) + + ; Yield each element of xs. An empty list fails. + (define amb (lambda (xs) (shift (lambda (k) (for-each xs k))))) + + ; Yield each integer in [lo, hi) without building the list. + (define amb-range + (lambda (lo hi) + (shift (lambda (k) + (letrec ((loop (lambda (i) + (if (< i hi) (begin (k i) (loop (+ i 1))) #inert)))) + (loop lo)))))) + + ; Yield each x of xs satisfying ok?, with enter! applied before the + ; continuation runs and leave! after it has finished exploring. Use this + ; whenever a candidate has to be recorded in mutable state. + (define amb-bracket + (lambda (xs ok? enter! leave!) + (shift (lambda (k) + (for-each xs (lambda (x) + (if (ok? x) + (begin (enter! x) (k x) (leave! x)) + #inert))))))) + + ; amb-bracket over the integers in [lo, hi). + (define amb-bracket-range + (lambda (lo hi ok? enter! leave!) + (shift (lambda (k) + (letrec ((loop (lambda (i) + (if (< i hi) + (begin + (if (ok? i) + (begin (enter! i) (k i) (leave! i)) + #inert) + (loop (+ i 1))) + #inert)))) + (loop lo)))))) + + ; --- reporting a solution ------------------------------------------------ + + (define emit (lambda (s) ((vector-ref sink 0) s))) + + ; --- strategies ---------------------------------------------------------- + + ; Run body with an arbitrary handler applied to every solution. + (define search + (vau (handler & body) env + (with-sink (eval env handler) + (lambda () (eval env (cons sequence body)))) + #inert)) + + ; Every solution, in the order found. + (define collect + (vau body env + (let ((acc (make-vector 1 ()))) + (with-sink + (lambda (s) (vector-set! acc 0 (cons s (vector-ref acc 0)))) + (lambda () (eval env (cons sequence body)))) + (list-reverse (vector-ref acc 0))))) + + ; How many solutions, retaining none of them. + (define count-of + (vau body env + (let ((n (make-vector 1 0))) + (with-sink + (lambda (s) (vector-set! n 0 (+ (vector-ref n 0) 1))) + (lambda () (eval env (cons sequence body)))) + (vector-ref n 0)))) + + ; The first solution, or () if there is none. Escapes through call/cc, so + ; the rest of the search tree is never explored. The handler is restored on + ; both paths. + (define first-of + (vau body env + (let ((saved (vector-ref sink 0))) + (let ((found + (call/cc (lambda (return) + (vector-set! sink 0 (lambda (s) (return s))) + (reset (eval env (cons sequence body))) + ())))) + (vector-set! sink 0 saved) + found))))) diff --git a/lib/IronKernel.Amb/test/amb_test.ikr b/lib/IronKernel.Amb/test/amb_test.ikr new file mode 100644 index 0000000..18d510a --- /dev/null +++ b/lib/IronKernel.Amb/test/amb_test.ikr @@ -0,0 +1,153 @@ +; Tests for IronKernel.Amb. +; +; `ik test` loads src/ then this file, and counts the file as failing if +; evaluating it raises. Failures are tallied so that every check runs, then the +; last form references an unbound name to fail the run and point at the tally. + +(define failures (make-vector 1 0)) + +(defn (check name ok) + (if ok + (printf " ok {0}\n" name) + (begin + (printf " FAIL {0}\n" name) + (vector-set! failures 0 (+ (vector-ref failures 0) 1))))) + +(defn (list-equal? xs ys) + (if (null? xs) + (null? ys) + (if (null? ys) #f + (if (eq? (car xs) (car ys)) (list-equal? (cdr xs) (cdr ys)) #f)))) + +; --- choice points ---------------------------------------------------------- + +(check "amb enumerates a list" + (list-equal? (collect (emit (amb (list 1 2 3)))) (list 1 2 3))) + +(check "amb over () fails" + (null? (collect (emit (amb ()))))) + +(check "nested amb takes the cross product" + (eq? 4 (length (collect (let ((a (amb (list 1 2)))) + (let ((b (amb (list 10 20)))) + (emit (cons a b)))))))) + +(check "amb-range enumerates a half-open interval" + (list-equal? (collect (emit (amb-range 2 5))) (list 2 3 4))) + +(check "require prunes a branch" + (list-equal? (collect (let ((a (amb-range 0 10))) + (require (< a 3)) + (emit a))) + (list 0 1 2))) + +(check "fail abandons a branch" + (list-equal? (collect (let ((a (amb (list 1 2 3)))) + (if (eq? a 2) (fail) #inert) + (emit a))) + (list 1 3))) + +; --- strategies ------------------------------------------------------------- + +(check "count-of tallies solutions" + (eq? 4 (count-of (emit (amb-range 0 4))))) + +(check "first-of returns the first solution" + (eq? 6 (first-of (let ((a (amb (list 5 6 7)))) + (require (> a 5)) + (emit a))))) + +(check "first-of returns () when there is none" + (null? (first-of (let ((a (amb (list 1 2)))) (fail) (emit a))))) + +(check "search accepts a custom handler" + (let ((total (make-vector 1 0))) + (search (lambda (s) (vector-set! total 0 (+ (vector-ref total 0) s))) + (emit (amb-range 1 5))) + (eq? 10 (vector-ref total 0)))) + +(check "emit outside a strategy is a no-op" + (begin (emit 'ignored) #t)) + +(check "strategies nest" + (list-equal? (collect (let ((a (amb-range 0 3))) + (emit (count-of (emit (amb-range 0 a)))))) + (list 0 1 2))) + +; --- bracketed choice ------------------------------------------------------- + +(define st (make-vector 1 0)) + +(defn (bump v) (vector-set! st 0 (+ (vector-ref st 0) v))) +(defn (drop v) (vector-set! st 0 (- (vector-ref st 0) v))) + +(check "amb-bracket exposes state to the continuation" + (list-equal? (collect (let ((x (amb-bracket (list 1 2 3) + (lambda (v) #t) bump drop))) + (emit (vector-ref st 0)))) + (list 1 2 3))) + +(check "amb-bracket restores state afterwards" + (zero? (vector-ref st 0))) + +(check "amb-bracket honours its guard" + (list-equal? (collect (let ((x (amb-bracket (list 1 2 3 4) + (lambda (v) (< v 3)) bump drop))) + (emit x))) + (list 1 2))) + +(check "amb-bracket-range brackets nested choices" + (let ((seen (collect (let ((a (amb-bracket-range 1 4 + (lambda (v) #t) bump drop))) + (let ((b (amb-bracket-range 1 4 + (lambda (v) (> v a)) bump drop))) + (emit (vector-ref st 0))))))) + ; a+b over 1<=a