Expert Systems and Rule-Based AI

Expert systems were one of the earliest commercial successes of AI, and Prolog is an ideal language for building them. In this chapter we build a complete expert system shell and demonstrate it with practical examples.

Architecture diagram for the Expert Shell example
Figure 10. Architecture diagram for the Expert Shell example

What Is an Expert System?

An expert system is a computer program that emulates the decision-making ability of a human expert. Developed in the 1970s and 1980s during the “Rule-Based AI” era (producing famous systems like MYCIN and DENDRAL), they represent one of the first successful applications of AI to real-world problems.

The standard architecture of an expert system consists of four key components:

  1. Knowledge Base (KB): A database of domain-specific facts and rules (heuristic knowledge) usually structured as “IF-THEN” statements.
  2. Inference Engine: The brain of the system, which applies logical rules to the knowledge base to deduce new information or prove a hypothesis. It can operate via forward chaining (data-driven) or backward chaining (goal-driven).
  3. Explanation Facility: A module that explains the system’s reasoning path to the user, answering “How” a conclusion was reached or “Why” a particular question is being asked.
  4. User Interface: The interactive portal through which the system prompts the user for missing information and displays conclusions.

Prolog is uniquely suited for building expert systems because its core runtime environment already includes an inference engine (SLD resolution) and a backtracking search mechanism.

Building an Expert System Shell in Prolog

Instead of hard-coding an expert system for a single domain, we can build a domain-independent shell. The shell defines the interactive loop, maintains the database of user-supplied facts, and provides explanation utilities, while the specific domain knowledge is loaded from a separate rules file via the shell’s load_kb/1 predicate.

To implement the shell, we use Prolog’s dynamic database to store facts provided by the user during a session using known/2 terms. Prolog’s built-in backward-chaining engine automatically executes the rules. When a rule needs an attribute that is not yet known, the shell prompts the user, records the answer, and continues evaluation.

