Skip to the content
On this page
  1. Read this before you read anything else
  2. Experiment 1 — A family tree
  3. Experiment 2 — member, append, reverse and length
  4. Experiment 3 — The maximum of a list
  5. Experiment 4 — Flatten a nested list
  6. Experiment 5 — Factorial and Fibonacci
  7. Experiment 6 — GCD by recursion
  8. Experiment 7 — Cut and fail
  9. Experiments 8–11 — A graph, DFS, BFS, and the comparison
  10. Experiment 12 — Greedy Best-First and A*
  11. Experiment 13 — Map colouring
  12. Experiment 14 — N-Queens
  13. Experiment 15 — Facts and rules in propositional and first-order logic
  14. Experiment 16 — Forward and backward chaining
  15. Experiment 17 — An expert system, and its explanation facility
  16. Experiment 18 — A DCG grammar for English
  17. Experiment 19 — Deterministic Naive Bayes
  18. The unit-3 extras that share 07_bayes_and_local_search.py
  19. What the runner asserts
  20. Lab examination
  21. Each program, on its own page

19 experiments, each set out as 1. Question, 2. Aim, 3. Steps, 4. Programme, 5. Execution and Results. The syllabus names SWI-Prolog and adds "environment for practice without installation" — meaning swish.swi-prolog.org, which runs in a browser.

Code lives in labs/course-13a-ai/.

Read this before you read anything else

Every Prolog file here runs, in SWI-Prolog 9.0.4. Each experiment has two halves:

Half Files Status
The Prolog you submit 16 .pl files Run in SWI-Prolog: each is consulted, and every ?- query it shows is asked, by tools/data-science/prolog_lab.py
The check 7 .py files Executed and asserted by tools/data-science/run_ai_labs.py, which also runs the .pl files and checks that SWI-Prolog gives the same answers

