all_lessons/SICP JavaScript/17 · Nondeterministic computinglesson 17 / 22

Nondeterministic computing

Lesson 16 changed one evaluator rule — when arguments evaluate — and laziness fell out. But a lazy program still computes one answer per expression. Now we change a different rule and let an expression compute many. We add a single operator, amb ("ambiguous"), that means "pick one of these values, and if a later constraint rejects it, automatically back up and pick another." With it the evaluator gains backtracking search as a built-in. The mechanism that makes this work — and the deep idea of the lesson — is that the evaluator stops returning a value and instead threads two continuations: what to do on success, and what to do on failure.

Book source
Maps to SICP 4.3 (nondeterministic computing): amb and automatic search, the success/failure-continuation evaluator, and require as a filter. We follow that arc — introduce amb/require, explain continuations concretely, then trace a small puzzle's backtracking — but the evaluator sketch, the worked puzzle, and the trace are original JavaScript.
Linear position
Prerequisite: Lesson 16 (you change the evaluator's rules to change the language) and Lesson 14 (the eval/apply structure being modified), with Lesson 13 (streams as a sequence-of-answers contrast) in mind.
New capability: add amb so choice and automatic backtracking become language primitives; understand the success/failure-continuation evaluator that implements them; and express search declaratively as choices plus constraints instead of nested loops.
The plan
Five moves. (1) Introduce amb and require as new syntax and say what they mean. (2) Explain a continuation concretely — "the rest of the computation as a callable" — because it is the whole engine. (3) Show how the evaluator is rebuilt to take two continuations, and how amb uses the failure one to backtrack. (4) Work a real puzzle (Pythagorean triples) as choices + a filter, with the backtracking trace drawn out. (5) Contrast this with Lesson 13 streams — both explore many possibilities, but one searches and one sequences.

1 · amb and require — what they mean

amb(a, b, c, …) is an expression that nondeterministically returns one of its arguments. Operationally: it returns the first one, but it remembers the others as untried alternatives. If, later, the surrounding computation fails — declares "this can't be right" — control rewinds back to this amb and it returns the next alternative, re-running everything downstream. An amb with no arguments, amb(), has nothing to return: it is immediate failure, "this path is dead."

require(p) is the filter that triggers backtracking. Like amb it is special syntax the evaluator recognizes (not an ordinary function that returns a value), but it reads as if defined in terms of amb:

// require: if the predicate fails, this branch is dead -> backtrack.
function require(p) { if (!p) return amb(); }   // amb() = no choices = fail

// A number that is nondeterministically one of a list:
function anElementOf(xs) {
  require(!isEmpty(xs));
  return amb(head(xs), anElementOf(tail(xs)));   // this OR the rest
}

The programming model is a reversal. Instead of writing a loop that tries combinations and tests each, you declare the choices with amb and declare the constraints with require, and the evaluator does the trying and the backing-up. You describe the shape of the answer; the search is the runtime's job. That is a step toward "say what, not how" — which Lesson 18 takes all the way.

2 · A continuation, concretely

To implement automatic backtracking the evaluator needs to be able to resume the rest of the computation from a chosen point, and to abandon the current path and resume a different one. Both require a first-class handle on "what happens next." That handle is a continuation.

Worked intuition — a continuation is the rest of the program, as a function
Consider evaluating 2 + f(x). At the moment you start computing f(x), "the rest of the computation" is: take whatever f(x) produces, add 2, and hand the result to whoever wanted 2 + f(x). Written as a function of the pending value v:
  the continuation of  f(x)  in  2 + f(x)   is   (v) => deliver (2 + v) onward
A continuation is exactly that: the surrounding context reified as a callable that takes the hole's value. Normally it is implicit — the call stack is the chain of continuations. Nondeterminism makes it explicit so it can be saved, called later, or thrown away.

The nondeterministic evaluator carries two continuations at every step:

success continuation
Called with a value and the current failure continuation: "here is the result; here is how to undo me if you later get stuck." It carries the answer forward.
failure continuation
A zero-argument callable: "the path you were on is dead — try the next alternative." Calling it is what backtracking physically is. The most recent amb with untried choices installed it.

3 · The evaluator rebuilt around two continuations

This is the same eval/apply cycle from Lesson 14, but evaluate no longer returns a value. It takes the expression, the environment, and the two continuations, and it calls one of them. Most expression types are mechanical — evaluate a subexpression, and in its success continuation continue with the rest. The interesting case is amb.

// Ordinary expression: succeed with the value, passing failure through unchanged.
function evalSelf(expr, env, succeed, fail) {
  succeed(expr.value, fail);          // "here is the value; here is how to back up"
}

// amb: try each choice in turn. If a choice's downstream fails, try the next.
function evalAmb(expr, env, succeed, fail) {
  const choices = expr.choices;       // the argument expressions
  function tryNext(i) {
    if (i >= choices.length) {
      fail();                         // out of choices -> tell the PRIOR amb to back up
    } else {
      evaluate(choices[i], env,
        succeed,                      // a choice succeeded: carry it forward...
        () => tryNext(i + 1));         // ...but if downstream fails, RE-ENTER here at i+1
    }
  }
  tryNext(0);
}

Read the failure continuation of choice i: it is () => tryNext(i+1). So when some require deep downstream calls its failure continuation, the chain of failure continuations leads control right back to this amb, which advances to choice i+1 and re-runs everything after it with the new value. Backtracking is just: invoke the failure continuation that the last open amb installed. No undo log, no explicit stack of states — the continuation closures are the saved search state.

// The driver: ask for one answer; print it; on "try-again", call the saved fail.
function ambEval(expr, env) {
  evaluate(expr, env,
    (value, fail) => { report(value); /* keep `fail` to ask for the NEXT answer */ },
    () => report("no more values"));     // top-level failure = search exhausted
}

4 · Worked puzzle — Pythagorean triples by choice + filter

Find integers i ≤ j ≤ k in 1..n with i*i + j*j === k*k. The imperative version is three nested loops and a test. The nondeterministic version declares the same thing:

function pythagoreanTriple(n) {
  const k = anIntegerBetween(1, n);
  const j = anIntegerBetween(1, k);          // enforce j <= k by construction
  const i = anIntegerBetween(1, j);          // enforce i <= j by construction
  require(i * i + j * j === k * k);          // the only real constraint
  return [i, j, k];
}
Worked example — backtracking trace for n = 5
anIntegerBetween(lo, hi) is amb(lo, anIntegerBetween(lo+1, hi)). Choices are tried smallest-first. Watch the failure continuations fire:
k=1 -> j=1 -> i=1 : 1+1 == 1?  no  -> require fails -> backtrack
   no more i (i<=j=1) -> backtrack to j
   no more j (j<=k=1) -> backtrack to k
k=2 -> j=1 -> i=1 : 1+1 == 4?  no  -> backtrack
       j=2 -> i=1 : 1+4 == 4?  no  -> backtrack
              i=2 : 4+4 == 4?  no  -> backtrack out of k=2
k=3, k=4 : every (i,j) fails the require -> exhausted, backtrack each time
k=5 -> j=1,2,3 : all fail
       j=4 -> i=1 : 1+16 == 25? no
              i=2 : 4+16 == 25? no
              i=3 : 9+16 == 25? YES -> require passes -> succeed([3,4,5])
Every "backtrack" above is one call to a saved failure continuation, which re-enters the most recent amb at its next choice. You never wrote a loop, an index, or an "undo." The require is the only line that knows the actual rule; the search structure is generated entirely by amb + continuations. Asking the driver for another answer (calling the retained top-level fail) resumes from [3,4,5] and continues the hunt.

5 · Search vs sequence — the contrast with Lesson 13 streams

Both streams and amb deal with "many values," so it is worth being precise about the difference.

Lesson 13 streamsLesson 17 amb
Mental modela sequence of values, produced on demanda search tree of choices, explored with backtracking
You writegenerators + filters that produce a listchoices + constraints; the engine finds a satisfier
Control flowlinear: pull the next elementbranching: dead ends rewind to the last open choice
State of "where am I"the unforced tail thunkthe current failure continuation
Failurefilter just skips an elementrequire abandons a branch and backs up

You can simulate one with the other — a stream of all solutions, or a depth-first search expressed as nested stream filters — but the natural fit differs. Streams shine when you want the values in order, lazily; amb shines when you want one satisfier of a constraint and don't care how the engine finds it. Crucially, amb made the backtracking control a language primitive, whereas streams only made the data lazy. That is the larger trajectory: each evaluator variant promotes something previously hand-coded (delay, then search) into the language itself.

Common mistakes / failure modes

Putting require too late
If you generate all of i,j,k before any constraint, the engine explores the full tree. Push requires as early as possible (or encode constraints in the ranges, like j≤k) to prune branches before they grow — the order of choices and tests is the performance story.
Expecting amb to be parallel or random
It is deterministic depth-first: choices are tried left-to-right, and you get the first satisfier in that order. "Nondeterministic" describes the semantics (any choice could be the answer), not the implementation.
Side effects inside a branch
Backtracking re-runs downstream code, so a display or mutation inside an amb branch can fire many times or need undoing. Like laziness, nondeterminism is hostile to effects — keep branches pure.
Infinite choice with no shrinking constraint
An unbounded anIntegerBetween(1, ∞) under a constraint that never holds searches forever, because depth-first never exhausts the first branch. Bound your choices or ensure progress.

Checkpoint exercise

Try it
(a) Write, in the amb language, a function that returns a pair [a, b] of distinct elements from a list xs such that a + b === target. (b) For xs = [2, 3, 5], target = 8, give the backtracking trace until the first answer. (c) Where would you place require to prune earliest?
function pairSummingTo(xs, target) {
  const a = anElementOf(xs);
  const b = anElementOf(xs);
  require(a !== b);
  require(a + b === target);
  return [a, b];
}
Answers: (b) a=2,b=2 fails a!==b → backtrack; a=2,b=3 distinct but 5≠8 → backtrack; a=2,b=5 distinct, 7≠8 → backtrack; out of b, backtrack a; a=3,b=2 → 5≠8; a=3,b=3 fails distinct; a=3,b=5 → 8===8 succeed [3,5]. (c) Both requires are already as early as the bindings allow; to prune harder, generate b from anElementOf of the elements after a (encoding a<b structurally), which removes the duplicate and symmetric branches the require(a!==b) was filtering.

Where this points next

With amb you say what the answer must satisfy and let the engine search — but you still write a procedure: generate this, then that, then require this. The choices and constraints are tangled with an implicit control order (depth-first, left-to-right). Lesson 18 pushes declarativeness all the way: a logic / query language where you state facts and rules about relations and pose queries, with no procedural order at all. The engine that makes this work generalizes amb's search with a deeper idea — unification, two-directional pattern matching — so the same rule can run "forwards" and "backwards." It is the same evaluator-owns-semantics move once more, taken to its declarative limit.

Takeaway
amb(...) nondeterministically returns one of its arguments and remembers the rest; require(p) is if (!p) amb() — empty amb means immediate failure. Together they let you express search as declared choices plus declared constraints, with the runtime doing the trying and the backing-up. The engine is the eval/apply cycle from Lesson 14 rebuilt around two continuations — reified "rest of the computation" callables: a success continuation that carries a value forward (along with the current way to undo it) and a failure continuation that, when called, re-enters the most recent amb at its next untried choice. Backtracking is literally invoking a saved failure continuation; the continuation closures are the search state, so no undo log is needed. The Pythagorean-triple trace shows the whole pruning depth-first search arising from amb + require with no loops. Versus Lesson 13 streams: streams make data lazy and yield a sequence; amb makes control nondeterministic and searches a tree. Effects and unbounded choices are the hazards. Owning the evaluator once again bought a new programming model from one new rule.

Interview prompts