The expert_shell project provides a domain-independent shell. Here is the file expert_shell/prolog/shell.pl:

  1  %% shell.pl - Expert system shell with backward chaining and explanations
  2 :- module(shell, [
  3     consult_expert/1,
  4     explain/1,
  5     ask_question/1,
  6     provide_answer/2,
  7     reset_known/0,
  8     load_kb/1
  9 ]).
 10 
 11 :- dynamic known/2.  % known(Attribute, Value) - user-provided facts
 12 
 13 %% reset_known - Clear all user-provided answers
 14 reset_known :-
 15     retractall(known(_, _)).
 16 
 17 %% provide_answer(+Attribute, +Value)
 18 %% Programmatically supply an answer (used by tests and non-interactive
 19 %% drivers) instead of prompting the user.
 20 provide_answer(Attribute, Value) :-
 21     retractall(known(Attribute, _)),
 22     assertz(known(Attribute, Value)).
 23 
 24 %% consult_expert(-Conclusion) - Main entry point
 25 %% When a KB has been loaded via load_kb/1, its hypothesis/1 rules
 26 %% (in module user) are tried first; otherwise the built-in default
 27 %% applies.  Answers provided earlier via provide_answer/2 are kept;
 28 %% call reset_known/0 explicitly for a fresh consultation.
 29 consult_expert(Conclusion) :-
 30     consult_kb_hypothesis(Conclusion),
 31     !.
 32 
 33 consult_kb_hypothesis(Conclusion) :-
 34     kb_file(_),
 35     !,
 36     user:hypothesis(Conclusion).
 37 consult_kb_hypothesis(Conclusion) :-
 38     hypothesis(Conclusion).
 39 
 40 %% explain(+Conclusion) - Show reasoning chain
 41 explain(Conclusion) :-
 42     explanation_text(Conclusion, Explanation),
 43     format("Conclusion: ~w~n", [Conclusion]),
 44     format("Reasoning: ~w~n", [Explanation]).
 45 
 46 explanation_text(C, E) :-
 47     kb_file(_),
 48     user:hypothesis_explanation(C, E),
 49     !.
 50 explanation_text(C, E) :-
 51     hypothesis_explanation(C, E).
 52 
 53 %% ask_question(+Attribute) - Ask user for information
 54 %% Reuse a known answer; otherwise read a line, strip a trailing '.',
 55 %% and convert to an atom or number as appropriate.  EOF aborts the
 56 %% consultation gracefully.
 57 ask_question(Attribute) :-
 58     known(Attribute, Value),
 59     !,
 60     format("~w: (cached) ~w~n", [Attribute, Value]).
 61 ask_question(Attribute) :-
 62     format("~nWhat is the value of ~w? ", [Attribute]),
 63     catch(read_line_to_string(user_input, Line),
 64           _, Line = end_of_file),
 65     (   Line == end_of_file
 66     ->  format("~nEOF reached; aborting consultation.~n"),
 67         fail
 68     ;   normalize_answer(Line, Value),
 69         provide_answer(Attribute, Value)
 70     ).
 71 
 72 %% normalize_answer(+RawString, -Value)
 73 %% Strip a trailing '.', try a numeric conversion, else produce an atom.
 74 normalize_answer(Raw, Value) :-
 75     string_codes(Raw, Codes0),
 76     strip_trailing_dot(Codes0, Codes),
 77     string_codes(Term, Codes),
 78     (   catch(atom_number(Term, Value), _, fail)
 79     ->  true
 80     ;   atom_string(Value, Term)
 81     ).
 82 
 83 strip_trailing_dot(Codes, Rest) :-
 84     append(Rest, [0'.], Codes),
 85     !.
 86 strip_trailing_dot(Codes, Codes).
 87 
 88 %% load_kb(+File) - Consult a knowledge-base file defining hypothesis/1
 89 %% and hypothesis_explanation/2 rules (if_/then_ style conditions read
 90 %% known/2 answers via check/1).  Clears previously loaded KB rules.
 91 :- dynamic kb_file/1.
 92 
 93 load_kb(File) :-
 94     retractall(kb_file(_)),
 95     unload_old_kb,
 96     assertz(kb_file(File)),
 97     open(File, read, In),
 98     repeat,
 99       read(In, Term),
100       (   Term == end_of_file
101       ->  close(In), !
102       ;   assertz(user:Term),
103           fail
104       ).
105 
106 unload_old_kb :-
107     forall(clause(user:hypothesis(_), _, Ref), erase(Ref)),
108     forall(clause(user:hypothesis_explanation(_, _), _, Ref),
109            erase(Ref)),
110     dynamic(user:hypothesis/1),
111     dynamic(user:hypothesis_explanation/2).
112 
113 %% check(+Condition) - True when the attribute has been answered as
114 %% requested, prompting via ask_question/1 when not yet known.
115 check(A == V) :- !,
116     (   known(A, V)
117     ->  true
118     ;   \+ known(A, _),
119         ask_question(A),
120         known(A, V)
121     ).
122 
123 %% Default hypothesis rules (used when no KB is loaded)
124 hypothesis(unknown) :-
125     format("Could not determine a conclusion from the given facts.~n").
126 
127 hypothesis_explanation(
128     unknown,
129     'Insufficient data to reach a conclusion.').

The shell’s key predicates:

  • consult_expert/1 is the main entry point. It tries the loaded KB’s hypothesis/1 rules first (falling back to the built-in unknown hypothesis) and no longer auto-retracts known/2 facts. Call reset_known/0 explicitly for a fresh consultation.
  • explain/1 routes through explanation_text/2, preferring the loaded KB’s hypothesis_explanation/2 over the shell’s own default.
  • ask_question/1 reads a line with read_line_to_string/2, runs normalize_answer/2 (which strips a trailing . and converts numbers) and treats EOF as a graceful abort. Known answers are reused and printed as “(cached)”.
  • provide_answer/2 supplies an answer programmatically (used by tests and non-interactive drivers) instead of prompting.
  • reset_known/0 clears all user-provided answers.
  • load_kb/1 loads a user knowledge-base file and asserts its hypothesis/1 and hypothesis_explanation/2 clauses into module user, clearing any previously loaded KB rules. This is what makes pluggable knowledge bases real rather than aspirational.

Knowledge Acquisition and Rule Representation

Knowledge acquisition is the process of extracting domain knowledge from human experts and structuring it into rules. In a Prolog-based expert system, we represent this knowledge using clauses.

Structuring Rules for the Shell

To plug into our shell, a domain knowledge base must define rules for the hypothesis/1 predicate and explanations for hypothesis_explanation/2. To prompt the user interactively, we define an ask_if/2 helper:

1 ask_if(Attribute, Value) :-
2     known(Attribute, Value), !.
3 ask_if(Attribute, Value) :-
4     \+ known(Attribute, _),
5     ask_question(Attribute),
6     known(Attribute, Value).

A rule in the knowledge base then looks like this:

1 hypothesis(diagnose_internet_issue) :-
2     ask_if(router_lights, off),
3     ask_if(cables_plugged_in, yes).

Improving Readability with Custom Operators

To make rules more readable for non-programmers, Prolog allows you to define custom operators using op/3. For example, we can define operators like if, then, and, and is to write rules in a natural-language-like syntax:

1 :- op(900, xfx, then).
2 :- op(800, xfy, and).
3 :- op(700, xfx, is).
4 
5 % Now we can write rules like:
6 % rule 1: if router_lights is off and cables are connected then problem is router_power.

We can then write a simple parser/meta-interpreter to evaluate these custom-cased rules.

Explanation Facilities

One of the defining features of an expert system is its ability to explain its reasoning.

  • “How” Explanations: Explain how the system reached a specific conclusion. This is done by traversing the proof tree or rule firing history and listing the rules and facts that succeeded.
  • “Why” Explanations: Explain why the system is asking a particular question. When the system prompts the user with a question, the user can type why. The system responds by showing the current rule it is trying to satisfy and the subgoal chain.

In our simplified shell.pl implementation, we provide a basic “How” explanation via explain/1, which fetches the pre-written hypothesis_explanation/2 text associated with the successful hypothesis. In a more advanced system, we can integrate the proof-tree meta-interpreter (from the previous chapter) to dynamically construct and display step-by-step explanations.

Case Study: A Wine Selection Advisor

To demonstrate rule-based reasoning in a practical domain, we look at the wine_advisor project. This advisor acts as a digital sommelier, recommending wines by matching food pairings and flavor profiles.

The system utilizes two distinct categories of rules:

  1. Meal Pairing Rules: Determining which color of wine (red, white, rose) matches the food type (fish, red meat, dessert).
  2. Flavor Preference Rules: Matching the user’s preference with the wine’s characteristics. The preference may be a body atom (bold, moderate, light) or a sweetness atom (sweet, dry).
Architecture diagram for the Wine Advisor example
Figure 11. Architecture diagram for the Wine Advisor example

The wine_advisor project implements a rule-based wine recommender. Here is the file wine_advisor/prolog/wine_rules.pl:

 1  %% wine_rules.pl - Wine selection expert system
 2 :- module(wine_rules, [
 3     recommend_wine/3
 4 ]).
 5 
 6 %% recommend_wine(+MealType, +Preference, -Wine)
 7 %% Preference may be a body atom (bold, moderate, light), a sweetness
 8 %% atom (sweet, dry), or 'any' matching all wines on both dimensions.
 9 recommend_wine(MealType, Preference, Wine) :-
10     wine(Wine, Color, Body, Sweetness),
11     meal_pairs_with(MealType, Color),
12     preference_matches(Preference, Body),
13     sweetness_matches(Preference, Sweetness).
14 
15 %% Wine database: wine(Name, Color, Body, Sweetness)
16 wine(cabernet_sauvignon, red, full, dry).
17 wine(merlot, red, medium, dry).
18 wine(pinot_noir, red, light, dry).
19 wine(chardonnay, white, full, dry).
20 wine(sauvignon_blanc, white, light, dry).
21 wine(riesling, white, light, sweet).
22 wine(champagne, white, light, dry).
23 wine(rose, rose, light, dry).
24 wine(port, red, full, sweet).
25 
26 %% Meal pairing rules
27 meal_pairs_with(red_meat, red).
28 meal_pairs_with(poultry, red).
29 meal_pairs_with(poultry, white).
30 meal_pairs_with(fish, white).
31 meal_pairs_with(seafood, white).
32 meal_pairs_with(pasta, red).
33 meal_pairs_with(dessert, white).
34 meal_pairs_with(cheese, red).
35 
36 %% Preference matching: body dimension.
37 %% Sweetness preferences (sweet, dry) do not constrain body.
38 preference_matches(bold, full).
39 preference_matches(moderate, medium).
40 preference_matches(light, light).
41 preference_matches(sweet, _).
42 preference_matches(dry, _).
43 preference_matches(any, _).
44 
45 %% Sweetness matching.
46 %% Body preferences (bold, moderate, light) do not constrain sweetness.
47 sweetness_matches(sweet, sweet).
48 sweetness_matches(dry, dry).
49 sweetness_matches(bold, _).
50 sweetness_matches(moderate, _).
51 sweetness_matches(light, _).
52 sweetness_matches(any, _).

This is a behavior change from the first edition of this chapter: body and sweetness are now orthogonal dimensions. recommend_wine(red_meat, bold, W) returns both cabernet_sauvignon and port because a bold preference constrains only body, not sweetness, and port is a full-bodied red. recommend_wine(dessert, sweet, W) returns only [riesling] because dessert pairs with white wine and only riesling is both white and sweet.

Case Study: A Pluggable Knowledge Base

As a final case study, we use the shell’s load_kb/1 support with the bundled expert_shell/prolog/sample_kb.pl, a small wine-selection knowledge base. Four hypotheses (serve_port, serve_riesling, serve_cabernet, serve_sauvignon_blanc) are driven by shell:check/1 conditions that read known/2 answers or prompt the user:

 1  %% sample_kb.pl - Wine-selection knowledge base for expert_shell
 2  %%
 3  %% If_/then_ style hypotheses: each condition is checked via
 4  %% shell:check/1, which reuses known/2 answers or asks the user.
 5  %% shell:load_kb/1 asserts these clauses into module `user` so the
 6  %% shell can find them via user:hypothesis/1 and
 7  %% user:hypothesis_explanation/2.
 8 
 9 hypothesis(serve_port) :-
10     shell:check(meal == dessert),
11     shell:check(sweet_preference == yes).
12 
13 hypothesis(serve_riesling) :-
14     shell:check(sweet_preference == yes),
15     \+ shell:known(meal, dessert).
16 
17 hypothesis(serve_cabernet) :-
18     shell:check(meal == red_meat),
19     shell:check(bold_preference == yes).
20 
21 hypothesis(serve_sauvignon_blanc) :-
22     shell:check(meal == fish),
23     shell:check(light_preference == yes).
24 
25 hypothesis_explanation(serve_port,
26     'A dessert meal plus a sweet preference points to port.').
27 hypothesis_explanation(serve_riesling,
28     'A sweet preference without a dessert meal suggests riesling.').
29 hypothesis_explanation(serve_cabernet,
30     'Red meat plus a bold preference points to cabernet sauvignon.').
31 hypothesis_explanation(serve_sauvignon_blanc,
32     'Fish plus a light preference points to sauvignon blanc.').

Running the Wine Knowledge Base

Load the KB and run a consultation in the SWI-Prolog REPL. Here we supply the answers programmatically with provide_answer/2:

1 ?- shell:load_kb('prolog/sample_kb.pl'),
2    shell:provide_answer(meal, dessert),
3    shell:provide_answer(sweet_preference, yes),
4    shell:consult_expert(Conclusion).
5 Conclusion = serve_port.
6 
7 ?- shell:explain(serve_port).
8 Conclusion: serve_port
9 Reasoning: A dessert meal plus a sweet preference points to port.

This case study demonstrates the power of separating the inference logic (defined in the shell) from the domain rules (loaded with load_kb/1), allowing you to build new expert systems simply by swapping in different rule files.

Optional Practice Problems

  1. Why Explanations: Extend the expert_shell system to support why queries. When the system asks the user a question, the user should be able to type why, and the system should print the rules that are currently being evaluated.
  2. Semi-Sweet Wines: In the wine_advisor project, add a semi-sweet preference and at least one semi-sweet wine to the database, and write a test asserting it is recommended for the right meals.