Prolog from the ground up

Expert: a rule-based adviser

A small expert system built from signs, causes and a rule that checks a whole set at once, ending with a report that explains the verdict.

Part 12 of 12

Advanced22 min readBack to Courses

The three parts of an expert system

An expert system is facts about the world, rules that draw conclusions from them, and a report that explains what was concluded. The example is small, but the parts are the parts: signs that were observed, causes that each need a particular set of signs, and a rule that checks a whole set at once.

That checking rule is the new idea here. forall(Generator, Test) succeeds only when the test holds for every answer the generator produces, which is exactly what it means for all the signs of a cause to be present.

The signs are the observations, and the causes are the conclusions. A sign is stored as a plain fact with no explanation; a cause is stored with the list of signs it needs. The rule that connects them never mentions a particular illness, which is why adding a cause is a matter of adding one fact, not editing the reasoning.

Deciding, and explaining

The candidates are collected with findall, so the answer arrives as one list instead of a trail of backtracking. An empty list is a real answer too, and an if-then-else says so in words, which keeps the whole report deterministic.

This is the last chapter, and the shape to take away is the one on screen: describe what you know as facts, write one rule per conclusion, and let a report explain the answer. Nothing is hidden in the code, which is the point of the language.

The report asks two questions of the collected list: is it empty, and what is in it. Fit = [] is a pattern that matches only the empty list, so the if-then-else can print a sentence for the case where nothing fits. Without that branch the program would print an empty list and leave the reader to interpret it.

Testing a whole set at once

forall(Generator, Test) is the engine’s way of saying every. It runs the generator, and for each answer it runs the test; if any test fails, the whole goal fails, and it leaves no choice points behind. That last part is why the report can ask a simple yes-or-no question and then print one verdict.

The generator in the example walks the list of signs a cause needs. The test checks that each sign is present as a fact. If the cause lists a sign that was never observed, forall fails at that sign without printing anything, and the next cause is tried. The list of causes comes from findall, so the report has a plain value to inspect; an empty list is not an error, it means no cause was proved from the signs recorded.

A trace of fits(flu) shows the check. cause(flu, Signs) binds Signs to [fever, cough]. forall takes the first sign, has_sign(fever, [fever, cough]) succeeds, then the second, has_sign(cough, [fever, cough]) succeeds. With no more signs, the generator is exhausted and forall succeeds. If cough were missing, the test would fail at that sign and fits(flu) would fail.

What goes wrong

A cause with an empty sign list fits everything, because forall over no answers succeeds. That is logically correct and almost never what a diagnosis means, so every cause should require at least one sign. A misspelled sign is quieter: the test fails, the cause disappears from the list, and no error points at the typo.

Printing inside forall prints once per sign rather than once per cause. Keep the printing outside the loop and let forall answer a yes-or-no question. And a cause whose signs are duplicated still fits; the check asks whether each sign is present, not how many times it was listed.

Does a cause fit the signs?

sign(fever).
sign(cough).
cause(flu, [fever, cough]).

has_sign(X, [X | _]).
has_sign(X, [_ | T]) :- has_sign(X, T).

fits(C) :-
    cause(C, Signs),
    forall(has_sign(S, Signs), sign(S)).

main :-
    ( fits(flu) -> write('flu fits') ; write('no') ), nl.
:- initialization(main).

A verdict from three facts

Run it and read the verdict. Then add a sign that flu does not ask for, give a second cause that uses it, and watch the list of diagnoses change.

Output
Click "Run" to execute your code

Three mistakes to watch for. A cause with an empty sign list fits everything, because forall over no answers succeeds. A misspelled sign makes a cause silently unfittable, with no error to point at it. And a rule that prints inside forall prints once per sign rather than once per cause, so keep the printing outside the loop.

Try these next

  • Add a third cause that needs only one sign and check that it appears in the list.
  • Remove a sign fact and watch which diagnoses disappear.
  • Add a why/1 rule that prints the signs a chosen cause needs.

A plant-care diagnosis

Run it and read the two diagnoses. Then add `sign(wet_soil).` and see the third cause join the list.

Output
Click "Run" to execute your code

Swap the symptom facts around and every rule above them changes with them. That is what makes this style a good home for rules that people, not only computers, need to argue about.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does a forall goal ask?