amb: Ambiguous Operator
The amb library provides John McCarthy’s ambiguous operator for nondeterministic programming. All uses of amb operators must occur within the dynamic extent of in-amb, which delimits an amb-tree. in-amb traverses the amb-tree and returns its leaves as a lazy stream. The order of the leaves depends on the search strategy (see Parameters).
1 Evaluation Model
An amb-tree is a leaf-labeled tree: internal nodes are pure branching points with no values of their own; only leaves carry the results of complete computations. Structurally, a tree is either a leaf value or a stream of subtrees. Categorically, this is the free monad over the stream functor.
Each amb expression creates an amb-tree with one branch per alternative. A branch that calls (amb) is a dead end: it is pruned and the search backtracks.
Internally, in-amb maintains an amb-frontier—an immutable deque of pending amb-trees. Each step pops a tree, runs one branch, and, depending on the outcome, either yields a leaf and suspends, enqueues a child tree and continues, discards the tree and continues (dead end), or just continues.
The alternatives in amb are mutually exclusive—each is lazily evaluated only when that branch is selected.
Corresponding amb-tree:
(amb (amb 1 2) (amb) (amb 'a 'b)) |
├── (amb 1 2) |
│ ├── 1 |
│ └── 2 |
├── (amb) |
└── (amb 'a 'b) |
├── 'a |
└── 'b |
Nested amb expressions are implicitly flattened: their alternatives are inserted into the parent’s choice list. Under unfair depth-first order, (amb (amb 1 2) (amb) (amb 'a 'b)) therefore produces the same sequence of leaves as (amb 1 2 'a 'b).
> (stream->list (in-amb (amb 1 2 'a 'b))) '(1 2 a b)
By contrast, when amb expressions appear in order (e.g., via let*), each subsequent amb is in the continuation of the previous one. The amb-tree becomes a Cartesian product of all choices.
Corresponding amb-tree:
(amb 1 2) |
├── (amb 'a 'b) |
│ ├── (amb 1 'a) |
│ │ ├── 1 |
│ │ └── 'a |
│ └── (amb 1 'b) |
│ ├── 1 |
│ └── 'b |
└── (amb 'a 'b) |
├── (amb 2 'a) |
│ ├── 2 |
│ └── 'a |
└── (amb 2 'b) |
├── 2 |
└── 'b |
Since this is the free monad over the stream functor, it can be expressed purely with streams. The correspondence is direct: amb is stream, in-amb is stream-flatten, and sequential amb in let* corresponds to for*/stream composed with stream-flatten. The examples above, rewritten in this style:
(define (stream-flatten s*) (stream* (if (stream-empty? s*) s* (call-with-values (λ () (stream-first s*)) (λ (v . v*) (define s (stream-rest s*)) (if (and (null? v*) (stream? v)) (if (stream-empty? v) (stream-flatten s) (stream-flatten (stream* (stream-first v) (stream-rest v) s))) (stream-cons (apply values v v*) (stream-flatten s))))))))
; A single amb node with nested alternatives:
> (stream->list (stream-flatten (stream (stream 1 2) (stream) (stream 'a 'b)))) '(1 2 a b)
; Sequential amb in let* form — the monadic bind — ; for*/stream with stream-flatten at each level:
> (stream->list (stream-flatten (for*/stream ([x (stream-flatten (stream 1 2))] [y (stream-flatten (stream 'a 'b))] [z (stream-flatten (stream x y))]) z))) '(1 a 1 b 2 a 2 b)
The translation extends to realistic programs. Whereas in-amb and nested amb expressions both flatten implicitly, the stream encoding requires explicit stream-flatten at each level. Intermediate bindings use #:do, and (amb) (dead end) becomes #:when or #:unless.
> (define sequence->amb (compose make-amb sequence-generate))
> (define (amb:pythagorean n) (define a (sequence->amb (in-range 1 n))) (define a² (* a a)) (define b (sequence->amb (in-range a n))) (define b² (* b b)) (define c (sequence->amb (in-range b n))) (define c² (* c c)) (unless (= (+ a² b²) c²) (amb)) (vector-immutable a b c))
> (define (stream:pythagorean n) (for*/stream ([a (stream-flatten (in-range 1 n))] #:do [(define a² (* a a))] [b (stream-flatten (in-range a n))] #:do [(define b² (* b b))] [c (stream-flatten (in-range b n))] #:do [(define c² (* c c))] #:when (= (+ a² b²) c²)) (vector-immutable a b c)))
> (for/and ([n (in-inclusive-range 100 200 20)]) (printf "pythagorean ~a:\n" n) (printf " stream: ") (define stream:sol* (time (stream->list (stream-flatten (stream:pythagorean n))))) (printf " amb: ") (define amb:sol* (time (stream->list (in-amb (amb:pythagorean n))))) (printf " ~a solutions\n" (length stream:sol*)) (equal? amb:sol* stream:sol*))
pythagorean 100:
stream: cpu time: 133 real time: 140 gc time: 35
amb: cpu time: 62 real time: 63 gc time: 1
50 solutions
pythagorean 120:
stream: cpu time: 194 real time: 297 gc time: 8
amb: cpu time: 137 real time: 157 gc time: 8
65 solutions
pythagorean 140:
stream: cpu time: 200 real time: 225 gc time: 3
amb: cpu time: 176 real time: 197 gc time: 4
78 solutions
pythagorean 160:
stream: cpu time: 303 real time: 401 gc time: 5
amb: cpu time: 313 real time: 382 gc time: 10
94 solutions
pythagorean 180:
stream: cpu time: 410 real time: 479 gc time: 11
amb: cpu time: 610 real time: 630 gc time: 15
108 solutions
pythagorean 200:
stream: cpu time: 593 real time: 734 gc time: 17
amb: cpu time: 590 real time: 609 gc time: 19
125 solutions
#t
The difference is in the traversal mechanism. stream-flatten relies on the implicit program stack as its frontier, yielding unfair depth-first order (LIFO). in-amb replaces the stack with an amb-frontier to make the traversal order configurable (see Parameters).
Despite the continuation capture and deque management, the overhead is negligible in practice—on the Pythagorean triple example above, the amb version runs at roughly the same speed as the stream version.
The stream-flatten wrappers in the stream version serve a specific purpose: they mirror amb’s implicit flattening of nested amb-trees, so that each loop variable can traverse a leaf-labeled tree rather than just a flat stream. When the iteration source is already flat—as it is for in-range or in-stream over a plain stream—wrapping it in stream-flatten adds significant overhead. In those cases, omit stream-flatten and iterate directly:
> (define (pythagorean n) (for*/stream ([a (in-range 1 n)] #:do [(define a² (* a a))] [b (in-range a n)] #:do [(define b² (* b b))] [c (in-range b n)] #:do [(define c² (* c c))] #:when (= (+ a² b²) c²)) (vector-immutable a b c)))
> (for ([n (in-inclusive-range 100 200 20)]) (printf "pythagorean ~a:\n " n) (define sol* (time (stream->list (pythagorean n)))) (printf " ~a solutions\n" (length sol*)))
pythagorean 100:
cpu time: 0 real time: 0 gc time: 0
50 solutions
pythagorean 120:
cpu time: 1 real time: 1 gc time: 0
65 solutions
pythagorean 140:
cpu time: 1 real time: 1 gc time: 0
78 solutions
pythagorean 160:
cpu time: 2 real time: 2 gc time: 0
94 solutions
pythagorean 180:
cpu time: 3 real time: 3 gc time: 0
108 solutions
pythagorean 200:
cpu time: 3 real time: 3 gc time: 0
125 solutions
2 Ambiguous Operator
syntax
(amb expr ...)
Each expr is implicitly wrapped in a thunk and collected into a mutable vector.
When called with no arguments, (amb) signals failure, causing the search to backtrack. This is commonly used as a pruning mechanism to discard branches that do not satisfy a constraint.
> (stream->list (in-amb (amb 1 2 3))) '(1 2 3)
> (stream->list (in-amb (amb))) '()
A dead-end (amb) nested among alternatives prunes only that branch—the others proceed normally:
> (stream->list (in-amb (amb 1 2 (amb) 3 4))) '(1 2 3 4)
A productive nested amb creates a child amb-tree. When the inner tree is exhausted, the search backtracks and continues with the next sibling in the parent:
> (stream->list (in-amb (amb (amb 1 2 3) (amb 'a 'b 'c)))) '(1 2 3 a b c)
Alternatives producing multiple values are supported:
syntax
(for/amb maybe-length (for-clause ...) break-clause ... body ...+)
(for/amb type-ann-maybe maybe-length (for-clause ...) type-ann-maybe expr ...+)
syntax
(for*/amb maybe-length (for-clause ...) break-clause ... body ...+)
(for*/amb type-ann-maybe maybe-length (for-clause ...) type-ann-maybe expr ...+)
If the optional #:length clause is specified, the syntax resembles for/vector and for*/vector. Each loop body is implicitly wrapped in a thunk and collected into a mutable vector, mirroring how amb itself works.
> (for/list ([(a b) (in-amb (for/amb #:length 4 #:fill (values 0 'w) ([i #(1 2)] [j #(x y)]) (values i j)))]) (cons a b)) '((1 . x) (2 . y) (0 . w) (0 . w))
> (for/list ([(a b) (in-amb (for/amb #:length 4 ([i #(1 2)] [j #(x y)]) (values i j)))]) (cons a b)) '((1 . x) (2 . y))
If the optional #:length clause is omitted, the syntax resembles for/stream and for*/stream. Each iteration is lazily evaluated into a stream, which is then unpacked via sequence-generate and delegated to make-amb. Since the body is evaluated on demand, this works with sequences of any size, including infinite ones.
> (for/list ([x (in-amb (for/amb ([i (in-naturals)]) i))] [_ 5]) x) '(0 1 2 3 4)
> (for/list ([x (in-amb (for/amb ([c "hello"]) c))]) x) '(#\h #\e #\l #\l #\o)
3 Stream Constructor
syntax
(in-amb expr)
The form (in-amb expr) expands to (in-amb* (λ () expr)).
> (stream->list (in-amb (amb 1 (amb 2 3) (amb 4 5 6) 7 8))) '(1 2 3 4 5 6 7 8)
in-amb acts as a one-step lookahead stream constructor. To determine whether the stream is non-empty, it eagerly evaluates the next leaf.
> (for/list ([i (in-amb (begin0 (amb 1 (amb 2 3) 4) (displayln 'hi)))] [_ 2]) i)
hi
hi
hi
'(1 2)
> (for/list ([_ 2] [i (in-amb (begin0 (amb 1 (amb 2 3) 4) (displayln 'hi)))]) i)
hi
hi
'(1 2)
> (define (choices) (define x (amb 1 2 3)) (define y (amb 'a 'b)) (list x y)) > (stream->list (in-amb* choices)) '((1 a) (1 b) (2 a) (2 b) (3 a) (3 b))
syntax
(in-amb/do expr)
procedure
(in-amb*/do thk) → sequence?
thk : (-> any)
4 Parameters
The behavior of the amb search engine is governed by two orthogonal parameters: current-amb-depth-first? controls traversal order and current-amb-fair? controls fairness. Together, they make the amb-frontier pluggable in ways impossible to achieve with the implicit stack alone.
As described in Evaluation Model, the search driver maintains an amb-frontier. Each step pops an amb-tree from the front and runs it. The result determines what happens next:
Dead end: the tree is exhausted and is discarded (not returned to the amb-frontier). The search continues.
Nothing: the tree produced nothing on this resumption. The parent tree is put back for its remaining alternatives. current-amb-fair? controls whether the parent tree goes to the front or the back. The search continues.
Leaf: a result is produced. The parent tree is put back (as in the nothing case). The search suspends.
Branch: a new amb-tree was created. The parent tree is put back (as in the nothing case), and the new child tree is also inserted. current-amb-depth-first? controls whether the child tree goes to the front or the back. The search continues.
Both parameters are captured per-tree at the point where amb or for/amb is evaluated. This means different parts of a computation can use different strategies via parameterize. As a result, different subtrees may coexist in the same search with different traversal strategies.
parameter
(current-amb-depth-first? depth-first?) → void? depth-first? : boolean?
When #t (the default), child trees are pushed to the front of the amb-frontier, yielding depth-first search (DFS). When #f, child trees are pushed to the back, yielding breadth-first search (BFS).
> (stream->list (in-amb (amb 1 (amb 2 3) (amb 4 5 6) 7 8))) '(1 2 3 4 5 6 7 8)
> (parameterize ([current-amb-depth-first? #f]) (stream->list (in-amb (amb 1 (amb 2 3) (amb 4 5 6) 7 8)))) '(1 7 8 2 3 4 5 6)
parameter
(current-amb-fair? fair?) → void? fair? : boolean?
When #f (the default), the parent tree is placed back at the front. When #t, the parent tree is placed at the back, giving other pending trees a chance to run first. This prevents any single branch from starving its siblings.
> (stream->list (in-amb (let* ([a (amb 1 2 3)] [b (amb 4 5 6)]) (list a b)))) '((1 4) (1 5) (1 6) (2 4) (2 5) (2 6) (3 4) (3 5) (3 6))
> (stream->list (in-amb (let ([a (amb 1 2 3)]) (parameterize ([current-amb-fair? #t]) (define b (amb 4 5 6)) (list a b))))) '((1 4) (2 4) (3 4) (1 5) (2 5) (3 5) (1 6) (2 6) (3 6))
parameter
(current-amb-shuffler shuffle!) → void? shuffle! : (-> mutable-vector? void?)
The default value is void (no shuffling).
> (require srfi/43)
> (parameterize ([current-amb-shuffler vector-reverse!]) (stream->list (in-amb (amb 1 2 3 4 5)))) '(5 4 3 2 1)