Blog › ICP guides
Prolog developer on retainer: SWI-Prolog, logic programming, CLP(FD), and knowledge base engineering on monthly retainer
September 30, 2026 · ~19 min read
A healthcare staffing company’s SWI-Prolog scheduling engine was producing wrong answers. Some valid schedules were being rejected as having no solution. Some invalid schedules — physician assignments that violated on-call constraints — were being accepted. And some queries timed out entirely on inputs that had worked correctly the week before. Three separate issues were causing all three failure modes simultaneously. The Prolog developer on retainer diagnosed them in sequence. First: a ! (cut) placed inside the innermost assignment rule was eliminating all backtracking alternatives once the first physician candidate was found. When the first candidate later failed a constraint checked in the calling clause — an attending requirement that demanded board certification the first candidate lacked — Prolog could not backtrack past the cut to try the next candidate, so the query failed with “no solution” even though valid certified candidates existed. Second: findall/3 was being used to collect physician assignment options; because findall always succeeds (returning an empty list when no solutions exist), queries over shifts with no eligible physicians returned empty assignment lists rather than failing, and the scheduler was treating empty lists as valid shift coverage. Third: use_module(library(clpfd)) had been removed from one module during a refactor, causing CLP(FD) constraints in that module to fall back to arithmetic evaluation, which raised instantiation_error on unbound variables at constraint-check time rather than posting the constraint for propagation.
Each fix was targeted and small. The cut was removed from the assignment rule and replaced with once/1 scoped to the intended deterministic subgoal, so the calling clause could backtrack over assignment candidates while the inner search remained deterministic where intended. The findall calls were replaced with bagof calls with explicit failure handling: bagof(Candidate, eligible_physician(Candidate, Shift), Candidates) -> true ; fail, so that shifts with no eligible physicians caused the scheduler to backtrack and try a different overall assignment pattern rather than accepting empty coverage. The missing use_module(library(clpfd)) was restored. The combined diff was under forty lines of Prolog. The scheduler passed all test cases and processed a full month of shift assignments without a timeout or incorrect output.
Prolog fundamentals: facts, rules, unification, and backtracking
Prolog programs consist of clauses: facts, rules, and queries. A fact asserts an unconditional truth: physician(dr_smith). certified(dr_smith, cardiology). available(dr_smith, monday_morning). A rule defines a conditional relationship: eligible_physician(P, Shift) :- physician(P), certified(P, specialty(Shift)), available(P, Shift). A query asks whether a goal can be proved given the current knowledge base: ?- eligible_physician(Who, monday_morning). Prolog attempts to prove the query by searching for clauses whose heads unify with the goal, then recursively proving the body of each matching clause. Unification is the pattern matching at the core of this search: two terms unify if they are identical, if one is an unbound variable (which gets bound to the other), or if they are compound terms with the same functor and arity whose arguments pairwise unify. f(X, 2) = f(1, Y) succeeds with X = 1, Y = 2. f(1, 2) = f(1, 3) fails.
When a goal can be proved in more than one way — because multiple clauses match — Prolog creates a choice point: a saved state to which execution can return (backtrack) if the current branch leads to failure. This backtracking search is the mechanism that makes Prolog powerful for search and constraint problems, but also the source of its most common production bugs. The cut operator ! eliminates choice points: when ! is reached during execution, all choice points created since the parent goal was called are discarded. Any subsequent failure cannot backtrack past the cut into the alternatives it eliminated. In the healthcare scheduler, the cut was inside the rule for the innermost assignment subgoal: assign_physician(Physician, Shift) :- eligible_physician(Physician, Shift), !. This made assign_physician/2 deterministic — it returned the first eligible physician and committed to it. But the calling rule that checked the board-certification constraint saw the committed assignment, tested the constraint, found it failed, and then could not backtrack to try the second or third eligible physician because the cut had eliminated those choice points. The fix was to remove the cut from the assignment rule and use once(eligible_physician(Physician, Shift)) only in contexts where determinism was actually required: at the top-level schedule-generation call, not inside the backtracking search itself.
List processing in Prolog uses the [Head|Tail] unification pattern. [H|T] = [1, 2, 3] unifies with H = 1, T = [2, 3]. Recursive list predicates follow the standard pattern: a base case for the empty list, and a recursive case for [H|T]. my_length([], 0). my_length([_|T], N) :- my_length(T, N1), N is N1 + 1. The built-in predicates length/2, append/3, member/2, last/2, nth0/3 (zero-indexed), nth1/3 (one-indexed), msort/2 (sort preserving duplicates), sort/2 (sort removing duplicates), permutation/2, flatten/2, and list_to_set/2 are available in SWI-Prolog without import. The maplist/2, maplist/3, include/3, exclude/3, foldl/4, and aggregate_all/3 higher-order predicates in library(apply) and library(aggregate) implement the standard functional list combinators using call/N for goal application. A retainer engagement optimizing list processing identifies cases where recursive predicates accumulate without tail-recursion (causing stack growth proportional to list length rather than constant stack depth) and redesigns them with accumulator variables: sum_list(List, Sum) :- sum_list_(List, 0, Sum). sum_list_([], Acc, Acc). sum_list_([H|T], Acc, Sum) :- Acc1 is Acc + H, sum_list_(T, Acc1, Sum).
The meta-predicates findall/3, bagof/3, and setof/3 collect solutions. findall(Template, Goal, Bag) always succeeds: if Goal has solutions, Bag is the list of Template instantiations for each solution; if Goal has no solutions, Bag is []. This makes findall safe in the sense of always succeeding, but dangerous in contexts where the absence of solutions should trigger backtracking: findall(P, eligible_physician(P, Shift), []) succeeds (with empty list) when no physician is eligible for the shift, so a calling predicate that checks only whether the findall succeeded will incorrectly proceed. bagof(Template, Goal, Bag) fails when Goal has no solutions, which is the correct behavior for gap detection. bagof also groups solutions by unbound free variables in Goal that are not part of Template; to suppress this grouping, the existential quantifier syntax bagof(Template, X^Goal, Bag) asserts that X is existentially quantified and should not be used for grouping. setof/3 behaves like bagof but returns solutions sorted and deduplicated, which is appropriate for canonical answer sets where solution order is irrelevant and duplicates are invalid.
CLP(FD) constraint programming, DCG grammars, and SWI-Prolog services
Constraint Logic Programming over Finite Domains — library(clpfd) in SWI-Prolog — is the mechanism that makes combinatorial scheduling, assignment, and optimization problems tractable in Prolog. Without CLP(FD), a scheduling query generates all possible assignments and then tests each against constraints: generate-and-test. For a 20-person schedule with 5 shifts, each with 4 possible assignees, the generate-and-test approach explores up to 4^20 combinations before finding a valid assignment or concluding that none exists. With CLP(FD), constraints are posted as relationships between variables before enumeration begins. Each constraint reduces the variable domains: X in 1..8, Y in 1..8, X #\= Y posts that X and Y are distinct integers between 1 and 8. When labeling begins (labeling([ff], [X, Y])), the constraint propagation engine has already eliminated values that violate posted constraints from each variable’s domain, so enumeration searches a much smaller space.
The core CLP(FD) arithmetic constraints are #=/2 (equal), #\=/2 (not equal), #</2 (less than), #>/2 (greater than), #=</2 (less than or equal), #>=/2 (greater than or equal). Domain constraints: X in 1..10 (X in closed range), X in 1..10 \/ 20..30 (X in union of ranges), X ins 1..10 (list of variables all in range), domain(Vars, Low, High). Global constraints: all_different(Vars) (all variables take distinct values, using arc consistency propagation more efficient than pairwise #\=), sum(Vars, #=, Total) (sum constraint), global_cardinality(Vars, Pairs) (each value from the key list appears exactly the count in the corresponding value). Labeling strategies: labeling([], Vars) labels in declaration order with ascending values; labeling([ff], Vars) uses fail-first, selecting the variable with the smallest remaining domain first (typically reduces search by pruning constrained variables early); labeling([min(Obj)], Vars) minimizes the objective variable. A retainer engagement designing a CLP(FD) system audits whether constraints are posted before labeling (posting constraints after labeling has already committed some variables defeats propagation), whether all_different is used instead of pairwise #\= (all_different’s arc consistency is exponentially faster for large sets), and whether the labeling strategy matches the problem structure.
Definite Clause Grammars (DCGs) are Prolog’s grammar notation. A DCG rule sentence --> noun_phrase, verb_phrase. expands to a Prolog clause with two extra arguments: an input difference list. The expansion is: sentence(S0, S) :- noun_phrase(S0, S1), verb_phrase(S1, S). Terminal symbols are enclosed in list brackets: noun_phrase --> [the], noun. DCGs are invoked with phrase(sentence, InputList) or phrase(sentence, InputList, Rest). The pushback notation sentence --> noun_phrase, verb_phrase, sentence_rest//0. with // arity passes the current position to auxiliary nonterminals for lookahead. DCGs are used in SWI-Prolog not only for natural language parsing but for protocol parsing, configuration file parsing, and any structured text format that benefits from backtracking and grammar composition. A retainer engagement covering DCG authorship designs grammars that use !/0 cuts judiciously within alternatives — the same cut-scoping concerns that affect regular Prolog rules apply equally to DCG rules — and handles left recursion by refactoring to right-recursive rules with accumulator arguments.
SWI-Prolog’s HTTP server library enables production REST services in Prolog. A minimal service: load library(http/thread_httpd) and library(http/http_dispatch), declare routes with the @http_handler annotation: :- http_handler(root(api/schedule), handle_schedule, [method(get)]), and implement the handler: handle_schedule(Request) :- http_parameters(Request, [date(Date, [])], []), compute_schedule(Date, Schedule), reply_json_dict(_{date: Date, schedule: Schedule}). Thread-based request handling is built in; the server creates a thread pool sized to the CPU count by default. For JSON, library(http/json) provides json_read_dict/2 and json_write_dict/3; for SPARQL queries against RDF triple stores, library(semweb/sparql_client) provides sparql_query/3. A retainer engagement building SWI-Prolog HTTP services designs the request/response cycle so that Prolog’s backtracking does not escape into the HTTP handler (each handler call must produce exactly one response), using once/1 around the scheduling call and formatting failures as 404 or 409 responses rather than letting Prolog’s failure propagate.
How HourTab tracks Prolog developer retainer hours
Prolog retainers produce an invisibility problem with a unique character. The work product of a Prolog retainer engagement — restructuring a rule to remove a misplaced cut, replacing a findall with a bagof, restoring a missing use_module declaration, redesigning a labeling strategy — is measured in lines changed, not hours worked. The forty-line fix that made the healthcare scheduler correct required understanding the Prolog execution model (choice points, cut scope, backtracking), the meta-predicate semantics (why findall succeeds on empty), and the CLP(FD) constraint lifecycle (why constraints must be posted before labeling). A client who sees the diff without that context sees “forty lines of Prolog and fourteen hours billed.” The gap between the artifact (the diff) and the value delivered (a scheduler that no longer produces incorrect results, times out, or silently accepts invalid coverage) is the communication problem that every Prolog retainer faces.
HourTab gives Prolog developers a public retainer-hours URL they paste into the first message of every client engagement. The client opens the URL and sees the burn-down: hours purchased, hours used, hours remaining, and a work log of every session. For Prolog retainers specifically, the work log entries carry more information than the burn-down chart alone can convey. Each entry should name the Prolog mechanism involved (cut/! choice point scope, findall vs bagof failure detection, CLP(FD) ins constraint propagation, labeling/2 ff strategy, DCG phrase/2 grammar invocation, module declaration namespace isolation, assert/retract dynamic update, tabling memoization for recursive predicates, SPARQL library(semweb) query), the specific predicate and module, the diagnostic approach (SWI-Prolog trace/0 showing the choice point elimination after the first successful assignment candidate; length/2 profiling showing 10’ list operations on findall empty-list returns; library(clpfd) instantiation_error thrown at constraint check because module import was missing), the change made and the rationale (cut removed from assignment rule and replaced with once/1 at the calling level because Prolog’s cut scope extends to the parent clause — the cut was eliminating choice points the calling rule needed for backtracking over physician candidates; findall replaced with bagof because findall always succeeds — an empty list is a valid findall result, not a signal to backtrack), and the before-and-after observable metric (scheduler incorrect-result rate: 15% of inputs → 0%; scheduling gap detection rate: 0% with findall → 100% with bagof; query time for 20-person schedule: 4 hours → 12 seconds with CLP(FD)). Entries at that level of specificity turn an invoice line item into a documented systems improvement the client can reference when justifying the retainer to stakeholders who do not know Prolog.
Prolog retainers share the “invisible reasoning work” communication challenge with other logic and functional language retainers. An Elixir developer on retainer for OTP supervisor tree design produces no artifact between process restarts — the value is the absence of incidents. An OCaml developer on retainer for module functor composition produces types that verify correctness at compile time but are invisible at runtime. The Prolog developer’s work — cut placement that determines search completeness, meta-predicate selection that determines failure propagation, CLP(FD) constraint formulation that determines whether a problem is tractable — produces outcomes that are equally invisible between incidents. HourTab’s work log entries name the reasoning artifact (the specific predicate, the specific mechanism, the specific diagnostic output) and connect it to an observable outcome (a rate, a time, a correctness percentage), so that the client understands what changed and why it mattered without needing to understand the Prolog execution model in detail.
Track Prolog developer retainer hours without the status emails
HourTab gives Prolog developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Prolog developer retainers
What does a Prolog developer on retainer typically do?
A Prolog developer on monthly retainer provides ongoing knowledge base maintenance (fact and rule authorship, cut/! placement for determinism control, module declarations for predicate namespace isolation, assert/retract dynamic knowledge base updates), search architecture (findall/bagof/setof selection for gap detection vs collection, call/N higher-order dispatch, aggregate_all/3 for efficient summarization, once/1 for deterministic subgoal calls), CLP(FD) constraint programming (ins/in domain setup, #= #\= #< #> #=< #>= constraint posting, all_different/1 permutation constraints, labeling/2 ff/min/max strategy selection, global_cardinality/2 cardinality constraints), DCG grammar authorship (phrase/2 invocation, pushback notation for lookahead, difference list optimization), and SWI-Prolog HTTP service implementation (http_server/http_dispatch REST endpoints, @http_handler route annotations, reply_json_dict JSON responses, SPARQL library(semweb) integration).
What Prolog work is most underlogged in a retainer?
Cut/! placement audit (diagnosing that a cut in an inner rule was eliminating choice points the calling rule needed for backtracking over valid candidates; restructuring to use once/1 at the calling level; solution completeness: 85% → 100%; 10–20 hours invisible in rule restructuring), findall/bagof selection (replacing findall with bagof to make absent solutions fail rather than return empty lists; gap detection rate: 0% → 100%; 6–14 hours invisible in meta-predicate replacements), and CLP(FD) labeling strategy (redesigning generate-and-test to post constraints before labeling so propagation eliminates incompatible domains before enumeration; query time: 4 hours → 12 seconds; 12–24 hours invisible in constraint formulation).
What are typical Prolog developer retainer rates?
Entry-level Prolog developers (1–2 years, basic facts/rules/queries, unification, [H|T] list recursion, findall/3, is/2 arithmetic) bill at $75–$130/hr. Mid-level Prolog engineers (2–4 years, cut/! scope design, bagof/setof/findall selection, module systems, CLP(FD) constraint formulation, DCG grammars, assert/retract, call/N) bill at $120–$215/hr. Senior Prolog architects (4–8 years, SWI-Prolog HTTP services, SPARQL/semweb integration, CLP(FD) labeling strategy optimization, tabling/memoization, constraint reification, full knowledge engineering systems) bill at $175–$315/hr. Monthly retainer ranges: $2,500–$6,000/mo for advisory retainers (15–25 hrs), $8,000–$22,000/mo for full development engagements.
What should a Prolog developer retainer agreement include?
A Prolog developer retainer agreement should specify: knowledge base scope (fact/rule authorship, cut/! placement audit, module declaration management, assert/retract dynamic update, cut vs once/1 vs \+/1 determinism control), search scope (findall/bagof/setof selection for failure semantics, call/N higher-order dispatch, aggregate_all/3 summarization, accumulator tail-recursion optimization), CLP scope (CLP(FD) ins/in constraint domain setup, global constraint all_different/global_cardinality, labeling/2 strategy selection, constraint posting before labeling), HTTP/SPARQL scope (SWI-Prolog http_server/http_dispatch REST implementation, @http_handler annotations, JSON reply_json_dict serialization, SPARQL library(semweb) queries), and hour logging format (SWI-Prolog version, predicate name and module, diagnostic trace output, fix applied and rationale, before/after correctness or performance metric).
How should Prolog developer retainer hours be logged?
Log each Prolog retainer session with: advisory category (cut/! choice point scope, findall/bagof/setof selection, CLP(FD) ins constraint formulation, labeling/2 ff strategy, DCG phrase/2 grammar, module namespace isolation, assert/retract dynamic update, call/N higher-order, aggregate_all/3 summarization, http_server REST endpoint, SPARQL semweb query, tabling memoization, constraint reification), specific predicate and module, diagnostic output (SWI-Prolog trace/0 showing choice point elimination after first assignment; findall empty-list return masking shift with no eligible physicians; library(clpfd) instantiation_error at constraint check), fix applied and rationale (cut removed because Prolog cut scope extends to parent clause — cut in inner rule eliminated parent's backtracking choices; findall replaced with bagof because findall always succeeds on empty — empty list is a valid result, not a failure signal), and before/after metric (solution correctness rate: 85% → 100%; gap detection: 0% → 100%; query time for 20-person schedule: 4 hours → 12 seconds). Include SWI-Prolog version and module name.