(Updated October 2026: SWI-Prolog could not be installed where these labs are checked, and every .pl file said NOT EXECUTED. It now installs from the Ubuntu archive. Running the files found five faults, each corrected in its file and noted below: a warning on loading 07_cut_fail.pl, another on loading 15_logic.pl under a non-UTF-8 locale, compare_searches/2 printing four comparisons, the expert system's explanation calling every fact a rule, and forward chaining that existed only as a comment.)

The .pl file is the deliverable. It is what you paste into SWISH and what the examiner marks. Under 5. Execution and Results is what SWI-Prolog answered: the toplevel waits for a key between answers, so the queries were asked by prolog_lab.py, which prints every answer as the toplevel does — X = asha ; for each and a full stop after the last — and, past eight answers, the first and how many there are.

bash tools/data-science/setup_prolog.sh          # SWI-Prolog, from the Ubuntu archive
pip install -r tools/requirements.txt
python3 tools/data-science/run_ai_labs.py

Output ends:

  16 Prolog programs run
  they cover experiments: [1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 15, 16, 17, 18, 19]
  (08_graph_search.pl covers experiments 8-11, which are one graph)

==============================================================
7 lab programs executed and asserted, 0 failed
covering all 19 prescribed experiments

Nineteen experiments, sixteen files because experiments 8–11 are one graph asked four ways — represent it, DFS it, BFS it, compare the path lengths — so they share 08_graph_search.pl, and one section below.

IN THE PYTHON CHECK, FIVE EXPERIMENTS RUN AS REAL LOGIC PROGRAMS

pytholog is a small Prolog engine on PyPI. It performs genuine SLD resolution over facts and recursive rules, so in the Python half experiments 1, 15, 16, 17 and the backward-chaining half of 16 are not simulated — they are resolved.

Its limits are real, and the script proves each one before working around it rather than quietly avoiding it:

Limit What the script shows Which experiments
No list terms mem(b, [a,b,c]) returns ['No'] where SWI-Prolog says true 2, 3, 4
is/2 does not evaluate fact(5, X) returns ['No'] where SWI-Prolog gives X = 120 5, 6
No cut ! is not a term at all 7
No DCG --> is not parsed 18
Arity ≥ 1 required a 0-arity proposition raises IndexError 16 (rewritten with a dummy argument)
Nested derived rules cousin via sib/2 returns ['bhanu', 'kiran', 'meena'] — kiran is his own cousin 1

That last row is the important one, and Experiment 1 below spends real space on it. The .pl file uses the idiomatic nested form, and SWI-Prolog answers it correctly — its answers are shown there. The engine limitation is documented, not hidden.


Experiment 1 — A family tree

1. Question

Represent a family tree in Prolog, with rules for father, mother, grandparent, ancestor, sibling and cousin.

2. Aim

State the facts, write the rules — one of them recursive — and query them.

3. Steps

In SWI-Prolog, 01_family_tree.pl:

  1. State the facts.
  2. Write the rules.
  3. Ask the queries.

The Python check, 01_family_tree.py, through pytholog's resolution:

  1. State the facts and simple rules.
  2. Resolve the recursive ancestor/2.
  3. Put the base case first.
  4. Guard sibling/2 against X = Y.
  5. Count one solution per proof.
  6. Find cousins, and pytholog's limit.

THE TREE

The tree, from fixtures.py: ram and sita have asha and ravi; asha has meena and kiran; ravi has bhanu.

Query Answer
parent(ram, X) ['asha', 'ravi']
father(X, asha) ['ram']
grandparent(ram, X) ['bhanu', 'kiran', 'meena']
ancestor(ram, X) ['asha', 'bhanu', 'kiran', 'meena', 'ravi']

ancestor/2 is the one that matters. parent(ram, X) returns two names; ancestor(ram, X) returns five. The extra three are two levels down and reach the answer only through the recursive clause. Delete that clause and they vanish — which is the demonstration that this is resolution and not a lookup.

4. Programme

In SWI-Prolog, 01_family_tree.pl:

% Experiment 1 -- A family tree in Prolog: ancestor/2, sibling/2, cousin/2.
%
% Run it: swipl 01_family_tree.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 01_family_tree.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: State the facts
parent(ram,   asha).
parent(ram,   ravi).
parent(sita,  asha).
parent(sita,  ravi).
parent(asha,  kiran).
parent(asha,  meena).
parent(ravi,  bhanu).

male(ram).    male(ravi).   male(kiran).  male(bhanu).
female(sita). female(asha). female(meena).

% Step 2: Write the rules
father(X, Y) :- parent(X, Y), male(X).
mother(X, Y) :- parent(X, Y), female(X).

grandparent(X, Y) :- parent(X, Z), parent(Z, Y).

% ancestor/2 -- the recursive one, and the point of the experiment.
% BASE CASE FIRST. Prolog tries clauses top to bottom, so putting the
% recursive clause first makes it recurse before it can ever succeed.
ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

descendant(X, Y) :- ancestor(Y, X).

% sibling/2 -- share a parent, and are not the same person.
% Without the X \= Y guard, everyone is their own sibling.
sibling(X, Y) :- parent(P, X), parent(P, Y), X \= Y.

% cousin/2 -- their parents are siblings.
cousin(X, Y) :- parent(A, X), parent(B, Y), sibling(A, B).

% Step 3: Ask the queries
% ?- ancestor(ram, X).            % asha, ravi, kiran, meena, bhanu
% ?- descendant(kiran, X).        % asha, ram, sita
% ?- sibling(asha, X).            % ravi  (twice -- once per shared parent)
% ?- cousin(kiran, X).            % bhanu  (twice -- through each shared parent of
%                                 %  asha and ravi; setof/3 gives it once)
% ?- father(X, kiran).            % no -- kiran's parent asha is female
% ?- findall(X, ancestor(ram, X), L).   % L = [asha, ravi, kiran, meena, bhanu]

% --- WHY sibling(asha, X) GIVES ravi TWICE -----------------------------------
% asha and ravi share BOTH ram and sita, and Prolog reports one solution per
% way of proving the goal. Use setof/3 to get distinct answers:
%   ?- setof(X, sibling(asha, X), L).
% This is a real property of resolution, not a bug, and it is examinable.

The Python check, 01_family_tree.py, through pytholog's resolution:

"""Experiment 1 — A family tree, executed as a logic program.

This is NOT a simulation. pytholog implements SLD resolution over Horn
clauses, so the recursive ancestor/2 rule below is genuinely resolved the way
Prolog would resolve it -- including finding kiran, who is three levels down
and reachable only by recursion.
"""
import pytholog as pl

from fixtures import FAMILY_FACTS, FAMILY_RULES


def build():
    kb = pl.KnowledgeBase("family")
    kb(FAMILY_FACTS + FAMILY_RULES)
    return kb


def answers(kb, goal, var="X"):
    """Sorted distinct bindings for one variable -- Prolog's setof/3."""
    result = kb.query(pl.Expr(goal))
    if result in (["No"], []):
        return []
    return sorted({r[var] for r in result if isinstance(r, dict) and var in r})


def facts_and_simple_rules():
    kb = build()

    assert answers(kb, "parent(ram, X)") == ["asha", "ravi"]
    assert answers(kb, "father(X, asha)", "X") == ["ram"]
    assert answers(kb, "mother(X, asha)", "X") == ["sita"]
    assert answers(kb, "grandparent(ram, X)") == ["bhanu", "kiran", "meena"]

    print(f"  parent(ram, X)        -> {answers(kb, 'parent(ram, X)')}")
    print(f"  father(X, asha)       -> {answers(kb, 'father(X, asha)', 'X')}")
    print(f"  mother(X, asha)       -> {answers(kb, 'mother(X, asha)', 'X')}")
    print(f"  grandparent(ram, X)   -> {answers(kb, 'grandparent(ram, X)')}")
    print("       father/2 and mother/2 are the SAME rule with a different")
    print("       guard. That is what makes logic programming compact")


def recursion_is_what_makes_ancestor_work():
    """The point of the experiment: kiran is reachable only by recursing."""
    kb = build()

    descendants = answers(kb, "ancestor(ram, X)")
    assert descendants == ["asha", "bhanu", "kiran", "meena", "ravi"], descendants

    # One level down is the base case; two and three need the recursive clause.
    direct = answers(kb, "parent(ram, X)")
    assert set(direct) == {"asha", "ravi"}
    assert {"kiran", "meena", "bhanu"} <= set(descendants)
    assert {"kiran", "meena", "bhanu"} & set(direct) == set()

    print(f"  parent(ram, X)   -> {direct}                (1 level)")
    print(f"  ancestor(ram, X) -> {descendants}")
    print("       kiran, meena and bhanu are TWO levels down and appear only")
    print("       through the recursive clause. Delete it and they vanish.")
    print("       This is genuine SLD resolution, not a table lookup")


def base_case_must_come_first():
    """Clause ORDER matters in Prolog, and does not matter in logic."""
    good = pl.KnowledgeBase("good")
    good(FAMILY_FACTS + [
        "anc(X, Y) :- parent(X, Y)",                   # base case FIRST
        "anc(X, Y) :- parent(X, Z), anc(Z, Y)",
    ])
    result = good.query(pl.Expr("anc(ram, X)"))
    found = sorted({r["X"] for r in result if isinstance(r, dict)})
    assert found == ["asha", "bhanu", "kiran", "meena", "ravi"], found

    print(f"  base case first -> {found}")
    print("       with the RECURSIVE clause first, SWI-Prolog recurses before")
    print("       it can ever reach a fact, and a left-recursive rule such as")
    print("       'anc(X,Y) :- anc(X,Z), parent(Z,Y)' loops for ever.")
    print("       Clause order matters in Prolog and does NOT matter in logic --")
    print("       because Prolog is backward chaining with DEPTH-FIRST search")


def sibling_needs_the_inequality_guard():
    kb = build()
    siblings = answers(kb, "sibling(asha, X)")
    assert siblings == ["ravi"], siblings
    assert "asha" not in siblings, "the neq guard stops asha being her own sibling"

    # Without the guard, everyone is their own sibling.
    loose = pl.KnowledgeBase("loose")
    loose(FAMILY_FACTS + ["sib(X, Y) :- parent(P, X), parent(P, Y)"])
    bad = sorted({r["X"] for r in loose.query(pl.Expr("sib(asha, X)"))
                  if isinstance(r, dict)})
    assert "asha" in bad, bad

    print(f"  sibling(asha, X)  with the guard -> {siblings}")
    print(f"  without the guard                -> {bad}   <- asha is her own sibling")
    print("       every parent(P,X), parent(P,Y) pair unifies with X = Y unless")
    print("       you forbid it. In SWI-Prolog the guard is X \\\\= Y")


def duplicate_solutions_are_real():
    """asha and ravi share TWO parents, so Prolog proves sibling twice."""
    kb = build()
    raw = kb.query(pl.Expr("sibling(asha, X)"))
    bindings = [r["X"] for r in raw if isinstance(r, dict) and "X" in r]
    distinct = sorted(set(bindings))

    assert distinct == ["ravi"]
    assert len(bindings) >= 1

    print(f"  raw solutions for sibling(asha, X): {bindings}")
    print(f"  distinct                          : {distinct}")
    print("       asha and ravi share BOTH ram and sita, so in SWI-Prolog the")
    print("       goal succeeds once per shared parent -- one solution per PROOF,")
    print("       not per answer. setof/3 collapses them, and this is a property")
    print("       of resolution rather than a bug")


def cousins_expose_an_engine_limitation():
    """Two encodings of the same logic. One of them breaks pytholog.

    This is worth seeing rather than hiding: the .pl file uses the idiomatic
    nested form, which SWI-Prolog handles correctly. pytholog does not
    propagate neq/2 correctly through a nested derived predicate, so the same
    rule returns wrong answers here -- and the FLAT formulation, which asks
    the same question without an intermediate rule, gets it right.
    """
    # (a) The flat form: kiran and Y have different parents who share a parent.
    flat = pl.KnowledgeBase("flat")
    flat(FAMILY_FACTS + [
        "cousin(X, Y) :- parent(A, X), parent(B, Y), "
        "parent(G, A), parent(G, B), neq(A, B)"])
    flat_answers = sorted({r["X"] for r in flat.query(pl.Expr("cousin(kiran, X)"))
                           if isinstance(r, dict)})
    assert flat_answers == ["bhanu"], flat_answers

    # (b) The nested form, which is what the .pl file uses.
    nested = pl.KnowledgeBase("nested")
    nested(FAMILY_FACTS + [
        "sib(X, Y) :- parent(P, X), parent(P, Y), neq(X, Y)",
        "cousin(X, Y) :- parent(A, X), parent(B, Y), sib(A, B)"])
    nested_answers = sorted({r["X"] for r in nested.query(pl.Expr("cousin(kiran, X)"))
                             if isinstance(r, dict)})
    assert nested_answers == ["bhanu", "kiran", "meena"], nested_answers
    assert "kiran" in nested_answers, "kiran is returned as his OWN cousin"

    print(f"  flat rule   (no intermediate predicate) -> {flat_answers}   CORRECT")
    print(f"  nested rule (calls sib/2)               -> {nested_answers}")
    print("       the nested answer is WRONG: kiran is not his own cousin, and")
    print("       meena is his SISTER. pytholog does not propagate neq/2")
    print("       correctly through a nested derived predicate.")
    print("       SWI-Prolog handles the nested form correctly, and the .pl file")
    print("       uses it because it is the idiomatic encoding. This is an")
    print("       ENGINE limitation, not a flaw in the logic -- and it is why")
    print("       the .pl file is the deliverable and this file is the check")


def main():
    print("Experiment 1 -- A family tree as a logic program")
    # Step 1: State the facts and simple rules
    facts_and_simple_rules()
    # Step 2: Resolve the recursive ancestor/2
    recursion_is_what_makes_ancestor_work()
    # Step 3: Put the base case first
    base_case_must_come_first()
    # Step 4: Guard sibling/2 against X = Y
    sibling_needs_the_inequality_guard()
    # Step 5: Count one solution per proof
    duplicate_solutions_are_real()
    # Step 6: Find cousins, and pytholog's limit
    cousins_expose_an_engine_limitation()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 01_family_tree.pl:

OUTPUT

?- ancestor(ram, X).
X = asha ;
X = ravi ;
X = kiran ;
X = meena ;
X = bhanu.

?- descendant(kiran, X).
X = asha ;
X = ram ;
X = sita.

?- sibling(asha, X).
X = ravi ;
X = ravi.

?- cousin(kiran, X).
X = bhanu ;
X = bhanu.

?- father(X, kiran).
false.

?- findall(X, ancestor(ram, X), L).
L = [asha, ravi, kiran, meena, bhanu].

?- setof(X, sibling(asha, X), L).
L = [ravi].

The Python check, 01_family_tree.py, through pytholog's resolution:

OUTPUT

Experiment 1 -- A family tree as a logic program
  parent(ram, X)        -> ['asha', 'ravi']
  father(X, asha)       -> ['ram']
  mother(X, asha)       -> ['sita']
  grandparent(ram, X)   -> ['bhanu', 'kiran', 'meena']
       father/2 and mother/2 are the SAME rule with a different
       guard. That is what makes logic programming compact
  parent(ram, X)   -> ['asha', 'ravi']                (1 level)
  ancestor(ram, X) -> ['asha', 'bhanu', 'kiran', 'meena', 'ravi']
       kiran, meena and bhanu are TWO levels down and appear only
       through the recursive clause. Delete it and they vanish.
       This is genuine SLD resolution, not a table lookup
  base case first -> ['asha', 'bhanu', 'kiran', 'meena', 'ravi']
       with the RECURSIVE clause first, SWI-Prolog recurses before
       it can ever reach a fact, and a left-recursive rule such as
       'anc(X,Y) :- anc(X,Z), parent(Z,Y)' loops for ever.
       Clause order matters in Prolog and does NOT matter in logic --
       because Prolog is backward chaining with DEPTH-FIRST search
  sibling(asha, X)  with the guard -> ['ravi']
  without the guard                -> ['asha', 'ravi']   <- asha is her own sibling
       every parent(P,X), parent(P,Y) pair unifies with X = Y unless
       you forbid it. In SWI-Prolog the guard is X \\= Y
  raw solutions for sibling(asha, X): ['ravi', 'ravi']
  distinct                          : ['ravi']
       asha and ravi share BOTH ram and sita, so in SWI-Prolog the
       goal succeeds once per shared parent -- one solution per PROOF,
       not per answer. setof/3 collapses them, and this is a property
       of resolution rather than a bug
  flat rule   (no intermediate predicate) -> ['bhanu']   CORRECT
  nested rule (calls sib/2)               -> ['bhanu', 'kiran', 'meena']
       the nested answer is WRONG: kiran is not his own cousin, and
       meena is his SISTER. pytholog does not propagate neq/2
       correctly through a nested derived predicate.
       SWI-Prolog handles the nested form correctly, and the .pl file
       uses it because it is the idiomatic encoding. This is an
       ENGINE limitation, not a flaw in the logic -- and it is why
       the .pl file is the deliverable and this file is the check

THREE THINGS THIS EXPERIMENT TEACHES THAT THE SYLLABUS DOES NOT SAY

1. Clause order is semantics, not style. Put the base case first:

ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

Write it left-recursively — ancestor(X,Y) :- ancestor(X,Z), parent(Z,Y). — and the query never reaches a parent fact. The logic is identical; the procedure is not, because Prolog is backward chaining with depth-first search. Corrected: this page said SWI-Prolog "loops for ever". Run here, SWI-Prolog 9.0.4 stops after about 6.3 million nested calls and nine seconds with Stack limit (1.0Gb) exceeded … Probable infinite recursion (cycle).

2. sibling/2 needs a guard. Without X \= Y:

sibling(asha, X)  with the guard -> ['ravi']
without the guard                -> ['asha', 'ravi']   <- asha is her own sibling

Every parent(P,X), parent(P,Y) pair unifies with X = Y unless you forbid it.

3. One solution per proof, not per answer. SWI-Prolog answers sibling(asha, X) with X = ravi twice: asha and ravi share both ram and sita, so the goal succeeds twice — once down each parent. setof/3 collapses it to [ravi]. This is a property of resolution, not a bug, and examiners like the answer. cousin(kiran, X) gives bhanu twice for the same reason: the parents' sibling/2 succeeds twice.

WHERE PYTHOLOG GETS COUSIN/2 WRONG

Formulation Answer for cousin(kiran, X)
Flat — parents' sibling's children, inline ['bhanu'] — correct
Nested — calls a derived sib/2 ['bhanu', 'kiran', 'meena'] — wrong

kiran is not his own cousin, and meena is his sister. pytholog does not propagate the inequality guard correctly through a nested derived predicate. SWI-Prolog answers the nested form correctly — only bhanu, above — and the .pl file uses it because it is the idiomatic encoding. This is an engine limitation.

RESULT

ancestor(ram, X) gives five answers, the three below his children only through the recursive clause; sibling(asha, X) and cousin(kiran, X) each answer twice, once per proof.

Experiment 2 — member, append, reverse and length

1. Question

Write list predicates in Prolog: member, append, reverse and length.

2. Aim

Define the four predicates recursively, and run append/3 backwards.

3. Steps

In SWI-Prolog, 02_lists.pl:

  1. Define member/2.
  2. Define append/3.
  3. Define reverse/2, with an accumulator.
  4. Define length/2.
  5. Ask the queries.
  6. Run append/3 backwards.

The Python check, 02_lists_and_arithmetic.py, for experiments 2–7:

  1. Prove pytholog's limits first.
  2. Experiment 2: the list predicates.
  3. Experiment 3: the maximum.
  4. Experiment 4: flatten.
  5. Experiments 5 and 6: factorial, Fibonacci and GCD.
  6. Experiment 7: cut, fail and negation.

THE PREDICATES

Goal Result
member(b, [a,b,c]) true
append([a,b,c], [d,e], X) [a,b,c,d,e]
reverse([a,b,c], X) [c,b,a]
length([a,b,c], N) 3

The file names them mem/2, app/3, rev/2 and len/2, so they do not clash with the built-in predicates of the same names.

pytholog has neither list terms nor arithmetic evaluation, and the Python check asserts both failures first — mem(b, [a,b,c]) -> ['No'] and fact(5, X) -> ['No'] — before checking the logic of experiments 2–7 in Python.

4. Programme

In SWI-Prolog, 02_lists.pl:

% Experiment 2 -- member/2, append/3, reverse/2, length/2.
%
% Run it: swipl 02_lists.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% pytholog has NO LIST TERMS, so none of this can run there. The .py file
% checks what these predicates COMPUTE, in Python.

% Step 1: Define member/2
mem(X, [X|_]).
mem(X, [_|T]) :- mem(X, T).

% Step 2: Define append/3
app([], L, L).
app([H|T], L, [H|R]) :- app(T, L, R).

% Step 3: Define reverse/2, with an accumulator
rev(L, R) :- rev_acc(L, [], R).
rev_acc([], Acc, Acc).
rev_acc([H|T], Acc, R) :- rev_acc(T, [H|Acc], R).

% Step 4: Define length/2
len([], 0).
len([_|T], N) :- len(T, N1), N is N1 + 1.

% Step 5: Ask the queries
% ?- mem(b, [a,b,c]).            % true
% ?- app([a,b], [c,d], X).       % X = [a,b,c,d]
% ?- rev([a,b,c], X).            % X = [c,b,a]
% ?- len([a,b,c], N).            % N = 3

% Step 6: Run append/3 backwards
% app/3 RUNS BACKWARDS. It is a RELATION, not a function:
%   ?- app(X, Y, [a,b,c]).
%   X = [],      Y = [a,b,c] ;
%   X = [a],     Y = [b,c]   ;
%   X = [a,b],   Y = [c]     ;
%   X = [a,b,c], Y = []      .
% FOUR solutions from ONE definition. No functional language does this, and it
% is the single best demonstration of what declarative programming means.

The Python check, 02_lists_and_arithmetic.py, for experiments 2–7:

"""Experiments 2-7 — List predicates, arithmetic, and the cut.

pytholog has NO LIST TERMS, NO ARITHMETIC EVALUATION and NO CUT, so these six
experiments cannot be run as logic programs here. That limitation is ASSERTED
below rather than described, and the same computations are then performed in
Python so the expected answers in the .pl files are checked.

The .pl files carry the real Prolog, and they are the lab deliverable.
"""
import pytholog as pl


def pytholog_has_no_lists():
    """Asserted, so this file can never quietly start claiming to test lists."""
    kb = pl.KnowledgeBase("lists")
    kb(["mem(X, [X|_])", "mem(X, [_|T]) :- mem(X, T)"])
    result = kb.query(pl.Expr("mem(b, [a,b,c])"))

    assert result == ["No"], result
    print("  pytholog: mem(b, [a,b,c]) -> ['No']  (SWI-Prolog answers 'true')")
    print("       there are no list TERMS, so [X|T] never unifies. Experiments")
    print("       2, 3 and 4 are executed in Python below; the .pl files carry")
    print("       the real Prolog")


def pytholog_has_no_arithmetic():
    kb = pl.KnowledgeBase("arith")
    kb(["fact(0, 1)", "fact(N, F) :- N1 is N-1, fact(N1, F1), F is N*F1"])
    result = kb.query(pl.Expr("fact(5, X)"))

    assert result == ["No"], result
    print("  pytholog: fact(5, X) -> ['No']  (SWI-Prolog answers X = 120)")
    print("       'is/2' does not evaluate, so experiments 5 and 6 are executed")
    print("       in Python too")


# --- experiment 2: member, append, reverse, length --------------------------

def list_predicates():
    """What the Prolog definitions compute, checked in Python."""
    xs = ["a", "b", "c"]
    ys = ["d", "e"]

    assert "b" in xs and "z" not in xs                     # member/2
    assert xs + ys == ["a", "b", "c", "d", "e"]            # append/3
    assert list(reversed(xs)) == ["c", "b", "a"]           # reverse/2
    assert len(xs) == 3                                    # length/2

    # append/3 is RELATIONAL: it can also split a list, which is the property
    # that makes Prolog different from a functional language.
    splits = [(xs[:i], xs[i:]) for i in range(len(xs) + 1)]
    assert len(splits) == 4
    assert splits[0] == ([], ["a", "b", "c"])
    assert splits[-1] == (["a", "b", "c"], [])

    print(f"  member(b, {xs})      -> true")
    print(f"  append({xs}, {ys}, X) -> {xs + ys}")
    print(f"  reverse({xs}, X)     -> {list(reversed(xs))}")
    print(f"  length({xs}, N)      -> {len(xs)}")
    print(f"  append(X, Y, {xs})   -> {len(splits)} solutions: {splits}")
    print("       that last one is the point of Prolog: append/3 RUNS BACKWARDS.")
    print("       One definition both concatenates and splits, because a rule")
    print("       states a RELATION rather than a function")


def maximum_of_a_list():
    """Experiment 3."""
    def maximum(xs):
        best = xs[0]
        for x in xs[1:]:
            if x > best:
                best = x
        return best

    assert maximum([3, 7, 2, 9, 4]) == 9
    assert maximum([5]) == 5
    assert maximum([-3, -7, -2]) == -2

    print(f"  max([3,7,2,9,4]) -> {maximum([3, 7, 2, 9, 4])}")
    print("       the Prolog version recurses on the tail and compares against")
    print("       the maximum of the rest. The base case is the ONE-element")
    print("       list, not the empty one -- max([]) has no answer")


def flatten_a_nested_list():
    """Experiment 4."""
    def flatten(x):
        if not isinstance(x, list):
            return [x]
        out = []
        for item in x:
            out.extend(flatten(item))
        return out

    nested = [1, [2, [3, 4], 5], [[6]], 7]
    assert flatten(nested) == [1, 2, 3, 4, 5, 6, 7]
    assert flatten([]) == []
    assert flatten([[], [[]], 1]) == [1], "empty lists vanish"

    print(f"  flatten({nested}) -> {flatten(nested)}")
    print(f"  flatten([[], [[]], 1]) -> {flatten([[], [[]], 1])}")
    print("       three clauses in Prolog: the empty list, a list head (recurse")
    print("       into it and append), and an atom head (keep it). Empty")
    print("       sublists disappear, which is the case people forget")


# --- experiments 5 and 6: arithmetic ----------------------------------------

def factorial_and_fibonacci():
    def fact(n):
        return 1 if n == 0 else n * fact(n - 1)

    def fib(n):
        return n if n < 2 else fib(n - 1) + fib(n - 2)

    assert [fact(n) for n in range(6)] == [1, 1, 2, 6, 24, 120]
    assert [fib(n) for n in range(10)] == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
    assert fact(10) == 3628800

    # Naive fib is exponential -- 177 calls for fib(10).
    calls = {"n": 0}

    def counted(n):
        calls["n"] += 1
        return n if n < 2 else counted(n - 1) + counted(n - 2)

    counted(10)
    assert calls["n"] == 177, calls["n"]

    print(f"  factorial 0..5 -> {[fact(n) for n in range(6)]}")
    print(f"  fibonacci 0..9 -> {[fib(n) for n in range(10)]}")
    print(f"  naive fib(10) makes {calls['n']} recursive calls")
    print("       the naive Prolog definition is exponential for the same")
    print("       reason. Add an accumulator, or use assertz/1 to memoise --")
    print("       which is Prolog's version of dynamic programming")


def gcd_by_recursion():
    """Experiment 6 -- Euclid's algorithm."""
    def gcd(a, b):
        return a if b == 0 else gcd(b, a % b)

    assert gcd(48, 18) == 6
    assert gcd(17, 5) == 1
    assert gcd(0, 7) == 7
    assert gcd(100, 75) == 25

    steps = []
    a, b = 48, 18
    while b:
        steps.append((a, b))
        a, b = b, a % b
    assert steps == [(48, 18), (18, 12), (12, 6)]

    print(f"  gcd(48, 18) = {gcd(48, 18)}   trace: {steps} -> 6")
    print(f"  gcd(17, 5)  = {gcd(17, 5)}    (coprime)")
    print("       two clauses in Prolog: gcd(A,0,A) is the base case, and")
    print("       gcd(A,B,G) :- B > 0, R is A mod B, gcd(B,R,G)")


# --- experiment 7: cut and fail ---------------------------------------------

def cut_and_fail_are_documented_only():
    """No cut in pytholog, so this is stated as semantics, not tested."""
    behaviours = [
        ("!", "commits to the current clause AND to the bindings made "
              "before it; discards remaining choice points"),
        ("fail", "always fails, forcing backtracking"),
        ("!, fail", "the CUT-FAIL idiom: commit, then fail, so the whole "
                    "goal fails with no alternatives tried"),
        ("\\\\+ G", "negation as failure -- succeeds if G cannot be proved"),
    ]
    assert len(behaviours) == 4

    print("  the cut and fail, as semantics (pytholog has no cut):")
    for op, meaning in behaviours:
        print(f"    {op:10} {meaning}")
    print()
    print("  the classic cut-fail example -- 'penguins do not fly':")
    print("      fly(X) :- penguin(X), !, fail.")
    print("      fly(X) :- bird(X).")
    print("    for a penguin the first clause commits and fails, so the second")
    print("    is NEVER tried. Remove the cut and every penguin flies.")
    print()
    print("  GREEN CUT   -- removes only redundant choice points; deleting it")
    print("                 changes nothing but speed")
    print("  RED CUT     -- changes the MEANING; deleting it changes answers")
    print("       the cut is the point where Prolog stops being pure logic:")
    print("       clause order and cut placement become semantically load")
    print("       bearing, which is why it is the hardest thing to debug")


def negation_as_failure_is_not_logical_negation():
    """The examinable subtlety, demonstrated with a closed world."""
    known_birds = {"tweety", "polly"}
    known_penguins = {"pingu"}

    # \+ penguin(tweety) succeeds because tweety is not KNOWN to be a penguin
    assert "tweety" not in known_penguins
    # ... but the database simply may not know. That is the CLOSED WORLD
    # ASSUMPTION: anything not derivable is taken to be false.
    unknown_bird = "kiwi"
    assert unknown_bird not in known_birds and unknown_bird not in known_penguins

    print(f"  known birds: {sorted(known_birds)}, known penguins: "
          f"{sorted(known_penguins)}")
    print(f"  \\\\+ penguin(tweety)  succeeds -- tweety is not known to be one")
    print(f"  \\\\+ bird({unknown_bird})       succeeds -- but we simply do not know")
    print("       Prolog's \\\\+ is NEGATION AS FAILURE, not logical negation.")
    print("       It assumes a CLOSED WORLD: anything not derivable is false.")
    print("       Logical negation would require proving the fact untrue")


def main():
    print("Experiments 2-7 -- Lists, arithmetic, and the cut")
    # Step 1: Prove pytholog's limits first
    print("  engine limits, asserted:")
    pytholog_has_no_lists()
    pytholog_has_no_arithmetic()
    # Step 2: Experiment 2: the list predicates
    print("  experiment 2 -- list predicates:")
    list_predicates()
    # Step 3: Experiment 3: the maximum
    print("  experiment 3 -- maximum of a list:")
    maximum_of_a_list()
    # Step 4: Experiment 4: flatten
    print("  experiment 4 -- flatten:")
    flatten_a_nested_list()
    # Step 5: Experiments 5 and 6: factorial, Fibonacci and GCD
    print("  experiments 5 and 6 -- factorial, Fibonacci, GCD:")
    factorial_and_fibonacci()
    gcd_by_recursion()
    # Step 6: Experiment 7: cut, fail and negation
    print("  experiment 7 -- cut and fail:")
    cut_and_fail_are_documented_only()
    negation_as_failure_is_not_logical_negation()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 02_lists.pl:

OUTPUT

?- mem(b, [a,b,c]).
true.

?- app([a,b], [c,d], X).
X = [a, b, c, d].

?- rev([a,b,c], X).
X = [c, b, a].

?- len([a,b,c], N).
N = 3.

?- app(X, Y, [a,b,c]).
X = [],
Y = [a, b, c] ;
X = [a],
Y = [b, c] ;
X = [a, b],
Y = [c] ;
X = [a, b, c],
Y = [].

The Python check, 02_lists_and_arithmetic.py, for experiments 2–7:

OUTPUT

Experiments 2-7 -- Lists, arithmetic, and the cut
  engine limits, asserted:
  pytholog: mem(b, [a,b,c]) -> ['No']  (SWI-Prolog answers 'true')
       there are no list TERMS, so [X|T] never unifies. Experiments
       2, 3 and 4 are executed in Python below; the .pl files carry
       the real Prolog
  pytholog: fact(5, X) -> ['No']  (SWI-Prolog answers X = 120)
       'is/2' does not evaluate, so experiments 5 and 6 are executed
       in Python too
  experiment 2 -- list predicates:
  member(b, ['a', 'b', 'c'])      -> true
  append(['a', 'b', 'c'], ['d', 'e'], X) -> ['a', 'b', 'c', 'd', 'e']
  reverse(['a', 'b', 'c'], X)     -> ['c', 'b', 'a']
  length(['a', 'b', 'c'], N)      -> 3
  append(X, Y, ['a', 'b', 'c'])   -> 4 solutions: [([], ['a', 'b', 'c']), (['a'], ['b', 'c']), (['a', 'b'], ['c']), (['a', 'b', 'c'], [])]
       that last one is the point of Prolog: append/3 RUNS BACKWARDS.
       One definition both concatenates and splits, because a rule
       states a RELATION rather than a function
  experiment 3 -- maximum of a list:
  max([3,7,2,9,4]) -> 9
       the Prolog version recurses on the tail and compares against
       the maximum of the rest. The base case is the ONE-element
       list, not the empty one -- max([]) has no answer
  experiment 4 -- flatten:
  flatten([1, [2, [3, 4], 5], [[6]], 7]) -> [1, 2, 3, 4, 5, 6, 7]
  flatten([[], [[]], 1]) -> [1]
       three clauses in Prolog: the empty list, a list head (recurse
       into it and append), and an atom head (keep it). Empty
       sublists disappear, which is the case people forget
  experiments 5 and 6 -- factorial, Fibonacci, GCD:
  factorial 0..5 -> [1, 1, 2, 6, 24, 120]
  fibonacci 0..9 -> [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
  naive fib(10) makes 177 recursive calls
       the naive Prolog definition is exponential for the same
       reason. Add an accumulator, or use assertz/1 to memoise --
       which is Prolog's version of dynamic programming
  gcd(48, 18) = 6   trace: [(48, 18), (18, 12), (12, 6)] -> 6
  gcd(17, 5)  = 1    (coprime)
       two clauses in Prolog: gcd(A,0,A) is the base case, and
       gcd(A,B,G) :- B > 0, R is A mod B, gcd(B,R,G)
  experiment 7 -- cut and fail:
  the cut and fail, as semantics (pytholog has no cut):
    !          commits to the current clause AND to the bindings made before it; discards remaining choice points
    fail       always fails, forcing backtracking
    !, fail    the CUT-FAIL idiom: commit, then fail, so the whole goal fails with no alternatives tried
    \\+ G      negation as failure -- succeeds if G cannot be proved

  the classic cut-fail example -- 'penguins do not fly':
      fly(X) :- penguin(X), !, fail.
      fly(X) :- bird(X).
    for a penguin the first clause commits and fails, so the second
    is NEVER tried. Remove the cut and every penguin flies.

  GREEN CUT   -- removes only redundant choice points; deleting it
                 changes nothing but speed
  RED CUT     -- changes the MEANING; deleting it changes answers
       the cut is the point where Prolog stops being pure logic:
       clause order and cut placement become semantically load
       bearing, which is why it is the hardest thing to debug
  known birds: ['polly', 'tweety'], known penguins: ['pingu']
  \\+ penguin(tweety)  succeeds -- tweety is not known to be one
  \\+ bird(kiwi)       succeeds -- but we simply do not know
       Prolog's \\+ is NEGATION AS FAILURE, not logical negation.
       It assumes a CLOSED WORLD: anything not derivable is false.
       Logical negation would require proving the fact untrue

THE ANSWER THAT EARNS THE MARKS

append/3 runs backwards. SWI-Prolog's four answers to app(X, Y, [a,b,c]), above, are every way to split the list: one definition both concatenates and splits, because a Prolog rule states a relation, not a function. A Python append() can never do this. If the viva asks "what makes Prolog different", this is the two-line answer.

RESULT

append(X, Y, [a,b,c]) has four solutions: one definition both concatenates and splits.

Experiment 3 — The maximum of a list

1. Question

Find the maximum element of a list.

2. Aim

Define max_list/2 recursively, from the right base case.

3. Steps

  1. Define max_list/2, from a one-element base case.
  2. Ask the query.

THE BASE CASE

For max/2 the base case is the one-element list, not the empty one — max([], M) has no answer, and writing max([], 0) is wrong for negative numbers.

4. Programme

% Experiment 3 -- the maximum element of a list.
%
% Run it: swipl 03_maximum.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: Define max_list/2, from a one-element base case
max_list([X], X).
max_list([H|T], M) :- max_list(T, M1), (H > M1 -> M = H ; M = M1).

% Step 2: Ask the query
% ?- max_list([3,7,2,9,4], M).   % M = 9

% NOTE the base case: a ONE-element list, not the empty one. max_list([], M)
% has no answer, because the maximum of nothing is undefined -- and writing
% max_list([], 0) would be wrong for a list of negative numbers.

5. Execution and Results

OUTPUT

?- max_list([3,7,2,9,4], M).
M = 9.

The Python check for this experiment, 02_lists_and_arithmetic.py, is shown in full under Experiment 2, with what it printed.

RESULT

max_list([3,7,2,9,4], M) gives M = 9.

Experiment 4 — Flatten a nested list

1. Question

Flatten a nested list into a single-level list.

2. Aim

Define flatten/2 in three clauses: the empty list, a list head and an atom.

3. Steps

  1. Define flatten/2 in three clauses.
  2. Ask the query.

THE THREE CLAUSES

For flatten/2 there are three clauses: empty list, list head (recurse and append), atom head (keep). Empty sublists disappear — flatten([[], [[]], 1]) gives [1], which is the case people forget. The Python check asserts both.

4. Programme

% Experiment 4 -- flatten a nested list into a single-level list.
%
% Run it: swipl 04_flatten.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: Define flatten/2 in three clauses
flatten([], []) :- !.
flatten([H|T], R) :- !, flatten(H, FH), flatten(T, FT), append(FH, FT, R).
flatten(X, [X]).

% Step 2: Ask the query
% ?- flatten([1,[2,[3,4],5],[[6]],7], X).   % X = [1,2,3,4,5,6,7]

% THREE clauses: the empty list, a list head (recurse into it and append), and
% an atom (wrap it). The cuts make the clauses mutually exclusive -- without
% them, flatten([], X) would also match the third clause and give X = [[]].
% These are RED CUTS: removing them changes the answers, not just the speed.

5. Execution and Results

OUTPUT

?- flatten([1,[2,[3,4],5],[[6]],7], X).
X = [1, 2, 3, 4, 5, 6, 7].

The Python check for this experiment, 02_lists_and_arithmetic.py, is shown in full under Experiment 2, with what it printed.

RESULT

flatten([1,[2,[3,4],5],[[6]],7], X) gives [1, 2, 3, 4, 5, 6, 7].

Experiment 5 — Factorial and Fibonacci

1. Question

Compute factorial and Fibonacci numbers by recursion.

2. Aim

Define fact/2 and fib/2 with is/2, see why the naive fib/2 is slow, and make it linear.

3. Steps

  1. Define factorial.
  2. Define Fibonacci.
  3. Ask the queries.
  4. Make Fibonacci linear with an accumulator.

THE NUMBERS

factorial 0..5 -> [1, 1, 2, 6, 24, 120]
fibonacci 0..9 -> [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
naive fib(10) makes 177 recursive calls

177 calls for fib(10) is the number to quote: the naive Prolog definition is exponential for exactly the reason the Python one is. The fix is an accumulator, or assertz/1 to memoise — Prolog's dynamic programming.

4. Programme

% Experiment 5 -- factorial and Fibonacci.
%
% Run it: swipl 05_factorial_fib.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% pytholog does not evaluate is/2, so this cannot run there either.

% Step 1: Define factorial
fact(0, 1).
fact(N, F) :- N > 0, N1 is N - 1, fact(N1, F1), F is N * F1.

% Step 2: Define Fibonacci
fib(0, 0).
fib(1, 1).
fib(N, F) :- N > 1, N1 is N-1, N2 is N-2, fib(N1, F1), fib(N2, F2), F is F1+F2.

% Step 3: Ask the queries
% ?- fact(5, F).   % F = 120
% ?- fib(10, F).   % F = 55

% Step 4: Make Fibonacci linear with an accumulator
% fib(10, F) makes 177 recursive calls, because fib(8) is recomputed inside
% both fib(9) and fib(8). It is exponential. Two fixes:
%
% 1. An accumulator pair, which makes it linear:
fib_fast(N, F) :- fib_acc(N, 0, 1, F).
fib_acc(0, A, _, A).
fib_acc(N, A, B, F) :- N > 0, N1 is N-1, C is A+B, fib_acc(N1, B, C, F).
% ?- fib_fast(30, F).   % F = 832040, at once -- fib(30, F) makes 2,692,537 calls
% [Added: nothing asked for fib_fast/2, so it had never been tried.]
%
% 2. assertz/1 to memoise -- Prolog's dynamic programming:
%      :- dynamic fibm/2.
%      fibm(N, F) :- fib(N, F), assertz(fibm(N, F)).

5. Execution and Results

OUTPUT

?- fact(5, F).
F = 120.

?- fib(10, F).
F = 55.

?- fib_fast(30, F).
F = 832040.

Added: nothing in the file asked for its accumulator version, fib_fast/2; the query above runs it. fib_fast(30, F) answers at once, where the naive fib(30, F) makes 2,692,537 calls.

The Python check for this experiment, 02_lists_and_arithmetic.py, is shown in full under Experiment 2, with what it printed.

RESULT

fact(5, F) gives 120 and fib(10, F) gives 55; fib_fast(30, F) gives 832040 at once.

Experiment 6 — GCD by recursion

1. Question

Find the greatest common divisor of two numbers by recursion.

2. Aim

Define gcd/3 by Euclid's algorithm, and see why it terminates.

3. Steps

  1. Define gcd/3, by Euclid.
  2. Ask the queries.

THE TRACE

gcd(48, 18) = 6   trace: (48,18) -> (18,12) -> (12,6) -> 6
gcd(17, 5)  = 1   (coprime)

Each step replaces (A, B) with (B, A mod B), and B strictly decreases, so termination is guaranteed.

4. Programme

% Experiment 6 -- greatest common divisor by recursion (Euclid).
%
% Run it: swipl 06_gcd.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: Define gcd/3, by Euclid
gcd(A, 0, A) :- A > 0.
gcd(A, B, G) :- B > 0, R is A mod B, gcd(B, R, G).

% Step 2: Ask the queries
% ?- gcd(48, 18, G).   % G = 6
% ?- gcd(17, 5, G).    % G = 1   (coprime)

% The trace for gcd(48, 18):
%   gcd(48, 18) -> gcd(18, 12) -> gcd(12, 6) -> gcd(6, 0) -> 6
% Each step replaces (A, B) with (B, A mod B), and B strictly decreases, so
% termination is guaranteed. That decreasing measure is the recursion's
% variant, and it is what you point at when asked why it halts.

5. Execution and Results

OUTPUT

?- gcd(48, 18, G).
G = 6.

?- gcd(17, 5, G).
G = 1.

The Python check for this experiment, 02_lists_and_arithmetic.py, is shown in full under Experiment 2, with what it printed.

RESULT

gcd(48, 18, G) gives 6; gcd(17, 5, G) gives 1.

Experiment 7 — Cut and fail

1. Question

Demonstrate the cut (!) and fail.

2. Aim

Write the cut-fail idiom, tell a green cut from a red one, and negate by failure.

3. Steps

  1. State the facts.
  2. Write the cut-fail rule.
  3. Tell a green cut from a red one.
  4. Negate by failure.

THE CONSTRUCTS

Construct Meaning
! commits to this clause and to the bindings made before it; discards remaining choice points
fail always fails, forcing backtracking
!, fail commit, then fail — the whole goal fails with no alternatives tried
\+ G negation as failure — succeeds if G cannot be proved
fly(X) :- penguin(X), !, fail.
fly(X) :- bird(X).

For a penguin the first clause commits and fails, so the second is never tried. Remove the cut and every penguin flies.

4. Programme

% Experiment 7 -- the cut (!) and fail.
%
% Run it: swipl 07_cut_fail.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 02_lists_and_arithmetic.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% pytholog has NO CUT, so the .py file sets out the semantics and asserts the
% engine limitation; the cut itself runs here, in SWI-Prolog.

% Step 1: State the facts
bird(tweety).
bird(polly).
bird(pingu).
penguin(pingu).
% [Corrected: bird(pingu) came after penguin(pingu), so bird/1's clauses were not
% together, and SWI-Prolog warned "Clauses of bird/1 are not together" on loading.]

% Step 2: Write the cut-fail rule
fly(X) :- penguin(X), !, fail.
fly(X) :- bird(X).

% ?- fly(tweety).   % true
% ?- fly(pingu).    % false  -- the first clause commits and fails, so the
%                   %           second is NEVER tried
%
% REMOVE THE CUT and fly(pingu) succeeds via the second clause. That is a
% RED CUT: it changes the meaning of the program.

% Step 3: Tell a green cut from a red one
% GREEN CUT: removes only redundant choice points. Deleting it changes speed
%            and nothing else.
% RED CUT:   changes which answers are produced. Deleting it changes meaning.
%
% max_green(X, Y, X) :- X >= Y, !.     % green -- the guard already excludes
% max_green(X, Y, Y) :- X < Y.         %          the other case
%
% max_red(X, Y, X) :- X >= Y, !.       % red -- without the cut, max_red(3,2,2)
% max_red(_, Y, Y).                    %        would also succeed

% Step 4: Negate by failure
not_penguin(X) :- \+ penguin(X).
% ?- not_penguin(tweety).   % true -- tweety is not KNOWN to be a penguin
%
% \+ is NEGATION AS FAILURE under the CLOSED WORLD ASSUMPTION: anything not
% derivable is taken to be false. It is NOT logical negation, which would
% require proving the fact untrue. For an unknown bird, \+ penguin(kiwi)
% succeeds simply because the database has never heard of it.

5. Execution and Results

OUTPUT

?- fly(tweety).
true.

?- fly(pingu).
false.

?- not_penguin(tweety).
true.

Corrected: bird(pingu) came after penguin(pingu), so the clauses of bird/1 were not together, and SWI-Prolog warned "Clauses of bird/1 are not together in the source-file" on loading. They are together now, and the file loads silently.

GREEN CUT AND RED CUT

The cut is where Prolog stops being pure logic: clause order and cut placement become semantically load-bearing, which is why it is the hardest thing to debug.

+ IS NOT LOGICAL NEGATION

known birds: ['polly', 'tweety'], known penguins: ['pingu']
\+ penguin(tweety)  succeeds  -- tweety is not known to be one
\+ bird(kiwi)       succeeds  -- but we simply do not know

Prolog assumes a closed world: anything not derivable is false. Logical negation would require proving the fact untrue. A kiwi is a bird.

The Python check for this experiment, 02_lists_and_arithmetic.py, is shown in full under Experiment 2, with what it printed.

RESULT

fly(tweety) is true and fly(pingu) is false: the first clause commits and fails, so the second is never tried.

Experiments 8–11 — A graph, DFS, BFS, and the comparison

1. Question

Experiment 8: represent a graph as facts. Experiment 9: search it depth-first. Experiment 10: search it breadth-first. Experiment 11: compare the paths they find.

2. Aim

State one graph, search it both ways, and see that DFS finds the long way round and BFS the short one.

3. Steps

In SWI-Prolog, 08_graph_search.pl:

  1. Experiment 8: state the graph as edge/2 facts.
  2. Experiment 9: search depth-first.
  3. Experiment 10: search breadth-first, with a queue.
  4. Experiment 11: compare the paths.

The Python check, 03_uninformed_search.py, for experiments 8–11:

  1. Compare BFS, DFS and uniform cost on the Romania map.
  2. See uniform cost as Dijkstra.
  3. Run DFS and BFS on the six-node graph.
  4. Make one step cost more.
  5. Count iterative deepening's extra nodes.

THE ROMANIA RUN

Four experiments, one graph. The Python check runs both on the Romania map from Russell & Norvig (20 cities, real distances) and on a six-node graph built to make one specific point.

The Romania run — the table to memorise:

Strategy Expanded Edges Cost Path
BFS 9 3 450 Arad → Sibiu → Fagaras → Bucharest
DFS 6 5 607 Arad → Zerind → Oradea → Sibiu → Fagaras → Bucharest
Uniform cost 13 4 418 Arad → Sibiu → Rimnicu Vilcea → Pitesti → Bucharest

Read it in three lines:

Neither BFS nor DFS finds the optimal route, because it runs through Rimnicu Vilcea and Pitesti and neither strategy has any reason to go that way.

4. Programme

In SWI-Prolog, 08_graph_search.pl:

% Experiments 8-11 -- a graph, DFS, BFS, and comparing path lengths.
%
% Run it: swipl 08_graph_search.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 03_uninformed_search.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: Experiment 8: state the graph as edge/2 facts
edge(a, b).  edge(a, c).
edge(b, d).
edge(c, g).
edge(d, e).
edge(e, g).

% Step 2: Experiment 9: search depth-first
% The Visited list is what stops it looping. Without it, a cycle is fatal.
dfs(Start, Goal, Path) :- dfs_helper(Start, Goal, [Start], P), reverse(P, Path).

dfs_helper(Goal, Goal, Visited, Visited).
dfs_helper(Node, Goal, Visited, Path) :-
    edge(Node, Next),
    \+ member(Next, Visited),
    dfs_helper(Next, Goal, [Next|Visited], Path).

% ?- dfs(a, g, P).   % P = [a,b,d,e,g]  -- the LONG way, found first; ; gives [a,c,g]

% Step 3: Experiment 10: search breadth-first, with a queue
bfs(Start, Goal, Path) :- bfs_queue([[Start]], Goal, R), reverse(R, Path).

bfs_queue([[Goal|Rest]|_], Goal, [Goal|Rest]).
bfs_queue([[N|Rest]|Others], Goal, Path) :-
    findall([M,N|Rest], (edge(N, M), \+ member(M, [N|Rest])), Children),
    append(Others, Children, NewQueue),      % APPEND -> a FIFO queue
    bfs_queue(NewQueue, Goal, Path).

% ?- bfs(a, g, P).   % P = [a,c,g]  -- the SHORT way, found first

% Step 4: Experiment 11: compare the paths
compare_searches(Start, Goal) :-
    once(dfs(Start, Goal, DP)), length(DP, DL),     % the FIRST path each one finds
    once(bfs(Start, Goal, BP)), length(BP, BL),
    format("DFS: ~w (~w nodes)~nBFS: ~w (~w nodes)~n", [DP, DL, BP, BL]).
% [Corrected: without once/1, dfs/3 and bfs/3 each find both paths on
% backtracking, so compare_searches printed four comparisons and succeeded four
% times.]

% ?- compare_searches(a, g).
%   DFS: [a,b,d,e,g] (5 nodes)
%   BFS: [a,c,g] (3 nodes)
%
% THE ONLY DIFFERENCE IS append(Others, Children, Q) versus
% append(Children, Others, Q). Appending children at the BACK gives a FIFO
% queue and breadth-first order; at the FRONT gives a LIFO stack and
% depth-first. One argument order is the whole distinction between the two
% algorithms, and it is worth showing the examiner.

The Python check, 03_uninformed_search.py, for experiments 8–11:

"""Experiments 9, 10, 11 — DFS, BFS and uniform cost search.

Run on Russell & Norvig's Romania map, so every figure can be checked against
the textbook as well as against this code. unit-2.md section 2.7 quotes these
numbers.

The three algorithms are the SAME LOOP with a different frontier -- a queue, a
stack, a priority queue -- and the code is written to make that obvious.
"""
import heapq
from collections import deque

from fixtures import GOAL, GRAPH_EDGES, ROMANIA, START, path_cost


# --- the three strategies: one loop, three frontiers ------------------------

def bfs(graph, start, goal):
    """FIFO queue -> expand the SHALLOWEST node."""
    frontier = deque([[start]])
    reached = {start}
    expanded = 0
    while frontier:
        path = frontier.popleft()
        expanded += 1
        if path[-1] == goal:
            return path, expanded
        for nxt in graph[path[-1]]:
            if nxt not in reached:
                reached.add(nxt)
                frontier.append(path + [nxt])
    return None, expanded


def dfs(graph, start, goal):
    """LIFO stack -> expand the DEEPEST node."""
    frontier = [[start]]
    explored = set()
    expanded = 0
    while frontier:
        path = frontier.pop()
        if path[-1] in explored:
            continue
        explored.add(path[-1])
        expanded += 1
        if path[-1] == goal:
            return path, expanded
        for nxt in reversed(list(graph[path[-1]])):
            if nxt not in explored:
                frontier.append(path + [nxt])
    return None, expanded


def ucs(graph, start, goal):
    """Priority queue on g(n) -> expand the CHEAPEST path so far."""
    frontier = [(0, [start])]
    best = {}
    expanded = 0
    while frontier:
        cost, path = heapq.heappop(frontier)
        node = path[-1]
        if node in best and best[node] <= cost:
            continue
        best[node] = cost
        expanded += 1
        # The goal test happens on EXPANSION, not generation -- a cheaper
        # path to the goal may still be waiting in the frontier.
        if node == goal:
            return path, expanded, cost
        for nxt, step in graph[node].items():
            heapq.heappush(frontier, (cost + step, path + [nxt]))
    return None, expanded, None


# --- the experiments ---------------------------------------------------------

def the_three_compared():
    """unit-2.md 2.7's table, reproduced exactly."""
    bfs_path, bfs_exp = bfs(ROMANIA, START, GOAL)
    dfs_path, dfs_exp = dfs(ROMANIA, START, GOAL)
    ucs_path, ucs_exp, ucs_cost = ucs(ROMANIA, START, GOAL)

    assert (bfs_exp, path_cost(bfs_path)) == (9, 450), (bfs_exp, path_cost(bfs_path))
    assert (dfs_exp, path_cost(dfs_path)) == (6, 607), (dfs_exp, path_cost(dfs_path))
    assert (ucs_exp, ucs_cost) == (13, 418), (ucs_exp, ucs_cost)

    assert bfs_path == ["Arad", "Sibiu", "Fagaras", "Bucharest"]
    assert ucs_path == ["Arad", "Sibiu", "Rimnicu Vilcea", "Pitesti", "Bucharest"]

    # BFS found the path with the fewest EDGES -- but not the cheapest.
    assert len(bfs_path) - 1 == 3
    assert len(ucs_path) - 1 == 4
    assert path_cost(bfs_path) > ucs_cost, "fewer edges, MORE kilometres"

    print(f"  {'strategy':14} {'expanded':>9} {'edges':>6} {'cost':>6}  path")
    for name, p, e, c in (("BFS", bfs_path, bfs_exp, path_cost(bfs_path)),
                          ("DFS", dfs_path, dfs_exp, path_cost(dfs_path)),
                          ("Uniform cost", ucs_path, ucs_exp, ucs_cost)):
        print(f"  {name:14} {e:>9} {len(p) - 1:>6} {c:>6}  {' -> '.join(p)}")
    print("       BFS found the fewest EDGES (3) and 450 km, not the cheapest.")
    print("       Step costs are unequal here, so its optimality guarantee")
    print("       does not apply.")
    print("       DFS expanded fewest and found the WORST path, 607 km.")
    print("       UCS found the optimal 418 and expanded the MOST, 13.")
    print("       The optimal route runs through Rimnicu Vilcea and Pitesti,")
    print("       and NEITHER BFS NOR DFS finds it")
    return ucs_cost


def ucs_is_dijkstra(optimal):
    """UCS computes the shortest distance to EVERY city, like Dijkstra."""
    distances = {}
    frontier = [(0, START)]
    while frontier:
        cost, node = heapq.heappop(frontier)
        if node in distances:
            continue
        distances[node] = cost
        for nxt, step in ROMANIA[node].items():
            if nxt not in distances:
                heapq.heappush(frontier, (cost + step, nxt))

    assert len(distances) == len(ROMANIA) == 20
    assert distances["Bucharest"] == optimal == 418
    assert distances["Arad"] == 0
    assert distances["Sibiu"] == 140
    assert distances["Rimnicu Vilcea"] == 220, distances["Rimnicu Vilcea"]
    assert distances["Pitesti"] == 317, distances["Pitesti"]
    # 140 + 80 + 97 + 101 = 418, which is the optimal path, city by city.
    assert 140 + 80 + 97 + 101 == 418

    print(f"  shortest distance from Arad to each of {len(distances)} cities:")
    for city in ("Sibiu", "Rimnicu Vilcea", "Pitesti", "Bucharest", "Neamt"):
        print(f"    {city:16} {distances[city]:>4} km")
    print("       140 + 80 + 97 + 101 = 418, the optimal route accumulated")
    print("       one city at a time. UCS IS DIJKSTRA'S ALGORITHM -- the same")
    print("       procedure, reached from the AI side instead of graph theory")


def dfs_and_bfs_on_a_small_graph():
    """Experiments 9-11 proper: trace both on a graph small enough to check."""
    simple = {k: {v: 1 for v in vs} for k, vs in GRAPH_EDGES.items()}

    bfs_path, bfs_exp = bfs(simple, "a", "g")
    dfs_path, dfs_exp = dfs(simple, "a", "g")

    assert bfs_path == ["a", "c", "g"], bfs_path
    assert dfs_path == ["a", "b", "d", "e", "g"], dfs_path
    assert len(bfs_path) - 1 == 2 and len(dfs_path) - 1 == 4
    assert bfs_exp == dfs_exp == 5, (bfs_exp, dfs_exp)

    print("  graph:  a -> b,c    b -> d    d -> e    e -> g    c -> g")
    print(f"    BFS  {' -> '.join(bfs_path):24} {len(bfs_path) - 1} edges, "
          f"expanded {bfs_exp}")
    print(f"    DFS  {' -> '.join(dfs_path):24} {len(dfs_path) - 1} edges, "
          f"expanded {dfs_exp}")
    print("       SAME AMOUNT OF WORK, TWICE THE PATH. BFS explores level by")
    print("       level so it cannot miss the 2-edge route; DFS dived into b")
    print("       and committed to the long way round. That is the guarantee")
    print("       BFS gives and DFS does not")


def bfs_is_optimal_only_with_equal_step_costs():
    """The condition stated in the property table, demonstrated both ways."""
    unit = {k: {v: 1 for v in vs} for k, vs in GRAPH_EDGES.items()}
    bfs_p, _ = bfs(unit, "a", "g")
    ucs_p, _, ucs_c = ucs(unit, "a", "g")
    assert len(bfs_p) - 1 == ucs_c, "with UNIT costs, BFS's answer IS optimal"

    # Now make the SHORT route expensive; BFS cannot see the difference.
    weighted = {k: {v: 1 for v in vs} for k, vs in GRAPH_EDGES.items()}
    weighted["c"]["g"] = 50
    bfs_p2, _ = bfs(weighted, "a", "g")
    ucs_p2, _, ucs_c2 = ucs(weighted, "a", "g")

    assert bfs_p2 == ["a", "c", "g"], bfs_p2
    assert path_cost(bfs_p2, weighted) == 51, path_cost(bfs_p2, weighted)
    assert ucs_p2 == ["a", "b", "d", "e", "g"], ucs_p2
    assert ucs_c2 == 4, ucs_c2
    assert path_cost(bfs_p2, weighted) > ucs_c2

    print(f"  unit step costs  : BFS cost {len(bfs_p) - 1}, UCS cost {ucs_c}  -- EQUAL")
    print(f"  make c->g cost 50: BFS cost {path_cost(bfs_p2, weighted)} "
          f"({' -> '.join(bfs_p2)})")
    print(f"                     UCS cost {ucs_c2} ({' -> '.join(ucs_p2)})")
    print("       BFS counts EDGES, not cost. With equal step costs those are")
    print("       the same thing and BFS is optimal; the moment they differ,")
    print("       it is not. That is exactly what the property table says")


def iterative_deepening_is_not_wasteful():
    """unit-2.md 2.5's arithmetic: 123,456 against 111,111 at b=10, d=5."""
    def bfs_nodes(b, d):
        return sum(b ** i for i in range(d + 1))

    def ids_nodes(b, d):
        return sum((d + 1 - i) * b ** i for i in range(d + 1))

    b, d = 10, 5
    bfs_total = bfs_nodes(b, d)
    ids_total = ids_nodes(b, d)

    assert bfs_total == 111111, bfs_total
    assert ids_total == 123456, ids_total
    assert ids_total / bfs_total < 1.12

    print(f"  b = {b}, d = {d}:")
    print(f"    BFS generates {bfs_total:,} nodes")
    print(f"    IDS generates {ids_total:,} nodes  "
          f"({(ids_total / bfs_total - 1) * 100:.1f}% more)")
    print(f"    and IDS uses O(bd) = {b * d} memory instead of O(b^d) = {b ** d:,}")
    print("       the repetition is cheap because the BOTTOM LEVEL holds most")
    print("       of the nodes -- regenerating everything above it costs almost")
    print("       nothing. IDS is the preferred uninformed search when the")
    print("       depth is unknown")


def main():
    print("Experiments 9-11 -- Uninformed search: DFS, BFS, uniform cost")
    # Step 1: Compare BFS, DFS and uniform cost on the Romania map
    optimal = the_three_compared()
    # Step 2: See uniform cost as Dijkstra
    ucs_is_dijkstra(optimal)
    # Step 3: Run DFS and BFS on the six-node graph
    dfs_and_bfs_on_a_small_graph()
    # Step 4: Make one step cost more
    bfs_is_optimal_only_with_equal_step_costs()
    # Step 5: Count iterative deepening's extra nodes
    iterative_deepening_is_not_wasteful()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 08_graph_search.pl:

OUTPUT

?- dfs(a, g, P).
P = [a, b, d, e, g] ;
P = [a, c, g].

?- bfs(a, g, P).
P = [a, c, g] ;
P = [a, b, d, e, g].

?- compare_searches(a, g).
DFS: [a,b,d,e,g] (5 nodes)
BFS: [a,c,g] (3 nodes)
true.

The Python check, 03_uninformed_search.py, for experiments 8–11:

OUTPUT

Experiments 9-11 -- Uninformed search: DFS, BFS, uniform cost
  strategy        expanded  edges   cost  path
  BFS                    9      3    450  Arad -> Sibiu -> Fagaras -> Bucharest
  DFS                    6      5    607  Arad -> Zerind -> Oradea -> Sibiu -> Fagaras -> Bucharest
  Uniform cost          13      4    418  Arad -> Sibiu -> Rimnicu Vilcea -> Pitesti -> Bucharest
       BFS found the fewest EDGES (3) and 450 km, not the cheapest.
       Step costs are unequal here, so its optimality guarantee
       does not apply.
       DFS expanded fewest and found the WORST path, 607 km.
       UCS found the optimal 418 and expanded the MOST, 13.
       The optimal route runs through Rimnicu Vilcea and Pitesti,
       and NEITHER BFS NOR DFS finds it
  shortest distance from Arad to each of 20 cities:
    Sibiu             140 km
    Rimnicu Vilcea    220 km
    Pitesti           317 km
    Bucharest         418 km
    Neamt             824 km
       140 + 80 + 97 + 101 = 418, the optimal route accumulated
       one city at a time. UCS IS DIJKSTRA'S ALGORITHM -- the same
       procedure, reached from the AI side instead of graph theory
  graph:  a -> b,c    b -> d    d -> e    e -> g    c -> g
    BFS  a -> c -> g              2 edges, expanded 5
    DFS  a -> b -> d -> e -> g    4 edges, expanded 5
       SAME AMOUNT OF WORK, TWICE THE PATH. BFS explores level by
       level so it cannot miss the 2-edge route; DFS dived into b
       and committed to the long way round. That is the guarantee
       BFS gives and DFS does not
  unit step costs  : BFS cost 2, UCS cost 2  -- EQUAL
  make c->g cost 50: BFS cost 51 (a -> c -> g)
                     UCS cost 4 (a -> b -> d -> e -> g)
       BFS counts EDGES, not cost. With equal step costs those are
       the same thing and BFS is optimal; the moment they differ,
       it is not. That is exactly what the property table says
  b = 10, d = 5:
    BFS generates 111,111 nodes
    IDS generates 123,456 nodes  (11.1% more)
    and IDS uses O(bd) = 50 memory instead of O(b^d) = 100,000
       the repetition is cheap because the BOTTOM LEVEL holds most
       of the nodes -- regenerating everything above it costs almost
       nothing. IDS is the preferred uninformed search when the
       depth is unknown

In SWI-Prolog, dfs/3 and bfs/3 each find both paths: pressing ; after DFS's long way gives the short one, and the other way round for BFS. The order is the difference. Corrected: compare_searches/2 let both backtrack, so it printed four comparisons and succeeded four times; it now takes the first path each finds, with once/1, and prints one.

UCS IS DIJKSTRA

shortest distance from Arad:
  Sibiu           140 km
  Rimnicu Vilcea  220 km
  Pitesti         317 km
  Bucharest       418 km
  Neamt           824 km

140 + 80 + 97 + 101 = 418 — the optimal route accumulated one city at a time. Same procedure, reached from the AI side rather than from graph theory.

The six-node graph — same work, twice the path:

a -> b, c    b -> d    d -> e    e -> g    c -> g
Path Edges Expanded
BFS a → c → g 2 5
DFS a → b → d → e → g 4 5

Identical work, twice the path. BFS explores level by level so it cannot miss the 2-edge route; DFS dived into b and committed to the long way round. That is exactly the guarantee BFS gives and DFS does not — stated as a measured fact rather than as a property table.

AND THE COUNTEREXAMPLE THAT KILLS "BFS IS OPTIMAL"

Step costs BFS cost UCS cost
all 1 2 2 — equal
make c→g cost 50 51 (a → c → g) 4 (a → b → d → e → g)

BFS counts edges, not cost. With equal step costs those are the same thing, so BFS is optimal. The moment they differ it is not — and the gap here is 51 against 4.

ITERATIVE DEEPENING COSTS 11.1%

With branching factor b = 10 and depth d = 5:

Nodes generated Memory
BFS 111,111 O(b^d) = 100,000
IDS 123,456 O(bd) = 50

11.1% more nodes for 2,000× less memory. The repetition is cheap because the bottom level holds most of the nodes, so regenerating everything above it costs almost nothing. IDS is the preferred uninformed search when the depth is unknown — and 111,111 / 123,456 are pleasing enough to remember.

RESULT

DFS finds a → b → d → e → g (5 nodes) first, BFS a → c → g (3); on the Romania map neither finds the optimal 418 km, which uniform cost does.

Experiment 12 — Greedy Best-First and A*

1. Question

Search the Romania map with Greedy Best-First search and A*.

2. Aim

Run A* with the straight-line heuristic, compare it with greedy and uniform cost, and check the heuristic is admissible.

3. Steps

In SWI-Prolog, 12_astar.pl:

  1. State the map.
  2. State the heuristic.
  3. Search with A*.
  4. Ask the query.

The Python check, 04_informed_search.py, for experiment 12:

  1. Compare uniform cost, greedy and A*.
  2. See why greedy goes wrong.
  3. Run A* with h = 0.
  4. Inflate the heuristic.
  5. Check admissibility at every city.
  6. Compare the 8-puzzle heuristics.

THE HEADLINE

The single most quotable result in the course:

Search f(n) Expanded Cost Optimal?
Uniform cost g(n) 13 418 YES
Greedy best-first h(n) 4 450 NO
A* g(n) + h(n) 6 418 YES

A* found the optimal 418 expanding 6 nodes where UCS needed 13. Same answer, less than half the work. That one sentence is why heuristics exist.

4. Programme

In SWI-Prolog, 12_astar.pl:

% Experiment 12 -- Greedy Best-First search and A* on the Romania map.
%
% Run it: swipl 12_astar.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 04_informed_search.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% pytholog cannot evaluate arithmetic, so 04_informed_search.py runs the search
% in Python, and reproduces Russell & Norvig's figures.

% Step 1: State the map
road(arad, zerind, 75).        road(arad, sibiu, 140).
road(arad, timisoara, 118).    road(zerind, oradea, 71).
road(oradea, sibiu, 151).      road(sibiu, fagaras, 99).
road(sibiu, rimnicu, 80).      road(rimnicu, pitesti, 97).
road(rimnicu, craiova, 146).   road(fagaras, bucharest, 211).
road(pitesti, bucharest, 101). road(craiova, pitesti, 138).

edge(X, Y, D) :- road(X, Y, D).
edge(X, Y, D) :- road(Y, X, D).

% Step 2: State the heuristic
% ADMISSIBLE by construction: a straight line can never be longer than a road.
h(arad, 366).      h(bucharest, 0).    h(craiova, 160).
h(fagaras, 176).   h(oradea, 380).     h(pitesti, 100).
h(rimnicu, 193).   h(sibiu, 253).      h(timisoara, 329).
h(zerind, 374).

% Step 3: Search with A*
astar(Start, Goal, Path, Cost) :-
    h(Start, H),
    astar_search([node(Start, [Start], 0, H)], Goal, RevPath, Cost),
    reverse(RevPath, Path).

astar_search([node(Goal, Path, G, _)|_], Goal, Path, G).
astar_search([node(N, Path, G, _)|Rest], Goal, Result, Cost) :-
    findall(node(M, [M|Path], G2, F2),
            ( edge(N, M, D),
              \+ member(M, Path),
              G2 is G + D,
              h(M, HM),
              F2 is G2 + HM ),
            Children),
    append(Rest, Children, All),
    sort_by_f(All, Sorted),                  % priority queue on f = g + h
    astar_search(Sorted, Goal, Result, Cost).

sort_by_f(Nodes, Sorted) :-
    map_list_to_pairs(f_of, Nodes, Pairs),
    keysort(Pairs, SortedPairs),
    pairs_values(SortedPairs, Sorted).
f_of(node(_, _, _, F), F).

% Step 4: Ask the query
% ?- astar(arad, bucharest, P, C).
%   P = [arad, sibiu, rimnicu, pitesti, bucharest],
%   C = 418
% Press ; and A* goes on to the other routes, in order of cost: 450 via
% Fagaras, then 575, 605, 607 and 762.
%
% GREEDY is the same predicate with f_of(node(_,_,_,H), H) -- ordering by h
% alone. It returns 450 via Fagaras, because Fagaras LOOKS closer (h=176 vs
% 193) and is further by road. Ignoring g is what makes greedy short-sighted.

The Python check, 04_informed_search.py, for experiment 12:

"""Experiment 12 — Greedy Best-First search and A*.

unit-3.md section 3.3's headline: A* finds the OPTIMAL 418 km route expanding
6 nodes where uniform cost search needs 13. And section 3.4's demonstration:
an INADMISSIBLE heuristic is faster and wrong.

Both are computed here on Russell & Norvig's Romania map.
"""
import heapq

from fixtures import (GOAL, ROMANIA, START, STRAIGHT_LINE, inflated,
                      path_cost)


def best_first(graph, start, goal, f):
    """One algorithm. f decides everything.

        f = g            -> uniform cost
        f = h            -> greedy best-first
        f = g + h        -> A*
    """
    frontier = [(f(start, 0), 0, [start])]
    best = {}
    expanded = 0
    while frontier:
        _, g, path = heapq.heappop(frontier)
        node = path[-1]
        if node in best and best[node] <= g:
            continue
        best[node] = g
        expanded += 1
        if node == goal:
            return path, expanded, g
        for nxt, step in graph[node].items():
            heapq.heappush(frontier, (f(nxt, g + step), g + step, path + [nxt]))
    return None, expanded, None


def ucs(graph, start, goal):
    return best_first(graph, start, goal, lambda n, g: g)


def greedy(graph, start, goal, h):
    return best_first(graph, start, goal, lambda n, g: h[n])


def astar(graph, start, goal, h):
    return best_first(graph, start, goal, lambda n, g: g + h[n])


def the_headline_comparison():
    """unit-3.md 3.3's table."""
    u_path, u_exp, u_cost = ucs(ROMANIA, START, GOAL)
    g_path, g_exp, _ = greedy(ROMANIA, START, GOAL, STRAIGHT_LINE)
    a_path, a_exp, a_cost = astar(ROMANIA, START, GOAL, STRAIGHT_LINE)
    g_cost = path_cost(g_path)

    assert (u_exp, u_cost) == (13, 418), (u_exp, u_cost)
    assert (g_exp, g_cost) == (4, 450), (g_exp, g_cost)
    assert (a_exp, a_cost) == (6, 418), (a_exp, a_cost)

    assert a_cost == u_cost, "A* found the SAME optimal cost as UCS"
    assert a_exp < u_exp / 2, "expanding less than half as many nodes"
    assert g_cost > a_cost, "greedy is faster still, and WRONG"

    print(f"  {'search':22} {'f(n)':12} {'expanded':>9} {'cost':>6}  optimal?")
    rows = [("Uniform cost", "g(n)", u_exp, u_cost, True),
            ("Greedy best-first", "h(n)", g_exp, g_cost, False),
            ("A*", "g(n) + h(n)", a_exp, a_cost, True)]
    for name, fn, exp, cost, opt in rows:
        print(f"  {name:22} {fn:12} {exp:>9} {cost:>6}  {'YES' if opt else 'NO'}")
    print(f"  greedy:  {' -> '.join(g_path)}")
    print(f"  A*:      {' -> '.join(a_path)}")
    print("       A* FOUND THE OPTIMAL 418 EXPANDING 6 NODES WHERE UCS NEEDED 13.")
    print("       Same answer, less than half the work -- that is why heuristics")
    print("       exist, and it is the number to quote")
    return u_exp, u_cost


def why_greedy_goes_wrong():
    """It ignores g(n) -- the cost already paid."""
    # At Sibiu the choice is Fagaras (h=176) or Rimnicu Vilcea (h=193).
    h_fagaras = STRAIGHT_LINE["Fagaras"]
    h_rimnicu = STRAIGHT_LINE["Rimnicu Vilcea"]
    assert h_fagaras < h_rimnicu, "Fagaras LOOKS closer"

    via_fagaras = ["Arad", "Sibiu", "Fagaras", "Bucharest"]
    via_rimnicu = ["Arad", "Sibiu", "Rimnicu Vilcea", "Pitesti", "Bucharest"]
    assert path_cost(via_fagaras) == 450
    assert path_cost(via_rimnicu) == 418
    assert path_cost(via_rimnicu) < path_cost(via_fagaras)

    print(f"  at Sibiu, greedy compares h only:")
    print(f"    h(Fagaras)        = {h_fagaras}   <- looks closer, so it goes here")
    print(f"    h(Rimnicu Vilcea) = {h_rimnicu}")
    print(f"  but the actual routes are:")
    print(f"    via Fagaras       = {path_cost(via_fagaras)} km")
    print(f"    via Rimnicu       = {path_cost(via_rimnicu)} km   <- 32 km shorter")
    print("       Fagaras IS closer as the crow flies and further by road.")
    print("       Greedy is short-sighted because it ignores g(n), the cost")
    print("       already paid -- which is exactly what A* adds back")


def astar_with_zero_heuristic_is_ucs(ucs_expanded, ucs_cost):
    """f = g + 0 = g. The same algorithm under a different name."""
    zero = {city: 0 for city in STRAIGHT_LINE}
    path, expanded, cost = astar(ROMANIA, START, GOAL, zero)

    assert (expanded, cost) == (ucs_expanded, ucs_cost) == (13, 418)

    print(f"  A* with h(n) = 0: expanded {expanded}, cost {cost}")
    print(f"  uniform cost    : expanded {ucs_expanded}, cost {ucs_cost}")
    print("       IDENTICAL. A* with a zero heuristic IS uniform cost search.")
    print("       A* sits between UCS (no information, optimal) and greedy")
    print("       (maximum information used badly, not optimal)")


def admissibility_is_what_guarantees_optimality():
    """unit-3.md 3.4: inflate the heuristic and A* becomes faster and WRONG."""
    rows = []
    for label, h in (("straight-line (admissible)", STRAIGHT_LINE),
                     ("straight-line x 2", inflated(2.0)),
                     ("straight-line x 5", inflated(5.0))):
        path, expanded, cost = astar(ROMANIA, START, GOAL, h)
        rows.append((label, expanded, cost, cost == 418, path))

    admissible = rows[0]
    doubled = rows[1]
    assert admissible[1:4] == (6, 418, True)
    assert doubled[1:4] == (4, 450, False), doubled
    assert doubled[1] < admissible[1], "the bad heuristic is FASTER"
    assert doubled[2] > admissible[2], "and WRONG"

    print(f"  {'heuristic':28} {'expanded':>9} {'cost':>6}  optimal?")
    for label, expanded, cost, opt, _ in rows:
        print(f"  {label:28} {expanded:>9} {cost:>6}  {'YES' if opt else 'NO'}")
    print(f"  the inflated heuristic returns: {' -> '.join(doubled[4])}")
    print("       THE INADMISSIBLE HEURISTIC IS FASTER AND WRONG -- 4 nodes")
    print("       instead of 6, and 450 instead of 418. Overestimating makes")
    print("       the node on the optimal path LOOK worse than an alternative,")
    print("       so A* commits to a goal before the better path is explored.")
    print("       The guarantee is gone the moment h(n) > h*(n) anywhere")


def check_the_heuristic_really_is_admissible():
    """h(n) <= h*(n) for every city -- verified against true shortest paths."""
    # True cost from every city to Bucharest, by running UCS backwards.
    true_cost = {}
    frontier = [(0, GOAL)]
    while frontier:
        cost, node = heapq.heappop(frontier)
        if node in true_cost:
            continue
        true_cost[node] = cost
        for nxt, step in ROMANIA[node].items():
            if nxt not in true_cost:
                heapq.heappush(frontier, (cost + step, nxt))

    violations = [(c, STRAIGHT_LINE[c], true_cost[c])
                  for c in true_cost if STRAIGHT_LINE[c] > true_cost[c]]
    assert violations == [], violations
    assert STRAIGHT_LINE[GOAL] == 0, "h(goal) must be 0"
    assert true_cost[START] == 418

    tightest = min(true_cost[c] - STRAIGHT_LINE[c]
                   for c in true_cost if c != GOAL)

    # And the inflated one DOES violate it -- which is the whole point.
    bad = inflated(2.0)
    bad_violations = [c for c in true_cost if bad[c] > true_cost[c]]
    assert len(bad_violations) > 10, len(bad_violations)

    print(f"  checked all {len(true_cost)} cities against their TRUE cost to "
          f"{GOAL}:")
    print(f"    straight-line violations: {len(violations)}  -- admissible")
    print(f"    h(Bucharest) = {STRAIGHT_LINE[GOAL]}")
    print(f"    tightest margin h*(n) - h(n) = {tightest} km")
    print(f"    the x2 heuristic violates admissibility at "
          f"{len(bad_violations)} of {len(true_cost)} cities")
    print("       a straight line can never be longer than a road, which is")
    print("       why this heuristic is admissible BY CONSTRUCTION rather than")
    print("       by luck -- and that is the argument to give in the exam")


def eight_puzzle_heuristics_and_dominance():
    """h2 dominates h1, so A* with h2 expands no more nodes."""
    goal = (1, 2, 3, 4, 5, 6, 7, 8, 0)
    state = (1, 2, 3, 4, 0, 6, 7, 5, 8)

    def misplaced(s):
        return sum(1 for i, v in enumerate(s) if v != 0 and v != goal[i])

    def manhattan(s):
        total = 0
        for i, v in enumerate(s):
            if v == 0:
                continue
            j = goal.index(v)
            total += abs(i // 3 - j // 3) + abs(i % 3 - j % 3)
        return total

    assert misplaced(goal) == 0 and manhattan(goal) == 0
    assert misplaced(state) == 2, misplaced(state)
    assert manhattan(state) == 2, manhattan(state)

    scrambled = (7, 2, 4, 5, 0, 6, 8, 3, 1)
    h1, h2 = misplaced(scrambled), manhattan(scrambled)
    # By hand: six tiles are out of place (7, 4, 5, 8, 3, 1), and their
    # Manhattan distances are 2 + 3 + 1 + 1 + 3 + 4 = 14.
    assert h1 == 6 and h2 == 14, (h1, h2)
    assert h2 >= h1, "h2 DOMINATES h1 -- it is always at least as large"

    print(f"  goal state:      h1 (misplaced) = 0, h2 (Manhattan) = 0")
    print(f"  one tile out:    h1 = {misplaced(state)}, h2 = {manhattan(state)}")
    print(f"  well scrambled:  h1 = {h1}, h2 = {h2}")
    print("       both come from RELAXED problems -- h1 lets a tile move")
    print("       anywhere, h2 lets it move to any adjacent square -- so both")
    print("       are admissible by construction.")
    print("       h2 >= h1 everywhere, so h2 DOMINATES h1 and A* with h2")
    print("       expands no more nodes. Dominance is the right way to compare")
    print("       heuristics, and a stronger claim than 'it was faster'")


def main():
    print("Experiment 12 -- Greedy Best-First search and A*")
    # Step 1: Compare uniform cost, greedy and A*
    ucs_exp, ucs_cost = the_headline_comparison()
    # Step 2: See why greedy goes wrong
    why_greedy_goes_wrong()
    # Step 3: Run A* with h = 0
    astar_with_zero_heuristic_is_ucs(ucs_exp, ucs_cost)
    # Step 4: Inflate the heuristic
    admissibility_is_what_guarantees_optimality()
    # Step 5: Check admissibility at every city
    check_the_heuristic_really_is_admissible()
    # Step 6: Compare the 8-puzzle heuristics
    eight_puzzle_heuristics_and_dominance()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 12_astar.pl:

OUTPUT

?- astar(arad, bucharest, P, C).
P = [arad, sibiu, rimnicu, pitesti, bucharest],
C = 418 ;
P = [arad, sibiu, fagaras, bucharest],
C = 450 ;
P = [arad, zerind, oradea, sibiu, rimnicu, pitesti, bucharest],
C = 575 ;
P = [arad, sibiu, rimnicu, craiova, pitesti, bucharest],
C = 605 ;
P = [arad, zerind, oradea, sibiu, fagaras, bucharest],
C = 607 ;
P = [arad, zerind, oradea, sibiu, rimnicu, craiova, pitesti, bucharest],
C = 762.

The Python check, 04_informed_search.py, for experiment 12:

OUTPUT

Experiment 12 -- Greedy Best-First search and A*
  search                 f(n)          expanded   cost  optimal?
  Uniform cost           g(n)                13    418  YES
  Greedy best-first      h(n)                 4    450  NO
  A*                     g(n) + h(n)          6    418  YES
  greedy:  Arad -> Sibiu -> Fagaras -> Bucharest
  A*:      Arad -> Sibiu -> Rimnicu Vilcea -> Pitesti -> Bucharest
       A* FOUND THE OPTIMAL 418 EXPANDING 6 NODES WHERE UCS NEEDED 13.
       Same answer, less than half the work -- that is why heuristics
       exist, and it is the number to quote
  at Sibiu, greedy compares h only:
    h(Fagaras)        = 176   <- looks closer, so it goes here
    h(Rimnicu Vilcea) = 193
  but the actual routes are:
    via Fagaras       = 450 km
    via Rimnicu       = 418 km   <- 32 km shorter
       Fagaras IS closer as the crow flies and further by road.
       Greedy is short-sighted because it ignores g(n), the cost
       already paid -- which is exactly what A* adds back
  A* with h(n) = 0: expanded 13, cost 418
  uniform cost    : expanded 13, cost 418
       IDENTICAL. A* with a zero heuristic IS uniform cost search.
       A* sits between UCS (no information, optimal) and greedy
       (maximum information used badly, not optimal)
  heuristic                     expanded   cost  optimal?
  straight-line (admissible)           6    418  YES
  straight-line x 2                    4    450  NO
  straight-line x 5                    4    450  NO
  the inflated heuristic returns: Arad -> Sibiu -> Fagaras -> Bucharest
       THE INADMISSIBLE HEURISTIC IS FASTER AND WRONG -- 4 nodes
       instead of 6, and 450 instead of 418. Overestimating makes
       the node on the optimal path LOOK worse than an alternative,
       so A* commits to a goal before the better path is explored.
       The guarantee is gone the moment h(n) > h*(n) anywhere
  checked all 20 cities against their TRUE cost to Bucharest:
    straight-line violations: 0  -- admissible
    h(Bucharest) = 0
    tightest margin h*(n) - h(n) = 1 km
    the x2 heuristic violates admissibility at 18 of 20 cities
       a straight line can never be longer than a road, which is
       why this heuristic is admissible BY CONSTRUCTION rather than
       by luck -- and that is the argument to give in the exam
  goal state:      h1 (misplaced) = 0, h2 (Manhattan) = 0
  one tile out:    h1 = 2, h2 = 2
  well scrambled:  h1 = 6, h2 = 14
       both come from RELAXED problems -- h1 lets a tile move
       anywhere, h2 lets it move to any adjacent square -- so both
       are admissible by construction.
       h2 >= h1 everywhere, so h2 DOMINATES h1 and A* with h2
       expands no more nodes. Dominance is the right way to compare
       heuristics, and a stronger claim than 'it was faster'

In SWI-Prolog, astar/4 gives 418 first; press ; and it goes on to the other routes in order of cost — 450, 575, 605, 607 and 762 — because the queue is always sorted by f = g + h.

WHY IT MATTERS

Why greedy goes wrong, in two numbers. At Sibiu, greedy compares h only:

h(Fagaras)        = 176   <- looks closer, so it goes here
h(Rimnicu Vilcea) = 193

but the actual routes are 450 km via Fagaras and 418 km via Rimnicu — 32 km shorter. Fagaras is closer as the crow flies and further by road. Greedy is short-sighted because it ignores g(n), the cost already paid, which is precisely what A* adds back.

A* WITH h = 0 IS UNIFORM COST SEARCH

A* with h(n) = 0: expanded 13, cost 418
uniform cost    : expanded 13, cost 418

Identical, not merely similar. A* sits between UCS (no information, optimal) and greedy (maximum information, used badly, not optimal).

AN INADMISSIBLE HEURISTIC IS FASTER AND WRONG

Heuristic Expanded Cost Optimal?
straight-line (admissible) 6 418 YES
straight-line × 2 4 450 NO
straight-line × 5 4 450 NO

Overestimating makes the node on the optimal path look worse than an alternative, so A commits to a goal before the better path is explored. The guarantee is gone the moment h(n) > h*(n) anywhere* — and the price here is 2 fewer expansions for 32 extra kilometres.

Admissibility, checked rather than assumed:

checked all 20 cities against their TRUE cost to Bucharest:
  straight-line violations: 0   -- admissible
  h(Bucharest) = 0
  tightest margin h*(n) - h(n) = 1 km
  the x2 heuristic violates admissibility at 18 of 20 cities

A straight line can never be longer than a road, so this heuristic is admissible by construction, not by luck. That is the argument to give in the exam — and the margin of 1 km shows how tight the bound is, which is what makes the heuristic good rather than merely valid.

8-puzzle: h2 dominates h1:

State h1 (misplaced tiles) h2 (Manhattan)
goal 0 0
one tile out 2 2
well scrambled 6 14

Both come from relaxed problems — h1 lets a tile move anywhere, h2 lets it move to any adjacent square — so both are admissible by construction. h2 ≥ h1 everywhere, so h2 dominates, and A with h2 expands no more nodes than A with h1. Dominance is the right way to compare two heuristics, and a far stronger claim than "it was faster on my example".

RESULT

A* finds the optimal 418 km expanding 6 nodes where uniform cost needs 13; greedy expands 4 and finds 450.

Experiment 13 — Map colouring

1. Question

Colour a map so that no two neighbouring regions share a colour, as a constraint satisfaction problem.

2. Aim

Colour Australia's seven regions with three colours by backtracking, and see why two are not enough.

3. Steps

In SWI-Prolog, 13_map_colouring.pl:

  1. State the colours.
  2. Write the constraints.
  3. Ask the query.

The Python check, 05_csp_backtracking.py, for experiments 13 and 14:

  1. Colour the map.
  2. Try two colours.
  3. Order by MRV and LCV.
  4. Solve 8-Queens.
  5. Count the solutions for each board size.

THE COLOURING

Region Colour
WA red
NT green
SA blue
Q red
NSW green
V red
T red
Method Assignments Backtracks
plain backtracking 7 0
MRV + degree 7 0

Zero backtracks either way — and that is worth saying honestly rather than pretending the heuristics saved the day. SA borders every mainland region, so MRV and the degree heuristic both pick it early; but the plain ordering happens to reach it early too, and once SA is fixed every neighbour has only two colours left.

4. Programme

In SWI-Prolog, 13_map_colouring.pl:

% Experiment 13 -- map colouring by backtracking.
%
% Run it: swipl 13_map_colouring.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 05_csp_backtracking.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: State the colours
colour(red). colour(green). colour(blue).

% Australia. Two regions joined by an edge must differ.
% Prolog's backtracking IS the CSP search -- there is no separate algorithm.
% Step 2: Write the constraints
colouring(WA, NT, SA, Q, NSW, V, T) :-
    colour(WA), colour(NT), colour(SA),
    colour(Q), colour(NSW), colour(V), colour(T),
    WA \= NT, WA \= SA,
    NT \= SA, NT \= Q,
    SA \= Q, SA \= NSW, SA \= V,
    Q \= NSW,
    NSW \= V.
    % Tasmania (T) has no land neighbours, so it is unconstrained.

% Step 3: Ask the query
% ?- colouring(WA, NT, SA, Q, NSW, V, T).
%   WA = red, NT = green, SA = blue, Q = red, NSW = green, V = red, T = red
%
% --- WHY THE CONSTRAINT ORDER MATTERS ----------------------------------------
% Written as above, Prolog generates all seven colours and THEN tests -- 3^7 =
% 2187 combinations. Interleaving generate and test prunes far earlier:
%
%   colouring2(WA, NT, SA, Q, NSW, V, T) :-
%       colour(WA), colour(NT), WA \= NT,
%       colour(SA), SA \= WA, SA \= NT,
%       colour(Q),  Q \= NT, Q \= SA,
%       ...
%
% That is FORWARD CHECKING by hand, and it is why the heuristics of unit-3.md
% section 3.8 exist. SA borders every mainland region, so MRV and the degree
% heuristic both choose it first -- and once SA is fixed, every neighbour has
% only two colours left.

The Python check, 05_csp_backtracking.py, for experiments 13 and 14:

"""Experiments 13 and 14 — Map colouring and N-Queens by backtracking.

unit-3.md section 3.8's claim, measured: the MRV heuristic reduces the number
of backtracks, and MRV and LCV correctly pull in opposite directions.
"""
import itertools

# Australia -- Russell & Norvig's map colouring example.
AUSTRALIA = {
    "WA": ["NT", "SA"],
    "NT": ["WA", "SA", "Q"],
    "SA": ["WA", "NT", "Q", "NSW", "V"],
    "Q":  ["NT", "SA", "NSW"],
    "NSW": ["Q", "SA", "V"],
    "V":  ["SA", "NSW"],
    "T":  [],                       # Tasmania -- no land neighbours
}
COLOURS = ["red", "green", "blue"]


def consistent(var, value, assignment, graph):
    return all(assignment.get(n) != value for n in graph[var])


def backtrack(graph, domains, assignment=None, stats=None, use_mrv=False):
    assignment = {} if assignment is None else assignment
    stats = {"assignments": 0, "backtracks": 0} if stats is None else stats

    if len(assignment) == len(graph):
        return assignment, stats

    unassigned = [v for v in graph if v not in assignment]
    if use_mrv:
        # MRV: fewest legal values left. Tie-break on DEGREE.
        def remaining(v):
            return sum(1 for c in domains[v] if consistent(v, c, assignment, graph))
        var = min(unassigned,
                  key=lambda v: (remaining(v),
                                 -sum(1 for n in graph[v] if n not in assignment)))
    else:
        var = unassigned[0]

    for value in domains[var]:
        if consistent(var, value, assignment, graph):
            assignment[var] = value
            stats["assignments"] += 1
            result, stats = backtrack(graph, domains, assignment, stats, use_mrv)
            if result is not None:
                return result, stats
            del assignment[var]
            stats["backtracks"] += 1
    return None, stats


def map_colouring():
    domains = {v: list(COLOURS) for v in AUSTRALIA}
    plain, plain_stats = backtrack(AUSTRALIA, domains)
    mrv, mrv_stats = backtrack(AUSTRALIA, domains, use_mrv=True)

    assert plain is not None and mrv is not None
    assert len(plain) == len(AUSTRALIA) == 7

    # Verify the solution really is consistent -- the definition of a CSP.
    for region, neighbours in AUSTRALIA.items():
        for n in neighbours:
            assert plain[region] != plain[n], (region, n)
            assert mrv[region] != mrv[n], (region, n)

    assert len({plain[r] for r in AUSTRALIA}) <= 3, "three colours suffice"

    print("  Australia, 3 colours, 7 regions:")
    for region in ("WA", "NT", "SA", "Q", "NSW", "V", "T"):
        print(f"    {region:4} {plain[region]}")
    print(f"  plain backtracking: {plain_stats['assignments']} assignments, "
          f"{plain_stats['backtracks']} backtracks")
    print(f"  with MRV + degree : {mrv_stats['assignments']} assignments, "
          f"{mrv_stats['backtracks']} backtracks")
    print("       SA borders every mainland region, so MRV and the degree")
    print("       heuristic both pick it early -- and once SA is fixed every")
    print("       neighbour has only two colours left")


def three_colours_are_necessary():
    """Two are not enough, and the search proves it rather than asserting it."""
    two = {v: ["red", "green"] for v in AUSTRALIA}
    result, stats = backtrack(AUSTRALIA, two)

    assert result is None, "no 2-colouring exists"
    assert stats["backtracks"] > 0

    # WA, NT and SA form a triangle -- three mutually adjacent regions.
    triangle = ["WA", "NT", "SA"]
    for a, b in itertools.combinations(triangle, 2):
        assert b in AUSTRALIA[a], (a, b)

    print(f"  with only 2 colours: no solution, after "
          f"{stats['backtracks']} backtracks")
    print(f"  the reason: {triangle} are MUTUALLY adjacent -- a triangle needs")
    print("       three colours. The search discovers this by exhausting every")
    print("       possibility, which is what 'no solution' means in a CSP")


def mrv_and_lcv_pull_opposite_ways():
    """The trick question from unit-3.md 3.8, made concrete."""
    assignment = {"WA": "red"}
    domains = {v: list(COLOURS) for v in AUSTRALIA}

    def remaining(v):
        return [c for c in domains[v] if consistent(v, c, assignment, AUSTRALIA)]

    counts = {v: len(remaining(v)) for v in AUSTRALIA if v not in assignment}
    mrv_choice = min(counts, key=lambda v: counts[v])

    assert counts["NT"] == 2 and counts["SA"] == 2, counts
    assert counts["T"] == 3 and counts["Q"] == 3
    assert counts[mrv_choice] == 2

    # LCV, for the chosen variable: which value rules out fewest neighbour options?
    def rules_out(var, value):
        total = 0
        for n in AUSTRALIA[var]:
            if n in assignment:
                continue
            total += sum(1 for c in domains[n]
                         if c == value and consistent(n, c, assignment, AUSTRALIA))
        return total

    lcv_order = sorted(remaining(mrv_choice), key=lambda c: rules_out(mrv_choice, c))
    assert len(lcv_order) == 2

    print(f"  after WA = red, legal values remaining per region:")
    for v in sorted(counts, key=lambda v: counts[v]):
        print(f"    {v:4} {counts[v]}")
    print(f"  MRV picks {mrv_choice} (fewest options) -- FAIL FAST")
    print(f"  LCV then orders its values {lcv_order} -- least constraining first")
    print("       MRV chooses the VARIABLE most likely to fail, because you")
    print("       want to discover a dead end now. LCV chooses the VALUE least")
    print("       likely to fail, because once committed you want it to survive.")
    print("       VARIABLES: FAIL FAST. VALUES: FAIL LATE")


def n_queens(n=8):
    """Experiment 14 -- backtracking, and the known solution counts."""
    solutions = []
    placements = {"count": 0}

    def safe(cols, row):
        c = len(cols)
        return all(r != row and abs(r - row) != c - i
                   for i, r in enumerate(cols))

    def place(cols):
        if len(cols) == n:
            solutions.append(tuple(cols))
            return
        for row in range(n):
            if safe(cols, row):
                cols.append(row)
                placements["count"] += 1
                place(cols)
                cols.pop()

    place([])

    # The published solution counts for N-Queens.
    known = {4: 2, 5: 10, 6: 4, 7: 40, 8: 92}
    assert len(solutions) == known[n], (n, len(solutions))

    first = solutions[0]
    assert len(set(first)) == n, "no two queens share a row"
    diagonals_ok = all(abs(first[i] - first[j]) != j - i
                       for i in range(n) for j in range(i + 1, n))
    assert diagonals_ok, first

    print(f"  {n}-Queens: {len(solutions)} distinct solutions, found after "
          f"{placements['count']} placements")
    print(f"  the first solution, as a row per column: {first}")
    board = "\n".join("    " + " ".join("Q" if first[c] == r else "."
                                        for c in range(n)) for r in range(n))
    print(board)
    return solutions


def n_queens_counts_across_n():
    """The counts are famously irregular -- 6 has FEWER solutions than 5."""
    counts = {}
    for n in range(4, 9):
        found = []

        def safe(cols, row):
            c = len(cols)
            return all(r != row and abs(r - row) != c - i
                       for i, r in enumerate(cols))

        def place(cols, limit=n):
            if len(cols) == limit:
                found.append(tuple(cols))
                return
            for row in range(limit):
                if safe(cols, row):
                    cols.append(row)
                    place(cols, limit)
                    cols.pop()

        place([])
        counts[n] = len(found)

    assert counts == {4: 2, 5: 10, 6: 4, 7: 40, 8: 92}, counts
    assert counts[6] < counts[5], "6 has FEWER solutions than 5"

    print("    n : solutions")
    for n, c in counts.items():
        print(f"    {n} : {c:>3}")
    print("       the counts are irregular -- n=6 has FOUR solutions where")
    print("       n=5 has ten. There is no formula; they are computed by")
    print("       search, which is why N-Queens is a search problem at all")


def main():
    print("Experiments 13 and 14 -- CSP: map colouring and N-Queens")
    # Step 1: Colour the map
    map_colouring()
    # Step 2: Try two colours
    three_colours_are_necessary()
    # Step 3: Order by MRV and LCV
    mrv_and_lcv_pull_opposite_ways()
    # Step 4: Solve 8-Queens
    n_queens(8)
    # Step 5: Count the solutions for each board size
    print("  solution counts by board size:")
    n_queens_counts_across_n()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 13_map_colouring.pl:

OUTPUT

?- colouring(WA, NT, SA, Q, NSW, V, T).
WA = red,
NT = green,
SA = blue,
Q = red,
NSW = green,
V = red,
T = red.
% the first of 18 answers

The Python check, 05_csp_backtracking.py, for experiments 13 and 14:

OUTPUT

Experiments 13 and 14 -- CSP: map colouring and N-Queens
  Australia, 3 colours, 7 regions:
    WA   red
    NT   green
    SA   blue
    Q    red
    NSW  green
    V    red
    T    red
  plain backtracking: 7 assignments, 0 backtracks
  with MRV + degree : 7 assignments, 0 backtracks
       SA borders every mainland region, so MRV and the degree
       heuristic both pick it early -- and once SA is fixed every
       neighbour has only two colours left
  with only 2 colours: no solution, after 4 backtracks
  the reason: ['WA', 'NT', 'SA'] are MUTUALLY adjacent -- a triangle needs
       three colours. The search discovers this by exhausting every
       possibility, which is what 'no solution' means in a CSP
  after WA = red, legal values remaining per region:
    NT   2
    SA   2
    Q    3
    NSW  3
    V    3
    T    3
  MRV picks NT (fewest options) -- FAIL FAST
  LCV then orders its values ['green', 'blue'] -- least constraining first
       MRV chooses the VARIABLE most likely to fail, because you
       want to discover a dead end now. LCV chooses the VALUE least
       likely to fail, because once committed you want it to survive.
       VARIABLES: FAIL FAST. VALUES: FAIL LATE
  8-Queens: 92 distinct solutions, found after 2056 placements
  the first solution, as a row per column: (0, 4, 7, 5, 2, 6, 1, 3)
    Q . . . . . . .
    . . . . . . Q .
    . . . . Q . . .
    . . . . . . . Q
    . Q . . . . . .
    . . . Q . . . .
    . . . . . Q . .
    . . Q . . . . .
  solution counts by board size:
    n : solutions
    4 :   2
    5 :  10
    6 :   4
    7 :  40
    8 :  92
       the counts are irregular -- n=6 has FOUR solutions where
       n=5 has ten. There is no formula; they are computed by
       search, which is why N-Queens is a search problem at all

SWI-Prolog's first answer is the colouring above; there are 18 in all, three choices for Tasmania, which touches nothing, times six for the mainland.

With only two colours: no solution, after 4 backtracks. The reason is structural: WA, NT and SA are mutually adjacent — a triangle needs three colours. The search discovers this by exhausting every possibility, which is what "no solution" means in a CSP.

MRV AND LCV PULL IN OPPOSITE DIRECTIONS, AND THAT IS CORRECT

After WA = red, the legal values remaining:

Region Values left
NT 2
SA 2
Q, NSW, V, T 3

MRV picks NT (fewest options). LCV then orders its values ['green', 'blue'], least constraining first.

NOTE

Variables: fail fast. Values: fail late. MRV chooses the variable most likely to fail, because you want to discover a dead end now. LCV chooses the value least likely to fail, because once you have committed you want it to survive.

RESULT

WA red, NT green, SA blue, Q red, NSW green, V red, T red — the first of 18 colourings; with two colours there is none.

Experiment 14 — N-Queens

1. Question

Place N queens on an N × N board so that no two attack each other.

2. Aim

Place the queens as a permutation, check only the diagonals, and count the solutions.

3. Steps

  1. Place the queens as a permutation.
  2. Check the diagonals.
  3. Ask the queries.

THE BOARD

92 distinct solutions, found after 2,056 placements
the first, as a row per column: (0, 4, 7, 5, 2, 6, 1, 3)

  Q . . . . . . .
  . . . . . . Q .
  . . . . Q . . .
  . . . . . . . Q
  . Q . . . . . .
  . . . Q . . . .
  . . . . . Q . .
  . . Q . . . . .

The Python check counts rows from 0; SWI-Prolog's first answer, [1, 5, 8, 6, 3, 7, 2, 4], is the same board counted from 1.

4. Programme

% Experiment 14 -- N-Queens by backtracking.
%
% Run it: swipl 14_n_queens.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 05_csp_backtracking.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: Place the queens as a permutation
queens(N, Qs) :- numlist(1, N, Ns), permutation(Ns, Qs), safe(Qs).

% Step 2: Check the diagonals
safe([]).
safe([Q|Qs]) :- no_attack(Q, Qs, 1), safe(Qs).

no_attack(_, [], _).
no_attack(Q, [Q1|Qs], D) :-
    Q =\= Q1 + D,            % not on the down diagonal
    Q =\= Q1 - D,            % not on the up diagonal
    D1 is D + 1,
    no_attack(Q, Qs, D1).

% Step 3: Ask the queries
% ?- queens(8, Qs).
%   Qs = [1, 5, 8, 6, 3, 7, 2, 4]  (and 91 more)
% ?- findall(Q, queens(8, Q), L), length(L, N).
%   N = 92  (and L, which the toplevel abbreviates with |...)
%
% --- WHY permutation/2 IS THE RIGHT REPRESENTATION ---------------------------
% Qs is a list of ROWS, one per column. Using a permutation of 1..N means no
% two queens can share a row OR a column BY CONSTRUCTION -- only the diagonals
% need checking. That reduces the space from N^N (16.7 million for N=8) to N!
% (40,320), before any search happens.
%
% CHOOSING THE REPRESENTATION IS MOST OF THE WORK. This is the same point as
% moving the blank rather than the tiles in the 8-puzzle (unit-2.md 2.1).
%
% Solution counts are famously irregular: 2, 10, 4, 40, 92 for N = 4..8.
% N=6 has FEWER solutions than N=5. There is no formula.

5. Execution and Results

OUTPUT

?- queens(8, Qs).
Qs = [1, 5, 8, 6, 3, 7, 2, 4].
% the first of 92 answers

?- findall(Q, queens(8, Q), L), length(L, N).
L = [[1, 5, 8, 6, 3, 7, 2, 4], [1, 6, 8, 3, 7, 4, 2|...], [1, 7, 4, 6, 8, 2|...], [1, 7, 5, 8, 2|...], [2, 4, 6, 8|...], [2, 5, 7|...], [2, 5|...], [2|...], [...|...]|...],
N = 92.
n Solutions
4 2
5 10
6 4
7 40
8 92

The counts are irregular — n = 6 has four solutions where n = 5 has ten. There is no formula; they are computed by search. That is why N-Queens is a search problem at all, and it is a better answer than "92" on its own.

SWI-Prolog prints the list of all 92 abbreviated, with |...: the toplevel shortens long answers, and N = 92 is the part to read.

The Python check for this experiment, 05_csp_backtracking.py, is shown in full under Experiment 13, with what it printed.

RESULT

queens(8, Qs) gives [1, 5, 8, 6, 3, 7, 2, 4] first, and there are 92.

Experiment 15 — Facts and rules in propositional and first-order logic

1. Question

Encode facts and rules in propositional and first-order logic.

2. Aim

Encode a propositional rule and a first-order one, and check truth tables, validity and entailment.

3. Steps

In SWI-Prolog, 15_logic.pl:

  1. Encode a propositional fact and rule.
  2. Encode first-order facts and one rule.

The Python check, 06_logic_and_chaining.py, for experiments 15–17:

  1. Experiment 15: the truth tables.
  2. Show P ⇒ Q is ¬P ∨ Q.
  3. Test validity, satisfiability and entailment.
  4. Check modus ponens and modus tollens.
  5. Experiment 16: chain forward.
  6. Chain backward, by resolution.
  7. Choose between them.
  8. Experiment 17: run the expert system.
  9. See the closed world.

THE TRUTH TABLE

P Q ¬P P∧Q P∨Q P⇒Q P⇔Q
F F T F F T T
F T T F T T F
T F F F T F F
T T F T T T T

P⇒Q is true whenever P is false. It is not causation — it says only there is no case where P holds and Q fails, which is exactly ¬P ∨ Q, verified over all 4 models. That equivalence is step 2 of the CNF procedure, and everything in resolution depends on it.

4. Programme

In SWI-Prolog, 15_logic.pl:

% Experiment 15 -- encoding facts and rules in propositional and first-order logic.
%
% Run it: swipl 15_logic.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 06_logic_and_chaining.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% The propositional half (truth tables, validity, entailment) is computed
% exhaustively in 06_logic_and_chaining.py.

:- encoding(utf8).
% [Corrected: the comments use logic symbols (∀ ⇒ ∧), and under a non-UTF-8
% locale SWI-Prolog warned "Illegal multibyte Sequence" loading the file.]

% Step 1: Encode a propositional fact and rule
% "If it rains, the ground is wet." "It is raining."
raining.
wet_ground :- raining.
% ?- wet_ground.   % true -- modus ponens, mechanised

% To say the same about SNOW you need a whole new symbol and a new rule. That
% is the limitation FOL removes.

% Step 2: Encode first-order facts and one rule
student(asha).   student(ravi).   student(meena).
studies(asha).   studies(meena).
teacher(rao).

% "All students who study, pass."   ∀x Student(x) ∧ Studies(x) ⇒ Passes(x)
passes(X) :- student(X), studies(X).

% ?- passes(asha).      % true
% ?- passes(ravi).      % false -- ravi does not study
% ?- findall(X, passes(X), L).   % L = [asha, meena]

% --- THE QUANTIFIER PAIRING RULE ---------------------------------------------
%   ∀ GOES WITH ⇒ .    ∃ GOES WITH ∧ .
%
%   ∀x Student(x) ⇒ Passes(x)     "all students pass"           CORRECT
%   ∀x Student(x) ∧ Passes(x)     "EVERYTHING is a student
%                                  and passes"                   WRONG
%   ∃x Student(x) ∧ Passes(x)     "some student passes"          CORRECT
%   ∃x Student(x) ⇒ Passes(x)     vacuously true as soon as
%                                  anything is not a student     WRONG
%
% A Prolog RULE is a universally quantified implication with the head as the
% consequent, so 'passes(X) :- student(X), studies(X).' IS
% ∀x (Student(x) ∧ Studies(x)) ⇒ Passes(x). The pairing is built into the
% syntax, which is why Prolog makes this error hard to commit.

% --- NESTED QUANTIFIERS, which is the other trap -----------------------------
% ∀x ∃y Loves(x, y)   -- everybody loves SOMEONE (possibly different people)
% ∃y ∀x Loves(x, y)   -- there is ONE person everybody loves
% NOT the same claim. Skolemising the first gives Loves(x, f(x)) -- a FUNCTION
% of x -- and the second gives Loves(x, s1) with a CONSTANT.

The Python check, 06_logic_and_chaining.py, for experiments 15–17:

"""Experiments 15, 16 and 17 — Logic, chaining, and an expert system.

Experiments 16 and 17 are executed as REAL LOGIC PROGRAMS through pytholog:
the rule base is resolved, not simulated. Experiment 15's propositional
truth tables are computed exhaustively.
"""
import itertools

import pytholog as pl


# --- experiment 15: propositional logic --------------------------------------

def truth_tables():
    """Every connective, over every model. unit-4.md section 4.3's table."""
    rows = []
    for p, q in itertools.product([False, True], repeat=2):
        rows.append((p, q, not p, p and q, p or q, (not p) or q, p == q))

    assert len(rows) == 4
    # P => Q is FALSE only when P is true and Q is false.
    implications = {(p, q): imp for p, q, _, _, _, imp, _ in rows}
    assert implications[(True, False)] is False
    assert implications[(False, False)] is True, "vacuously true"
    assert implications[(False, True)] is True, "vacuously true"
    assert implications[(True, True)] is True

    print("     P      Q     ¬P    P∧Q   P∨Q   P⇒Q   P⇔Q")
    for p, q, np_, pq, poq, imp, iff in rows:
        f = lambda b: " T " if b else " F "
        print(f"    {f(p)}   {f(q)}   {f(np_)}  {f(pq)}  {f(poq)}  "
              f"{f(imp)}  {f(iff)}")
    print("       P⇒Q is TRUE whenever P is false. It is not causation --")
    print("       it says only 'there is no case where P holds and Q fails',")
    print("       which is exactly ¬P ∨ Q")


def implication_equals_not_p_or_q():
    """The equivalence every CNF conversion starts with."""
    for p, q in itertools.product([False, True], repeat=2):
        assert ((not p) or q) == (not (p and not q))
    print("  P⇒Q ≡ ¬P ∨ Q, checked over all 4 models")
    print("       this is step 2 of the CNF procedure, and everything in")
    print("       resolution depends on it")


def validity_satisfiability_entailment():
    """The three notions, computed over all models of three symbols."""
    symbols = ["P", "Q", "R"]
    models = [dict(zip(symbols, vals))
              for vals in itertools.product([False, True], repeat=3)]
    assert len(models) == 8

    def tautology(m):
        return m["P"] or not m["P"]

    def contradiction(m):
        return m["P"] and not m["P"]

    def satisfiable_only(m):
        return m["P"] and m["Q"]

    assert all(tautology(m) for m in models), "VALID -- true in every model"
    assert not any(contradiction(m) for m in models), "UNSATISFIABLE"
    assert sum(satisfiable_only(m) for m in models) == 2, "SATISFIABLE"

    # Entailment: {P, P⇒Q} ⊨ Q. Check every model where the KB holds.
    kb_models = [m for m in models if m["P"] and ((not m["P"]) or m["Q"])]
    assert len(kb_models) == 2
    assert all(m["Q"] for m in kb_models), "Q is true in EVERY model of the KB"

    # And the equivalent formulation: KB ∧ ¬Q is unsatisfiable.
    assert not any(m["P"] and ((not m["P"]) or m["Q"]) and not m["Q"]
                   for m in models)

    print(f"  over {len(models)} models of P, Q, R:")
    print(f"    P ∨ ¬P     true in 8/8  -> VALID (a tautology)")
    print(f"    P ∧ ¬P     true in 0/8  -> UNSATISFIABLE")
    print(f"    P ∧ Q      true in 2/8  -> SATISFIABLE")
    print(f"  entailment: {{P, P⇒Q}} has {len(kb_models)} models, and Q holds in")
    print(f"    all of them, so {{P, P⇒Q}} ⊨ Q")
    print("       and equivalently KB ∧ ¬Q has ZERO models -- unsatisfiable.")
    print("       THAT second form is what resolution mechanises")


def modus_ponens_and_tollens():
    for p, q in itertools.product([False, True], repeat=2):
        implies = (not p) or q
        if implies and p:
            assert q, "modus ponens"
        if implies and not q:
            assert not p, "modus tollens"
    print("  modus ponens  (P⇒Q, P  ⊢ Q) and")
    print("  modus tollens (P⇒Q, ¬Q ⊢ ¬P) verified over all 4 models")
    print("       affirming the consequent (P⇒Q, Q ⊢ P) is NOT valid, and")
    print("       the truth table shows why: row (F, T) has P false and Q true")


# --- experiments 16 and 17: chaining, as a real logic program ----------------

# pytholog needs predicates WITH ARGUMENTS -- a 0-arity proposition such as
# a bare "a" raises IndexError inside its parser. So the same propositional
# rule base is written with a dummy argument, which changes nothing logically.
CHAIN_RULES = [
    "a(x)", "b(x)",
    "c(X) :- a(X), b(X)",
    "d(X) :- c(X)",
    "e(X) :- d(X), a(X)",
]


def forward_chaining():
    """Data-driven: start from facts, apply every rule, repeat."""
    facts = {"a", "b"}
    rules = [(("a", "b"), "c"), (("c",), "d"), (("d", "a"), "e")]

    passes = []
    changed = True
    while changed:
        changed = False
        for premises, conclusion in rules:
            if all(p in facts for p in premises) and conclusion not in facts:
                facts.add(conclusion)
                passes.append(conclusion)
                changed = True

    assert facts == {"a", "b", "c", "d", "e"}
    assert passes == ["c", "d", "e"], passes

    print(f"  facts {{a, b}} + 3 rules")
    print(f"  derived, in order: {passes}")
    print(f"  final knowledge base: {sorted(facts)}")
    print("       forward chaining derives EVERYTHING derivable, whether or")
    print("       not it was wanted. Data-driven")


def backward_chaining_through_resolution():
    """Goal-driven -- and this one is a REAL logic program."""
    kb = pl.KnowledgeBase("chain")
    kb(CHAIN_RULES)

    for goal in ("c(x)", "d(x)", "e(x)"):
        result = kb.query(pl.Expr(goal))
        assert result == ["Yes"], (goal, result)

    # A goal whose predicate is not in the KB at all raises rather than
    # answering "No". So does SWI-Prolog, with an existence error (16_chaining.pl
    # asks z(X) to show it). [Corrected: this called the raise an engine
    # limitation, and said SWI-Prolog answers 'false'; it raises too.]
    try:
        kb.query(pl.Expr("z(x)"))
        raise SystemExit("expected pytholog to raise on an unknown predicate")
    except TypeError:
        pass

    print("  the same rule base, queried backwards through SLD resolution:")
    for goal in ("c(x)", "d(x)", "e(x)"):
        print(f"    ?- {goal}.   -> Yes")
    print("    ?- z(x).     -> TypeError (pytholog raises on an unknown")
    print("                    predicate; SWI-Prolog raises an existence error)")
    print("       backward chaining proves ONLY what was asked. To prove e it")
    print("       needs d, which needs c, which needs a and b -- and it never")
    print("       derives anything outside that chain. Goal-driven")


def which_chaining_to_use():
    scenarios = [
        ("A sensor reading arrives; what does it imply?",
         "FORWARD", "few facts, many possible conclusions"),
        ("Does this patient have malaria?",
         "BACKWARD", "many facts, ONE question"),
        ("Monitoring a plant for alarm conditions",
         "FORWARD", "you want every consequence, continuously"),
        ("Diagnosing why a car will not start",
         "BACKWARD", "test only the hypotheses that matter"),
    ]
    assert sum(1 for _, d, _ in scenarios if d == "FORWARD") == 2

    print("    scenario                                  use        because")
    for scenario, direction, why in scenarios:
        print(f"    {scenario:41} {direction:10} {why}")
    print("       the deciding question is: how many possible conclusions, and")
    print("       how many facts? Prolog is BACKWARD chaining with depth-first")
    print("       search, which is why a left-recursive rule loops for ever")


# --- experiment 17: a rule-based expert system -------------------------------

MEDICAL_KB = [
    # facts about one patient, in working memory
    "fever(patient)",
    "cough(patient)",
    "fatigue(patient)",
    # rules -- the knowledge base proper
    "viral(X) :- fever(X), cough(X)",
    "flu(X) :- viral(X), fatigue(X)",
    "bacterial(X) :- fever(X), rash(X)",
    "rest_advised(X) :- flu(X)",
]


def expert_system():
    """A small diagnostic system, resolved rather than simulated."""
    kb = pl.KnowledgeBase("medical")
    kb(MEDICAL_KB)

    assert kb.query(pl.Expr("viral(patient)")) == ["Yes"]
    assert kb.query(pl.Expr("flu(patient)")) == ["Yes"]
    assert kb.query(pl.Expr("rest_advised(patient)")) == ["Yes"]
    # No rash was recorded, so bacterial cannot be derived.
    assert kb.query(pl.Expr("bacterial(patient)")) != ["Yes"]

    print("  working memory: fever, cough, fatigue")
    for goal in ("viral(patient)", "flu(patient)", "rest_advised(patient)"):
        print(f"    ?- {goal:22} -> Yes")
    print(f"    ?- bacterial(patient)     -> not derivable (no rash recorded)")
    print()
    print("  the EXPLANATION FACILITY, reconstructed from the derivation:")
    print("    HOW did you conclude rest_advised(patient)?")
    print("      by rule  rest_advised(X) :- flu(X)")
    print("      flu(patient)     by  flu(X) :- viral(X), fatigue(X)")
    print("        viral(patient) by  viral(X) :- fever(X), cough(X)")
    print("          fever(patient)   -- a fact in working memory")
    print("          cough(patient)   -- a fact in working memory")
    print("        fatigue(patient)   -- a fact in working memory")
    print("       THAT is what an expert system has and a neural network does")
    print("       not. The chain IS the explanation, and it falls out of the")
    print("       proof for free")


def the_closed_world_assumption_shows_here():
    """'Not derivable' is not the same as 'false'."""
    kb = pl.KnowledgeBase("medical2")
    kb(MEDICAL_KB)

    result = kb.query(pl.Expr("bacterial(patient)"))
    assert result != ["Yes"]

    # Add the missing symptom and the conclusion appears.
    kb2 = pl.KnowledgeBase("medical3")
    kb2(MEDICAL_KB + ["rash(patient)"])
    assert kb2.query(pl.Expr("bacterial(patient)")) == ["Yes"]

    print(f"  without rash: bacterial(patient) -> {result}")
    print(f"  add rash(patient): bacterial(patient) -> ['Yes']")
    print("       the first answer means 'I cannot prove it', NOT 'it is false'.")
    print("       An expert system under the closed world assumption reports")
    print("       the same thing for 'definitely not' and 'I was never told' --")
    print("       which is exactly the BRITTLENESS of unit-5.md section 5.1")


def main():
    print("Experiments 15-17 -- Logic, chaining and an expert system")
    # Step 1: Experiment 15: the truth tables
    print("  experiment 15 -- propositional logic:")
    truth_tables()
    # Step 2: Show P ⇒ Q is ¬P ∨ Q
    implication_equals_not_p_or_q()
    # Step 3: Test validity, satisfiability and entailment
    validity_satisfiability_entailment()
    # Step 4: Check modus ponens and modus tollens
    modus_ponens_and_tollens()
    # Step 5: Experiment 16: chain forward
    print("  experiment 16 -- forward chaining:")
    forward_chaining()
    # Step 6: Chain backward, by resolution
    print("  experiment 16 -- backward chaining (real resolution):")
    backward_chaining_through_resolution()
    # Step 7: Choose between them
    which_chaining_to_use()
    # Step 8: Experiment 17: run the expert system
    print("  experiment 17 -- a rule-based expert system:")
    expert_system()
    # Step 9: See the closed world
    the_closed_world_assumption_shows_here()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 15_logic.pl:

OUTPUT

?- wet_ground.
true.

?- passes(asha).
true.

?- passes(ravi).
false.

?- findall(X, passes(X), L).
L = [asha, meena].

The Python check, 06_logic_and_chaining.py, for experiments 15–17:

OUTPUT

Experiments 15-17 -- Logic, chaining and an expert system
  experiment 15 -- propositional logic:
     P      Q     ¬P    P∧Q   P∨Q   P⇒Q   P⇔Q
     F     F     T    F    F    T    T
     F     T     T    F    T    T    F
     T     F     F    F    T    F    F
     T     T     F    T    T    T    T
       P⇒Q is TRUE whenever P is false. It is not causation --
       it says only 'there is no case where P holds and Q fails',
       which is exactly ¬P ∨ Q
  P⇒Q ≡ ¬P ∨ Q, checked over all 4 models
       this is step 2 of the CNF procedure, and everything in
       resolution depends on it
  over 8 models of P, Q, R:
    P ∨ ¬P     true in 8/8  -> VALID (a tautology)
    P ∧ ¬P     true in 0/8  -> UNSATISFIABLE
    P ∧ Q      true in 2/8  -> SATISFIABLE
  entailment: {P, P⇒Q} has 2 models, and Q holds in
    all of them, so {P, P⇒Q} ⊨ Q
       and equivalently KB ∧ ¬Q has ZERO models -- unsatisfiable.
       THAT second form is what resolution mechanises
  modus ponens  (P⇒Q, P  ⊢ Q) and
  modus tollens (P⇒Q, ¬Q ⊢ ¬P) verified over all 4 models
       affirming the consequent (P⇒Q, Q ⊢ P) is NOT valid, and
       the truth table shows why: row (F, T) has P false and Q true
  experiment 16 -- forward chaining:
  facts {a, b} + 3 rules
  derived, in order: ['c', 'd', 'e']
  final knowledge base: ['a', 'b', 'c', 'd', 'e']
       forward chaining derives EVERYTHING derivable, whether or
       not it was wanted. Data-driven
  experiment 16 -- backward chaining (real resolution):
  the same rule base, queried backwards through SLD resolution:
    ?- c(x).   -> Yes
    ?- d(x).   -> Yes
    ?- e(x).   -> Yes
    ?- z(x).     -> TypeError (pytholog raises on an unknown
                    predicate; SWI-Prolog raises an existence error)
       backward chaining proves ONLY what was asked. To prove e it
       needs d, which needs c, which needs a and b -- and it never
       derives anything outside that chain. Goal-driven
    scenario                                  use        because
    A sensor reading arrives; what does it imply? FORWARD    few facts, many possible conclusions
    Does this patient have malaria?           BACKWARD   many facts, ONE question
    Monitoring a plant for alarm conditions   FORWARD    you want every consequence, continuously
    Diagnosing why a car will not start       BACKWARD   test only the hypotheses that matter
       the deciding question is: how many possible conclusions, and
       how many facts? Prolog is BACKWARD chaining with depth-first
       search, which is why a left-recursive rule loops for ever
  experiment 17 -- a rule-based expert system:
  working memory: fever, cough, fatigue
    ?- viral(patient)         -> Yes
    ?- flu(patient)           -> Yes
    ?- rest_advised(patient)  -> Yes
    ?- bacterial(patient)     -> not derivable (no rash recorded)

  the EXPLANATION FACILITY, reconstructed from the derivation:
    HOW did you conclude rest_advised(patient)?
      by rule  rest_advised(X) :- flu(X)
      flu(patient)     by  flu(X) :- viral(X), fatigue(X)
        viral(patient) by  viral(X) :- fever(X), cough(X)
          fever(patient)   -- a fact in working memory
          cough(patient)   -- a fact in working memory
        fatigue(patient)   -- a fact in working memory
       THAT is what an expert system has and a neural network does
       not. The chain IS the explanation, and it falls out of the
       proof for free
  without rash: bacterial(patient) -> ['No']
  add rash(patient): bacterial(patient) -> ['Yes']
       the first answer means 'I cannot prove it', NOT 'it is false'.
       An expert system under the closed world assumption reports
       the same thing for 'definitely not' and 'I was never told' --
       which is exactly the BRITTLENESS of unit-5.md section 5.1

Over the 8 models of P, Q, R:

Sentence True in Verdict
P ∨ ¬P 8/8 VALID (tautology)
P ∧ ¬P 0/8 UNSATISFIABLE
P ∧ Q 2/8 SATISFIABLE

Entailment, measured: {P, P⇒Q} has 2 models, and Q holds in all of them, so {P, P⇒Q} ⊨ Q. Equivalently KB ∧ ¬Q has zero models — and that second form is what resolution mechanises.

Modus ponens and modus tollens were verified over all 4 models. Affirming the consequent (P⇒Q, Q ⊢ P) is not valid, and the table shows why in one cell: row (F, T) has P false and Q true.

Corrected: the file's comments use logic symbols (∀ ⇒ ∧), and under a non-UTF-8 locale SWI-Prolog warned "Illegal multibyte Sequence" on loading it. It now begins with :- encoding(utf8).

RESULT

wet_ground is proved by modus ponens; passes/1 holds for asha and meena; {P, P⇒Q} ⊨ Q.

Experiment 16 — Forward and backward chaining

1. Question

Implement forward and backward chaining.

2. Aim

Chain backward, as Prolog does, and forward, written out with assertz/1, from the same rules.

3. Steps

  1. State the rule base.
  2. Chain backward.
  3. Chain forward.
  4. Ask for a goal nothing defines.

THE TWO DIRECTIONS

Forward, from {a, b} and 3 rules:

derived, in order: ['c', 'd', 'e']
final KB:          ['a', 'b', 'c', 'd', 'e']

Backward, the same rule base: to prove e it needs d, which needs c, which needs a and b — and it never derives anything outside that chain. Forward chaining derives everything derivable whether or not it was wanted.

4. Programme

% Experiment 16 -- forward and backward chaining.
%
% Run it: swipl 16_chaining.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 06_logic_and_chaining.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]

% Step 1: State the rule base
fact(a).
fact(b).

c(X) :- fact(a), fact(b), X = derived_c.
d(X) :- c(_), X = derived_d.
e(X) :- d(_), fact(a), X = derived_e.

% Step 2: Chain backward
% ?- e(X).
%   To prove e, it needs d; to prove d, it needs c; to prove c, it needs the
%   facts a and b. It proves ONLY what the goal requires, and touches nothing
%   else. Goal-driven, depth-first.

% Step 3: Chain forward
% Prolog does not do it, so you drive it with assertz/1. The same three rules,
% written as data: rule(Premises, Conclusion).
:- dynamic known/1.

rule([a, b], c).
rule([c], d).
rule([d, a], e).

forward :-
    (   rule(Premises, Conclusion),
        all_known(Premises),
        \+ known(Conclusion)
    ->  assertz(known(Conclusion)),
        format("derived ~w~n", [Conclusion]),
        forward
    ;   true
    ).

all_known([]).
all_known([P|Ps]) :- known(P), all_known(Ps).

% start from the facts a and b
start :- retractall(known(_)), assertz(known(a)), assertz(known(b)).

% ?- start, forward, findall(K, known(K), KB).
%   derived c, then d, then e -- KB = [a, b, c, d, e]
%
% It loops until no rule adds anything new, deriving EVERYTHING derivable --
% including whatever nobody asked for. Data-driven.
% [Corrected: forward/0 was only a comment, so forward chaining could not be
% run; it is now code, and the query above runs it.]

% Step 4: Ask for a goal nothing defines
% ?- z(X).
%   SWI-Prolog does not answer false: it raises an existence error, because
%   no clause for z/1 exists at all. (unknown/2 can make it fail quietly.)

% --- WHICH TO USE ------------------------------------------------------------
% Few facts, many possible conclusions  -> FORWARD.  A sensor reading arrives;
%                                          work out everything it implies.
% Many facts, ONE question              -> BACKWARD. "Does this patient have
%                                          malaria?" -- do not derive every
%                                          other disease first.
%
% And the practical consequence of Prolog's DEPTH-FIRST backward chaining:
% a LEFT-RECURSIVE rule loops for ever.
%
%   anc(X, Y) :- anc(X, Z), parent(Z, Y).     % INFINITE LOOP
%   anc(X, Y) :- parent(X, Y).
%
% Clause order matters in Prolog and does NOT matter in logic. That gap is the
% price of an efficient, incomplete proof procedure.

5. Execution and Results

OUTPUT

?- e(X).
X = derived_e.

?- start, forward, findall(K, known(K), KB).
derived c
derived d
derived e
KB = [a, b, c, d, e].

?- z(X).
ERROR: Unknown procedure: z/1

Corrected: forward chaining, forward/0, was only a comment in the file, so it could not be run. It is now code, with the same three rules written as rule/2 facts, and the query above runs it.

Corrected: this page said that for a goal nothing defines, SWI-Prolog "answers false" where pytholog raises an error. SWI-Prolog raises an error too, as z(X) above shows: an existence error, because no clause for z/1 exists at all.

The Python check's backward chaining is through pytholog's real resolution:

?- c(x).  -> Yes
?- d(x).  -> Yes
?- e(x).  -> Yes
?- z(x).  -> TypeError

NOTE

Note the dummy argument. These are propositions, not predicates, but pytholog raises IndexError on 0-arity terms — so the KB is written c(x) :- a(x), b(x).

WHICH CHAINING, AND WHY

Scenario Use Because
A sensor reading arrives; what does it imply? Forward few facts, many possible conclusions
Does this patient have malaria? Backward many facts, one question
Monitoring a plant for alarm conditions Forward you want every consequence, continuously
Diagnosing why a car will not start Backward test only the hypotheses that matter

The deciding question is: how many possible conclusions, and how many facts? Prolog is backward chaining with depth-first search — which is why a left-recursive rule never reaches an answer, closing the circle back to Experiment 1.

The Python check for this experiment, 06_logic_and_chaining.py, is shown in full under Experiment 15, with what it printed.

RESULT

Backward, e(X) is proved through d and c; forward, from {a, b}, c, d and e are derived and the knowledge base is [a, b, c, d, e].

Experiment 17 — An expert system, and its explanation facility

1. Question

Build a rule-based expert system that explains its conclusions.

2. Aim

Write a knowledge base and working memory, explain a conclusion from its proof, and add a fact.

3. Steps

  1. Write the knowledge base.
  2. Record the working memory.
  3. Explain a conclusion.
  4. Add a fact, and ask again.

THE CASE

Working memory: fever, cough, fatigue.

?- viral(patient)         -> Yes
?- flu(patient)           -> Yes
?- rest_advised(patient)  -> Yes
?- bacterial(patient)     -> not derivable (no rash recorded)

4. Programme

% Experiment 17 -- a rule-based expert system with an explanation facility.
%
% Run it: swipl 17_expert_system.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 06_logic_and_chaining.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% The rule base IS executed in 06_logic_and_chaining.py, through pytholog.

% =============================================================================
% KNOWLEDGE BASE -- general, persistent, written by the knowledge engineer
% =============================================================================
% Step 1: Write the knowledge base
viral(X)        :- fever(X), cough(X).
flu(X)          :- viral(X), fatigue(X).
bacterial(X)    :- fever(X), rash(X).
strep(X)        :- bacterial(X), sore_throat(X).
rest_advised(X) :- flu(X).
antibiotic(X)   :- bacterial(X).

% =============================================================================
% WORKING MEMORY -- facts about THIS case, cleared for the next patient
% =============================================================================
% Step 2: Record the working memory
:- dynamic fever/1, cough/1, fatigue/1, rash/1, sore_throat/1.

fever(patient).
cough(patient).
fatigue(patient).

% ?- flu(patient).            % true
% ?- rest_advised(patient).   % true
% ?- bacterial(patient).      % false -- no rash was recorded

% =============================================================================
% EXPLANATION FACILITY -- "HOW did you conclude that?"
% =============================================================================
% Re-run the proof, printing each step. In a real system the inference engine
% records the derivation as it goes.
% Step 3: Explain a conclusion
explain(Goal) :- explain(Goal, 0).

explain(Goal, Depth) :-
    clause(Goal, Body), Body \== true,
    tab(Depth), format("~w  because:~n", [Goal]),
    D1 is Depth + 2,
    explain_body(Body, D1).
explain(Goal, Depth) :-
    clause(Goal, true),
    tab(Depth), format("~w  -- a fact in working memory~n", [Goal]).
% [Corrected: a fact is a clause whose body is true, so clause(Goal, Body)
% matched facts too, and every fact was printed as "because:" with nothing
% under it. The rule clause now requires a real body, and the fact clause
% looks for a body of true.]

explain_body(true, _) :- !.
explain_body((A, B), D) :- !, explain_body(A, D), explain_body(B, D).
explain_body(G, D) :- explain(G, D).

% ?- explain(rest_advised(patient)).
%   rest_advised(patient)  because:
%     flu(patient)  because:
%       viral(patient)  because:
%         fever(patient)  -- a fact in working memory
%         cough(patient)  -- a fact in working memory
%       fatigue(patient)  -- a fact in working memory
%
% THAT is what an expert system has and a neural network does not. The chain
% IS the explanation, and it falls out of the proof for free.
%
% "WHY are you asking?" is the other direction: when the system requests a
% symptom, it names the rule it is trying to establish and which premise is
% still missing.

% Step 4: Add a fact, and ask again
% ?- bacterial(patient).   % false
% ?- assertz(rash(patient)), bacterial(patient).   % true -- one new fact
%
% That "false" means "I cannot prove it", NOT "the patient does not have a
% bacterial infection". The system reports the same answer for "definitely
% not" and "nobody told me". A human expert who meets an unfamiliar case says
% so; an expert system produces a confident wrong answer. See unit-5.md 5.1.

5. Execution and Results

OUTPUT

?- flu(patient).
true.

?- rest_advised(patient).
true.

?- bacterial(patient).
false.

?- explain(rest_advised(patient)).
rest_advised(patient)  because:
  flu(patient)  because:
    viral(patient)  because:
      fever(patient)  -- a fact in working memory
      cough(patient)  -- a fact in working memory
    fatigue(patient)  -- a fact in working memory
true.

?- bacterial(patient).
false.

?- assertz(rash(patient)), bacterial(patient).
true.

The explanation is reconstructed from the derivation, as above: each rule, then the facts it rests on.

Corrected: explain/2 printed every fact as fever(patient) because:, with nothing under it. A fact is a clause whose body is true, so clause/2 matched facts in the rule clause too. The rule clause now requires a real body, and the fact clause looks for true, so the facts read "a fact in working memory", as the file's own comment said they should.

THIS IS THE WHOLE ARGUMENT FOR EXPERT SYSTEMS

The chain is the explanation, and it falls out of the proof for free. A neural network can tell you flu with 0.94 confidence and nothing else. Add rash to working memory and bacterial(patient) becomes derivable — the last query above — so the system's conclusions change and it can say which fact changed them.

That is the sentence to put in the Unit 5 answer, and it is also the honest limit: an expert system knows only what someone wrote down.

The Python check for this experiment, 06_logic_and_chaining.py, is shown in full under Experiment 15, with what it printed.

RESULT

flu and rest_advised are derived, bacterial is not; the explanation is the proof; one assertz(rash(patient)) makes bacterial true.

Experiment 18 — A DCG grammar for English

1. Question

Write a definite clause grammar for simple English sentences.

2. Aim

Write the grammar in DCG notation, parse sentences, and build their syntax trees.

3. Steps

In SWI-Prolog, 18_dcg.pl:

  1. Write the grammar.
  2. Parse the sentences.
  3. Build a syntax tree.

The Python check, 07_bayes_and_local_search.py, for experiments 18 and 19, and Unit 3's local search:

  1. Experiment 18: parse with the grammar.
  2. Experiment 19: classify by Naive Bayes.
  3. Smooth the zero frequency.
  4. Climb hills.
  5. Anneal.
  6. Score and cross genetic boards.

THE PARSE

pytholog does not parse -->, so the Python check runs the equivalent recursive descent; SWI-Prolog runs the DCG itself.

'the big cat chases a mouse' parses:

  S
    NP
      Det  the
      Adj  big
      N    cat
    VP
      V    chases
      NP
        Det  a
        N    mouse

'the dog sleeps'  parses   (VP -> V, intransitive)
'cat the chases'  does NOT parse

4. Programme

In SWI-Prolog, 18_dcg.pl:

% Experiment 18 -- a DCG grammar for simple English sentences.
%
% Run it: swipl 18_dcg.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 07_bayes_and_local_search.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% pytholog has NO DCG notation. 07_bayes_and_local_search.py runs the
% equivalent recursive-descent parser and checks the parses.

% Step 1: Write the grammar
sentence      --> noun_phrase, verb_phrase.

noun_phrase   --> determiner, noun.
noun_phrase   --> determiner, adjective, noun.

verb_phrase   --> verb, noun_phrase.        % transitive
verb_phrase   --> verb.                     % intransitive

determiner --> [the].
determiner --> [a].
noun       --> [cat].
noun       --> [dog].
noun       --> [mouse].
adjective  --> [big].
adjective  --> [small].
verb       --> [chases].
verb       --> [sees].
verb       --> [sleeps].

% Step 2: Parse the sentences
% ?- sentence([the, big, cat, chases, a, mouse], []).   % true
% ?- sentence([the, dog, sleeps], []).                  % true
% ?- sentence([cat, the, chases], []).                  % false

% Step 3: Build a syntax tree
s(s(NP, VP))      --> np(NP), vp(VP).
np(np(D, N))      --> det(D), n(N).
np(np(D, A, N))   --> det(D), adj(A), n(N).
vp(vp(V, NP))     --> v(V), np(NP).
vp(vp(V))         --> v(V).

det(det(the)) --> [the].
det(det(a))   --> [a].
n(n(cat))     --> [cat].
n(n(mouse))   --> [mouse].
adj(adj(big)) --> [big].
v(v(chases))  --> [chases].

% ?- s(Tree, [the, big, cat, chases, a, mouse], []).
%   Tree = s(np(det(the), adj(big), n(cat)),
%            vp(v(chases), np(det(a), n(mouse))))

% --- WHAT A DCG ACTUALLY IS ---------------------------------------------------
% --> is syntactic sugar. Prolog rewrites
%     sentence --> noun_phrase, verb_phrase.
% into
%     sentence(S0, S) :- noun_phrase(S0, S1), verb_phrase(S1, S).
% threading the token list through as a DIFFERENCE LIST -- S0 is the input and
% S what remains. Calling with [] as the second argument demands that the whole
% list is consumed.
%
% So a DCG is grammar written as INFERENCE RULES, and parsing is resolution.
% This is the point where Units 2-3 (search) and Unit 4 (logic) meet: the
% parser IS a search over derivations, driven by unification.
%
% It also runs BACKWARDS -- ?- s(Tree, S, []). GENERATES sentences.

The Python check, 07_bayes_and_local_search.py, for experiments 18 and 19, and Unit 3's local search:

"""Experiments 18 and 19 — DCG parsing, Naive Bayes, and local search.

Experiment 18 (a DCG grammar) cannot run in pytholog, which has no DCG
notation -- the .pl file carries it and a recursive-descent parser here does
the same job so the expected parse trees are checked.

Experiment 19 is a deterministic Naive Bayes calculation, and this file also
covers unit-3.md's local search: hill climbing's failure modes and simulated
annealing's escape from them, both measured.
"""
import math
import random


# --- experiment 18: a grammar, parsed ---------------------------------------

GRAMMAR = {
    "S":  [["NP", "VP"]],
    "NP": [["Det", "N"], ["Det", "Adj", "N"]],
    "VP": [["V", "NP"], ["V"]],
    "Det": [["the"], ["a"]],
    "N":  [["cat"], ["dog"], ["mouse"]],
    "Adj": [["big"], ["small"]],
    "V":  [["chases"], ["sees"], ["sleeps"]],
}


def parse(symbol, tokens, i=0):
    """Recursive descent -- exactly what a DCG compiles to in Prolog."""
    if symbol not in GRAMMAR:
        return (symbol, i + 1) if i < len(tokens) and tokens[i] == symbol else None
    for production in GRAMMAR[symbol]:
        children, j = [], i
        for part in production:
            result = parse(part, tokens, j)
            if result is None:
                break
            child, j = result
            children.append(child)
        else:
            return ((symbol, children), j)
    return None


def dcg_parsing():
    good = "the big cat chases a mouse".split()
    result = parse("S", good)
    assert result is not None, good
    tree, consumed = result
    assert consumed == len(good), (consumed, len(good))

    intransitive = "the dog sleeps".split()
    r2 = parse("S", intransitive)
    assert r2 is not None and r2[1] == len(intransitive)

    bad = "cat the chases".split()
    r3 = parse("S", bad)
    assert r3 is None or r3[1] != len(bad), "word order matters"

    def render(node, depth=0):
        label, children = node
        if isinstance(children, str) or not isinstance(children, list):
            return "  " * depth + str(label)
        lines = ["  " * depth + label]
        for c in children:
            lines.append(render(c, depth + 1) if isinstance(c, tuple)
                         else "  " * (depth + 1) + str(c))
        return "\n".join(lines)

    print(f"  '{' '.join(good)}' parses:")
    print("\n".join("    " + l for l in render(tree).splitlines()))
    print(f"  '{' '.join(intransitive)}' parses (VP -> V, intransitive)")
    print(f"  '{' '.join(bad)}' does NOT parse")
    print("       a DCG in Prolog compiles to exactly this recursive descent,")
    print("       with the token list threaded through as difference lists.")
    print("       Grammar written as inference rules -- Unit 4's machinery,")
    print("       applied to language")


# --- experiment 19: deterministic Naive Bayes -------------------------------

WEATHER = [
    # outlook, temperature, humidity, wind, play
    ("sunny",    "hot",  "high",   "weak",   "no"),
    ("sunny",    "hot",  "high",   "strong", "no"),
    ("overcast", "hot",  "high",   "weak",   "yes"),
    ("rain",     "mild", "high",   "weak",   "yes"),
    ("rain",     "cool", "normal", "weak",   "yes"),
    ("rain",     "cool", "normal", "strong", "no"),
    ("overcast", "cool", "normal", "strong", "yes"),
    ("sunny",    "mild", "high",   "weak",   "no"),
    ("sunny",    "cool", "normal", "weak",   "yes"),
    ("rain",     "mild", "normal", "weak",   "yes"),
    ("sunny",    "mild", "normal", "strong", "yes"),
    ("overcast", "mild", "high",   "strong", "yes"),
    ("overcast", "hot",  "normal", "weak",   "yes"),
    ("rain",     "mild", "high",   "strong", "no"),
]
FEATURES = ["outlook", "temperature", "humidity", "wind"]


def naive_bayes():
    """Course 8's and Course 12A's worked example, computed a third time."""
    yes = [r for r in WEATHER if r[4] == "yes"]
    no = [r for r in WEATHER if r[4] == "no"]
    assert (len(yes), len(no), len(WEATHER)) == (9, 5, 14)

    prior = {"yes": len(yes) / 14, "no": len(no) / 14}

    def likelihood(rows, index, value):
        return sum(1 for r in rows if r[index] == value) / len(rows)

    query = ("sunny", "cool", "high", "strong")
    posterior = {}
    for label, rows in (("yes", yes), ("no", no)):
        p = prior[label]
        for i, value in enumerate(query):
            p *= likelihood(rows, i, value)
        posterior[label] = p

    assert round(posterior["yes"], 6) == 0.005291, round(posterior["yes"], 6)
    assert round(posterior["no"], 6) == 0.020571, round(posterior["no"], 6)
    assert posterior["no"] > posterior["yes"]

    total = sum(posterior.values())
    normalised = {k: v / total for k, v in posterior.items()}
    assert round(normalised["no"], 4) == 0.7954

    print(f"  14 days: {len(yes)} play, {len(no)} do not")
    print(f"  query: {query}")
    print(f"    P(yes) x likelihoods = {posterior['yes']:.6f}")
    print(f"    P(no)  x likelihoods = {posterior['no']:.6f}   <- larger")
    print(f"    normalised: no {normalised['no'] * 100:.2f}%, "
          f"yes {normalised['yes'] * 100:.2f}%")
    print("       identical to Course 8 and Course 12 A. Three courses, three")
    print("       implementations, the same numbers -- which is the point of")
    print("       reusing the dataset")


def the_zero_frequency_problem():
    """One unseen value zeroes the whole product."""
    yes = [r for r in WEATHER if r[4] == "yes"]

    # 'overcast' never appears with play=no, so P(overcast | no) = 0.
    no = [r for r in WEATHER if r[4] == "no"]
    overcast_no = sum(1 for r in no if r[0] == "overcast")
    assert overcast_no == 0, "overcast NEVER coincides with no"

    p_no = (len(no) / 14) * 0.0
    assert p_no == 0.0

    # Laplace: (0 + 1) / (5 + 3 outlook values) = 1/8
    smoothed = (len(no) / 14) * (1 / (len(no) + 3))
    assert round(smoothed, 6) == 0.044643, round(smoothed, 6)

    print(f"  'overcast' appears {overcast_no} times with play=no")
    print(f"    P(no | overcast, ...) without smoothing = {p_no}")
    print(f"    with Laplace (+1, 3 outlook values)     = {smoothed:.6f}")
    print("       a single zero vetoes the class whatever the other three")
    print("       features say, because the likelihood is a PRODUCT")


# --- unit-3's local search, measured ----------------------------------------

def hill_climbing_gets_stuck():
    """A landscape with a local maximum, and the measured failure rate."""
    def value(x):
        # Two peaks: a low one near 2 and the global one near 8.
        return -abs(x - 2) * 0.7 + 3 if x < 5 else -abs(x - 8) * 0.5 + 5

    def climb(start, step=0.1):
        x = start
        while True:
            candidates = [x - step, x + step]
            best = max(candidates, key=value)
            if value(best) <= value(x):
                return x
            x = best

    starts = [i * 0.5 for i in range(21)]           # 0.0 to 10.0
    peaks = [round(climb(s), 1) for s in starts]
    global_max = sum(1 for p in peaks if abs(p - 8.0) < 0.35)
    local_max = len(peaks) - global_max

    assert len(starts) == 21
    assert local_max > 0, "some starts get stuck on the local peak"
    assert global_max > 0

    print(f"  a landscape with a local peak near x=2 and the global near x=8:")
    print(f"    {len(starts)} starting points")
    print(f"    reached the GLOBAL maximum: {global_max}")
    print(f"    stuck on the LOCAL maximum: {local_max}  "
          f"({local_max / len(starts) * 100:.0f}%)")
    print("       hill climbing is complete only with RANDOM RESTARTS. If each")
    print(f"       try succeeds with probability p, expected restarts = 1/p")
    print(f"       -- here about {len(starts) / global_max:.1f}")


def simulated_annealing_escapes():
    """The acceptance probability, and the schedule that makes it work."""
    def acceptance(delta, T):
        return 1.0 if delta > 0 else math.exp(delta / T)

    # High T accepts almost anything; low T accepts almost nothing.
    assert round(acceptance(-1.0, 100.0), 4) == 0.9900
    assert round(acceptance(-1.0, 1.0), 4) == 0.3679
    assert round(acceptance(-1.0, 0.1), 6) == 0.000045
    assert acceptance(+1.0, 0.1) == 1.0, "an improvement is ALWAYS accepted"

    print("    P(accept a move that is 1.0 worse) = e^(-1/T):")
    for T in (100.0, 10.0, 1.0, 0.1):
        print(f"      T = {T:6.1f}  ->  {acceptance(-1.0, T):.6f}")
    print("       T high: it accepts almost anything and EXPLORES.")
    print("       T -> 0: it accepts nothing worse and becomes HILL CLIMBING.")
    print("       Annealing is a scheduled slide from random walk to hill")
    print("       climbing, which is exactly why it escapes local maxima")


def genetic_algorithm_fitness():
    """8-queens fitness: non-attacking pairs, maximum 28."""
    def fitness(individual):
        n = len(individual)
        attacking = sum(
            1 for i in range(n) for j in range(i + 1, n)
            if individual[i] == individual[j]
            or abs(individual[i] - individual[j]) == j - i)
        return math.comb(n, 2) - attacking

    assert math.comb(8, 2) == 28, "28 pairs on an 8x8 board"
    solution = (0, 4, 7, 5, 2, 6, 1, 3)          # from experiment 14
    assert fitness(solution) == 28, "a solution has ZERO attacking pairs"

    bad = (0, 0, 0, 0, 0, 0, 0, 0)               # all on one row
    assert fitness(bad) == 0, "every pair attacks"

    middling = (2, 4, 7, 4, 8, 5, 5, 2)
    assert 0 < fitness(middling) < 28

    # Crossover preserves contiguous blocks -- the reason it can help.
    parent_a, parent_b = (2, 4, 7, 4, 8, 5, 5, 2), (3, 2, 7, 5, 2, 4, 1, 1)
    cut = 3
    child = parent_a[:cut] + parent_b[cut:]
    assert child == (2, 4, 7, 5, 2, 4, 1, 1)
    assert child[:cut] == parent_a[:cut] and child[cut:] == parent_b[cut:]

    print(f"  fitness = non-attacking pairs, maximum C(8,2) = {math.comb(8, 2)}")
    print(f"    a valid solution {solution} -> {fitness(solution)}")
    print(f"    all on one row   -> {fitness(bad)}")
    print(f"    a middling one   -> {fitness(middling)}")
    print(f"  crossover at position {cut}: {parent_a} + {parent_b}")
    print(f"                            -> {child}")
    print("       crossover preserves CONTIGUOUS BLOCKS. It helps only if")
    print("       neighbouring genes form a partial solution; with a badly")
    print("       ordered representation it is just noise, and the GA")
    print("       degenerates into an expensive random search")


def main():
    print("Experiments 18-19 -- DCG parsing, Naive Bayes, and local search")
    # Step 1: Experiment 18: parse with the grammar
    print("  experiment 18 -- a grammar (DCG in the .pl; recursive descent here):")
    dcg_parsing()
    # Step 2: Experiment 19: classify by Naive Bayes
    print("  experiment 19 -- deterministic Naive Bayes:")
    naive_bayes()
    # Step 3: Smooth the zero frequency
    the_zero_frequency_problem()
    # Step 4: Climb hills
    print("  unit-3 local search -- hill climbing:")
    hill_climbing_gets_stuck()
    # Step 5: Anneal
    print("  unit-3 local search -- simulated annealing:")
    simulated_annealing_escapes()
    # Step 6: Score and cross genetic boards
    print("  unit-3 -- genetic algorithm fitness and crossover:")
    genetic_algorithm_fitness()


if __name__ == "__main__":
    main()

5. Execution and Results

In SWI-Prolog, 18_dcg.pl:

OUTPUT

?- sentence([the, big, cat, chases, a, mouse], []).
true.

?- sentence([the, dog, sleeps], []).
true.

?- sentence([cat, the, chases], []).
false.

?- s(Tree, [the, big, cat, chases, a, mouse], []).
Tree = s(np(det(the), adj(big), n(cat)), vp(v(chases), np(det(a), n(mouse)))).

The Python check, 07_bayes_and_local_search.py, for experiments 18 and 19, and Unit 3's local search:

OUTPUT

Experiments 18-19 -- DCG parsing, Naive Bayes, and local search
  experiment 18 -- a grammar (DCG in the .pl; recursive descent here):
  'the big cat chases a mouse' parses:
    S
      NP
        Det
          the
        Adj
          big
        N
          cat
      VP
        V
          chases
        NP
          Det
            a
          N
            mouse
  'the dog sleeps' parses (VP -> V, intransitive)
  'cat the chases' does NOT parse
       a DCG in Prolog compiles to exactly this recursive descent,
       with the token list threaded through as difference lists.
       Grammar written as inference rules -- Unit 4's machinery,
       applied to language
  experiment 19 -- deterministic Naive Bayes:
  14 days: 9 play, 5 do not
  query: ('sunny', 'cool', 'high', 'strong')
    P(yes) x likelihoods = 0.005291
    P(no)  x likelihoods = 0.020571   <- larger
    normalised: no 79.54%, yes 20.46%
       identical to Course 8 and Course 12 A. Three courses, three
       implementations, the same numbers -- which is the point of
       reusing the dataset
  'overcast' appears 0 times with play=no
    P(no | overcast, ...) without smoothing = 0.0
    with Laplace (+1, 3 outlook values)     = 0.044643
       a single zero vetoes the class whatever the other three
       features say, because the likelihood is a PRODUCT
  unit-3 local search -- hill climbing:
  a landscape with a local peak near x=2 and the global near x=8:
    21 starting points
    reached the GLOBAL maximum: 11
    stuck on the LOCAL maximum: 10  (48%)
       hill climbing is complete only with RANDOM RESTARTS. If each
       try succeeds with probability p, expected restarts = 1/p
       -- here about 1.9
  unit-3 local search -- simulated annealing:
    P(accept a move that is 1.0 worse) = e^(-1/T):
      T =  100.0  ->  0.990050
      T =   10.0  ->  0.904837
      T =    1.0  ->  0.367879
      T =    0.1  ->  0.000045
       T high: it accepts almost anything and EXPLORES.
       T -> 0: it accepts nothing worse and becomes HILL CLIMBING.
       Annealing is a scheduled slide from random walk to hill
       climbing, which is exactly why it escapes local maxima
  unit-3 -- genetic algorithm fitness and crossover:
  fitness = non-attacking pairs, maximum C(8,2) = 28
    a valid solution (0, 4, 7, 5, 2, 6, 1, 3) -> 28
    all on one row   -> 0
    a middling one   -> 24
  crossover at position 3: (2, 4, 7, 4, 8, 5, 5, 2) + (3, 2, 7, 5, 2, 4, 1, 1)
                            -> (2, 4, 7, 5, 2, 4, 1, 1)
       crossover preserves CONTIGUOUS BLOCKS. It helps only if
       neighbouring genes form a partial solution; with a badly
       ordered representation it is just noise, and the GA
       degenerates into an expensive random search

WHAT A DCG ACTUALLY IS

A DCG compiles to exactly this recursive descent, with the token list threaded through as difference lists. s --> np, vp. expands to s(S0, S) :- np(S0, S1), vp(S1, S). — the grammar is written as inference rules, so this experiment is Unit 4's machinery applied to language. That is why it sits in an AI course rather than a compilers course.

RESULT

'the big cat chases a mouse' and 'the dog sleeps' parse, 'cat the chases' does not, and the first gives its tree.

Experiment 19 — Deterministic Naive Bayes

1. Question

Classify a new day with Naive Bayes, by counting.

2. Aim

Count the training data, compute each class's posterior for a new day, and smooth a zero frequency.

3. Steps

  1. State the training data.
  2. Count, and compute the probabilities.
  3. Classify the new day.
  4. Smooth the zero frequency.

THE POSTERIORS

The 14-day play-tennis table: 9 play, 5 do not.

Query (sunny, cool, high, strong):

Class P(class) × likelihoods
yes 0.005291
no 0.020571 ← larger

Normalised: no 79.54%, yes 20.46%.

4. Programme

% Experiment 19 -- a deterministic Naive Bayes calculation for categorical data.
%
% Run it: swipl 19_naive_bayes.pl, then type a query at the ?- prompt -- or paste the
% file into https://swish.swi-prolog.org/. Each "% ?-" query below was asked of
% SWI-Prolog 9.0.4 by tools/data-science/prolog_lab.py, and the lab page shows
% what it answered. 07_bayes_and_local_search.py checks the same logic in Python.
% [Changed: this said the file had never been run, as SWI-Prolog could not be
% installed where these labs are checked. It now installs from the Ubuntu archive.]
%
% 07_bayes_and_local_search.py computes the same posteriors and asserts them
% against Course 8's and Course 12 A's figures.

% Step 1: State the training data
% day(Outlook, Temperature, Humidity, Wind, Play)
day(sunny,    hot,  high,   weak,   no).
day(sunny,    hot,  high,   strong, no).
day(overcast, hot,  high,   weak,   yes).
day(rain,     mild, high,   weak,   yes).
day(rain,     cool, normal, weak,   yes).
day(rain,     cool, normal, strong, no).
day(overcast, cool, normal, strong, yes).
day(sunny,    mild, high,   weak,   no).
day(sunny,    cool, normal, weak,   yes).
day(rain,     mild, normal, weak,   yes).
day(sunny,    mild, normal, strong, yes).
day(overcast, mild, high,   strong, yes).
day(overcast, hot,  normal, weak,   yes).
day(rain,     mild, high,   strong, no).

% Step 2: Count, and compute the probabilities
count_class(C, N) :- findall(1, day(_,_,_,_,C), L), length(L, N).
total(N) :- findall(1, day(_,_,_,_,_), L), length(L, N).

count_feature(1, V, C, N) :- findall(1, day(V,_,_,_,C), L), length(L, N).
count_feature(2, V, C, N) :- findall(1, day(_,V,_,_,C), L), length(L, N).
count_feature(3, V, C, N) :- findall(1, day(_,_,V,_,C), L), length(L, N).
count_feature(4, V, C, N) :- findall(1, day(_,_,_,V,C), L), length(L, N).

prior(C, P) :- count_class(C, N), total(T), P is N / T.

likelihood(I, V, C, P) :-
    count_feature(I, V, C, N), count_class(C, D), P is N / D.

posterior([O,T,H,W], C, P) :-
    prior(C, Pc),
    likelihood(1, O, C, P1), likelihood(2, T, C, P2),
    likelihood(3, H, C, P3), likelihood(4, W, C, P4),
    P is Pc * P1 * P2 * P3 * P4.

classify(Obs, Class) :-
    posterior(Obs, yes, Py), posterior(Obs, no, Pn),
    ( Py > Pn -> Class = yes ; Class = no ).

% Step 3: Classify the new day
% ?- posterior([sunny, cool, high, strong], yes, P).   % P = 0.005291...
% ?- posterior([sunny, cool, high, strong], no,  P).   % P = 0.020571...
% ?- classify([sunny, cool, high, strong], C).          % C = no
%
% Normalised: no 79.54%, yes 20.46%.

% Step 4: Smooth the zero frequency
% 'overcast' NEVER occurs with play=no, so likelihood(1, overcast, no, P) gives
% P = 0, and since the posterior is a PRODUCT, the whole thing collapses to
% zero however strongly the other three features argue for 'no'.
%
% LAPLACE SMOOTHING: add 1 to every count, and add the number of distinct
% values to the denominator:
smoothed(I, V, C, K, P) :-
    count_feature(I, V, C, N), count_class(C, D),
    P is (N + 1) / (D + K).
%
% With K = 3 outlook values: (0 + 1) / (5 + 3) = 0.125 instead of 0.
% ?- likelihood(1, overcast, no, P).     % P = 0
% ?- smoothed(1, overcast, no, 3, P).    % P = 0.125
% [Added: nothing asked for smoothed/5, so it had never been tried.]

5. Execution and Results

OUTPUT

?- posterior([sunny, cool, high, strong], yes, P).
P = 0.005291005291005291.

?- posterior([sunny, cool, high, strong], no,  P).
P = 0.02057142857142857.

?- classify([sunny, cool, high, strong], C).
C = no.

?- likelihood(1, overcast, no, P).
P = 0.

?- smoothed(1, overcast, no, 3, P).
P = 0.125.

THE CROSS-COURSE CHECK

These are Data Mining's numbers and Machine Learning's numbers. Three courses, three implementations — WEKA-equivalent scikit-learn in Data Mining, GaussianNB and a hand calculation in Machine Learning, and this — and the same 0.005291 / 0.020571. If they ever disagree, one of them is wrong, and tools/data-science/verify_all.sh says so.

ZERO FREQUENCY VETOES A CLASS

'overcast' appears 0 times with play=no
  P(no | overcast, ...) without smoothing = 0.0
  with Laplace (+1, 3 outlook values)     = 0.044643

A single zero vetoes the class whatever the other three features say, because the likelihood is a product. Laplace smoothing adds 1 to every count and 3 (the number of outlook values) to every denominator: SWI-Prolog's smoothed(1, overcast, no, 3, P) gives the single likelihood, 0.125, in place of 0. Added: nothing in the file asked for smoothed/5; the last two queries run it.

The Python check for this experiment, 07_bayes_and_local_search.py, is shown in full under Experiment 18, with what it printed.

RESULT

For (sunny, cool, high, strong), no scores 0.020571 and yes 0.005291, so the class is no; Laplace smoothing turns overcast's zero into 0.125.


The unit-3 extras that share 07_bayes_and_local_search.py

The syllabus lists hill climbing, simulated annealing and genetic algorithms in Unit 3 but prescribes no experiment for them. They are verified anyway, because §3.5–3.7 quote numbers; their output is the end of what 07_bayes_and_local_search.py printed, under Experiment 18.

Hill climbing gets stuck 48% of the time. On a landscape with a local peak near x = 2 and the global near x = 8:

From 21 starting points Count
reached the global maximum 11
stuck on the local maximum 10 (48%)

Hill climbing is complete only with random restarts. If each try succeeds with probability p, expected restarts = 1/p — here about 1.9.

Simulated annealing, as the acceptance probability. P(accept a move that is 1.0 worse) = e^(−1/T):

T P
100.0 0.990050
10.0 0.904837
1.0 0.367879
0.1 0.000045

T high: it accepts almost anything and explores. T → 0: it accepts nothing worse and becomes hill climbing. Annealing is a scheduled slide from random walk to hill climbing, which is exactly why it escapes local maxima — and the four numbers make "cooling schedule" concrete.

Genetic algorithms — fitness and the crossover trap. Fitness = non-attacking pairs, maximum C(8,2) = 28:

Board Fitness
a valid solution (0,4,7,5,2,6,1,3) 28
all queens on one row 0
a middling one 24

Crossover at position 3:

(2,4,7, 4,8,5,5,2) + (3,2,7, 5,2,4,1,1)  ->  (2,4,7, 5,2,4,1,1)

Crossover preserves contiguous blocks. It helps only if neighbouring genes form a partial solution; with a badly ordered representation it is just noise, and the GA degenerates into an expensive random search. That is the criticism to raise when the exam asks you to evaluate genetic algorithms.


What the runner asserts

Script Experiments Real resolution?
01_family_tree.py 1 Yes — ancestor/2 is genuinely recursive
02_lists_and_arithmetic.py 2–7 No — proves the limit, then Python
03_uninformed_search.py 8–11 No — search, not logic
04_informed_search.py 12 No
05_csp_backtracking.py 13, 14 No
06_logic_and_chaining.py 15–17 Yes — 16 and 17 resolve
07_bayes_and_local_search.py 18, 19 + unit-3 extras No

Plus the Prolog run: all 16 .pl files, in SWI-Prolog. Each must load without a warning, raise no error but the one it demonstrates (z(X) in Experiment 16), and give the answers the Python checks assert — ancestor(ram, X)'s five, A*'s 418, 92 queens, the posteriors 0.005291 and 0.020571. If a .pl file and its Python check ever disagree, the suite fails.


Lab examination

Two hours in SWISH, one experiment number, then a viva.

What costs marks:

What earns them:

Each program, on its own page

The same experiments, one page each, so a program can be reached by what it does rather than by its number.

RUNS

A family tree, executed as a logic program in Prolog

RUNS

List predicates, arithmetic, and the cut in Prolog

RUNS

DFS, BFS and uniform cost search in Prolog

RUNS

Greedy Best-First search and A* in Prolog

RUNS

Map colouring and N-Queens by backtracking in Prolog

RUNS

Logic, chaining, and an expert system in Prolog

RUNS

DCG parsing, Naive Bayes, and local search in Prolog