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/.
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.
Represent a family tree in Prolog, with rules for father, mother, grandparent, ancestor, sibling and cousin.
State the facts, write the rules — one of them recursive — and query them.
In SWI-Prolog, 01_family_tree.pl:
The Python check, 01_family_tree.py, through pytholog's resolution:
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.
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()
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.
Write list predicates in Prolog: member, append, reverse and length.
Define the four predicates recursively, and run append/3 backwards.
In SWI-Prolog, 02_lists.pl:
The Python check, 02_lists_and_arithmetic.py, for experiments 2–7:
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.
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()
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.
Find the maximum element of a list.
Define max_list/2 recursively, from the right base case.
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.
% 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.
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.
Flatten a nested list into a single-level list.
Define flatten/2 in three clauses: the empty list, a list head and an atom.
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.
% 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.
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].
Compute factorial and Fibonacci numbers by recursion.
Define fact/2 and fib/2 with is/2, see why the naive fib/2 is slow, and make it linear.
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.
% 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)).
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.
Find the greatest common divisor of two numbers by recursion.
Define gcd/3 by Euclid's algorithm, and see why it terminates.
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.
% 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.
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.
Demonstrate the cut (!) and fail.
Write the cut-fail idiom, tell a green cut from a red one, and 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.
% 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.
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
Green cut — removes only redundant choice points. Delete it and nothing changes but speed.
Red cut — changes the meaning. Delete it and the answers change.
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.
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.
State one graph, search it both ways, and see that DFS finds the long way round and BFS the short one.
In SWI-Prolog, 08_graph_search.pl:
The Python check, 03_uninformed_search.py, for experiments 8–11:
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:
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 the fewest (6) and found the worst path, 607 km. Cheap and wrong.
UCS found the optimal 418 and expanded the most, 13. Optimality is paid for in nodes.
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.
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()
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.
Search the Romania map with Greedy Best-First search and A*.
Run A* with the straight-line heuristic, compare it with greedy and uniform cost, and check the heuristic is admissible.
In SWI-Prolog, 12_astar.pl:
The Python check, 04_informed_search.py, for experiment 12:
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.
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()
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.
Colour a map so that no two neighbouring regions share a colour, as a constraint satisfaction problem.
Colour Australia's seven regions with three colours by backtracking, and see why two are not enough.
In SWI-Prolog, 13_map_colouring.pl:
The Python check, 05_csp_backtracking.py, for experiments 13 and 14:
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.
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()
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.
Place N queens on an N × N board so that no two attack each other.
Place the queens as a permutation, check only the diagonals, and count the solutions.
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.
% 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.
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.
Encode facts and rules in propositional and first-order logic.
Encode a propositional rule and a first-order one, and check truth tables, validity and entailment.
In SWI-Prolog, 15_logic.pl:
The Python check, 06_logic_and_chaining.py, for experiments 15–17:
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.
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()
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.
Implement forward and backward chaining.
Chain backward, as Prolog does, and forward, written out with assertz/1, from the same rules.
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.
% 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.
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].
Build a rule-based expert system that explains its conclusions.
Write a knowledge base and working memory, explain a conclusion from its proof, and add a fact.
THE CASE
Working memory: fever, cough, fatigue.
?- viral(patient) -> Yes
?- flu(patient) -> Yes
?- rest_advised(patient) -> Yes
?- bacterial(patient) -> not derivable (no rash recorded)
% 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.
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.
Write a definite clause grammar for simple English sentences.
Write the grammar in DCG notation, parse sentences, and build their syntax trees.
In SWI-Prolog, 18_dcg.pl:
The Python check, 07_bayes_and_local_search.py, for experiments 18 and 19, and Unit 3's local search:
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
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()
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.
Classify a new day with Naive Bayes, by counting.
Count the training data, compute each class's posterior for a new day, and smooth a 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%.
% 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.]
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.
07_bayes_and_local_search.pyThe 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.
| 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.
Two hours in SWISH, one experiment number, then a viva.
What costs marks:
ancestor/2 left-recursively and running the interpreter out of stacksibling/2 without the X \= Y guard — asha becomes her own siblingmax([], 0) as the base case — wrong for negative numbers= where you meant is — X = 2 + 3 binds X to the term 2+3\+ bird(kiwi) means a kiwi is not a birdWhat earns them:
append(X, Y, [a,b,c]) giving four solutions. One definition,
concatenation and splitting both, because a rule is a relation.
Quoting A* against UCS: 418 either way, 6 nodes against 13. A number beats an adjective.
Showing the inadmissible heuristic being faster. 4 expansions, 450 km. It demonstrates you know what the guarantee actually buys.
"h2 dominates h1", with 6 against 14 on a scrambled board — the right vocabulary for comparing heuristics.
"Variables fail fast, values fail late" for MRV and LCV, with the NT-has-2-values table behind it.
Printing the explanation chain for the expert system, and saying that is what a neural network cannot do.
Naming the closed-world assumption when you use \+.
The same experiments, one page each, so a program can be reached by what it does rather than by its number.