LLM Logic Guardrails Using a Neuro-Symbolic Pattern
Large Language Models (LLMs) are incredibly capable when it comes to open-ended generation, creative writing, and basic translation. However, they suffer from deep structural limitations that make them risky for high-stakes business logic:
- Hallucinations: They generate plausible-sounding but entirely fabricated facts.
- Inability to guarantee constraints: An LLM cannot be mathematically guaranteed to follow instructions or comply with safety bounds.
- Arithmetic errors: They often struggle with exact math or calculating cumulative percentages.
In critical domains like financial planning, computational law, or medical diagnosis, we cannot display raw LLM output to end users. We need a way to filter, validate, and verify the model’s outputs.
This chapter presents a neuro-symbolic pattern that uses a symbolic logic engine (Prolog) as a strict compliance guardrail on top of a neural model (the LLM).

The Neuro-Symbolic Guardrail Pattern
The architecture operates as a pipeline:
- Prompt & Structure: The user inputs a prompt. The LLM is instructed to return its recommendation in a structured format, such as JSON.
- Parsing & Bridge: The application parses the LLM’s JSON output and passes it across a bidirectional bridge (Janus) into a SWI-Prolog environment.
- Prolog Constraints: A predefined Prolog policy database contains strict rules (such as arithmetic checks, age-based risk limits, and exclusions).
- Validation Query: Prolog evaluates the recommendation. It returns an empty list if the recommendation is valid, or a list of specific error explanations if any constraints are violated.
- Action: If valid, the recommendation is shown to the user. If invalid, the system rejects it or feeds the error descriptions back to the LLM to request a corrected response (self-repair loop).
This design ensures that even if the LLM attempts to recommend high-risk assets to a senior citizen or creates a portfolio that doesn’t sum to 100%, the symbolic engine mathematically prevents it from reaching the user.
Prolog Guardrail Rules
We define the constraints using a Prolog module that parses the JSON string into a dict and evaluates it against multiple rules using findall/3. Parsing is guarded: invalid JSON yields a policy error string describing the parse failure, and missing keys yield a missing-keys error string. Only well-formed input with all required keys reaches the policy rules.
Here is the implementation in source-code/llm_logic_guardrails/prolog/guardrails.pl:
1 :- module(guardrails, [
2 validate_portfolio_json/2
3 ]).
4
5 :- use_module(library(http/json)).
6
7 /** <module> Financial Guardrails Module
8 *
9 * Validates investment recommendations generated by LLMs using
10 * symbolic rules.
11 * Input is a JSON string representing the recommendation:
12 * {
13 * "client_age": 70,
14 * "risk_tolerance": "low",
15 * "allocations": {
16 * "stocks": 20,
17 * "bonds": 50,
18 * "crypto": 10,
19 * "cash": 20
20 * }
21 * }
22 */
23
24 % Main entry point. Parses JSON and checks all policy rules.
25 % Returns a list of error atoms. If empty, the portfolio is valid.
26 validate_portfolio_json(JsonString, Errors) :-
27 setup_call_cleanup(
28 open_string(JsonString, Stream),
29 catch(
30 json_read_dict(Stream, Dict),
31 JsonError,
32 Dict = policy_error(JsonError)
33 ),
34 close(Stream)
35 ),
36 ( Dict = policy_error(JsonError)
37 -> message_to_string(JsonError, Msg),
38 format(string(ErrStr), "Invalid JSON input: ~w", [Msg]),
39 Errors = [ErrStr]
40 ; missing_required_keys(Dict, Missing), Missing \= []
41 -> atomic_list_concat(Missing, ', ', MissingStr),
42 format(string(ErrStr),
43 "Missing required keys in recommendation: ~w", [MissingStr]),
44 Errors = [ErrStr]
45 ; findall(Error, check_policy(Dict, Error), Errors)
46 ).
47
48 % Keys that every recommendation must provide. A key is "missing"
49 % when it is absent from the dict or its value is unbound (fresh var).
50 % Total: get_dict/2 can throw on absent keys, so wrapped in catch/3.
51 missing_required_keys(Dict, Missing) :-
52 findall(Key,
53 ( member(Key, [client_age, risk_tolerance, allocations]),
54 \+ has_value(Dict, Key)
55 ),
56 Missing).
57
58 % has_value(+Dict, +Key) is semidet. Total: fails instead of throwing
59 % when the key is absent or the value is a fresh variable.
60 has_value(Dict, Key) :-
61 catch(get_dict(Key, Dict, Value), _, fail),
62 nonvar(Value).
63
64 % risk_value(+Dict, -RiskAtom)
65 % Extract risk_tolerance and canonicalise to a downcased atom, so the
66 % policy rules work whether the value arrived as a Prolog atom
67 % (json_read_dict default) or as a string (Janus / value_string_as).
68 risk_value(Dict, RiskAtom) :-
69 has_value(Dict, risk_tolerance),
70 Risk0 = Dict.get(risk_tolerance, ""),
71 ( string(Risk0)
72 -> downcase_atom_(Risk0, RiskAtom)
73 ; atom(Risk0)
74 -> downcase_atom(Risk0, RiskAtom)
75 ; RiskAtom = unknown
76 ).
77
78 % Small norm/2-style conversion: string -> downcased atom.
79 downcase_atom_(String, Atom) :-
80 string_lower(String, Lower),
81 atom_string(Atom, Lower).
82
83 % --- Policy Rules ---
84
85 % 1. Total sum must be exactly 100%
86 check_policy(Dict, "Total allocation must sum to exactly 100%") :-
87 get_allocations(Dict, Stocks, Bonds, Crypto, Cash),
88 Total is Stocks + Bonds + Crypto + Cash,
89 Total \= 100.
90
91 % 2. If client is senior (> 65), high-risk assets (stocks + crypto) must
92 % be <= 30%
93 check_policy(Dict, Error) :-
94 has_value(Dict, client_age),
95 Age = Dict.get(client_age, 0),
96 Age > 65,
97 get_allocations(Dict, Stocks, _, Crypto, _),
98 HighRiskAllocation is Stocks + Crypto,
99 HighRiskAllocation > 30,
100 format(string(Error),
101 "Senior client (age ~w) has ~w% in high-risk assets (max: 30%)",
102 [Age, HighRiskAllocation]).
103
104 % 3. If risk tolerance is 'low', crypto allocation must be 0%
105 check_policy(Dict,
106 "Low risk tolerance portfolio cannot contain speculative crypto assets") :-
107 risk_value(Dict, low),
108 get_allocations(Dict, _, _, Crypto, _),
109 Crypto > 0.
110
111 % 4. If risk tolerance is 'low', conservative assets (bonds + cash) must
112 % be >= 50%
113 check_policy(Dict, Error) :-
114 risk_value(Dict, low),
115 get_allocations(Dict, _, Bonds, _, Cash),
116 Conservative is Bonds + Cash,
117 Conservative < 50,
118 format(string(Error),
119 "Low risk tolerance requires at least 50% in conservative assets (currently ~w%)", [Conservative]).
120
121 % 5. No asset allocation can be negative
122 check_policy(Dict, "Asset allocations cannot be negative") :-
123 get_allocations(Dict, Stocks, Bonds, Crypto, Cash),
124 (Stocks < 0 ; Bonds < 0 ; Crypto < 0 ; Cash < 0).
125
126 % --- Helper to extract allocations safely with default 0 if missing ---
127 get_allocations(Dict, Stocks, Bonds, Crypto, Cash) :-
128 ( has_value(Dict, allocations)
129 -> Allocations = Dict.get(allocations, _{})
130 ; Allocations = _{}
131 ),
132 Stocks = Allocations.get(stocks, 0),
133 Bonds = Allocations.get(bonds, 0),
134 Crypto = Allocations.get(crypto, 0),
135 Cash = Allocations.get(cash, 0).
Python Verification Harness
We use the Janus Python-Prolog bridge to load the Prolog rules and run our validations programmatically.
Here is the code in source-code/llm_logic_guardrails/verify_llm.py:
1 import json
2 import sys
3 import janus_swi as janus
4
5 # Define simulated LLM portfolio recommendations
6 VALID_RECOMMENDATION = {
7 "client_age": 70,
8 "risk_tolerance": "low",
9 "allocations": {
10 "stocks": 10,
11 "bonds": 60,
12 "crypto": 0,
13 "cash": 30
14 }
15 }
16
17 INVALID_RECOMMENDATION = {
18 "client_age": 72,
19 "risk_tolerance": "low",
20 "allocations": {
21 "stocks": 40, # Violates: High-risk stocks + crypto (50%) > 30% for age > 65
22 "bonds": 30,
23 "crypto": 10, # Violates: Low risk tolerance cannot have crypto
24 "cash": 10 # Violates: Bonds + Cash (40%) < 50% for low risk
25 } # Violates: Sum is 40 + 30 + 10 + 10 = 90% (not 100%)
26 }
27
28 FAILURES = []
29
30 def check(condition, message):
31 if condition:
32 print(f" [PASS] {message}")
33 else:
34 print(f" [FAIL] {message}")
35 FAILURES.append(message)
36
37 def test_recommendation(name, recommendation_dict):
38 print(f"\nTesting recommendation: {name}")
39 print("LLM Output JSON:")
40 json_str = json.dumps(recommendation_dict, indent=2)
41 print(json_str)
42
43 # Query Prolog guardrails
44 query_str = "validate_portfolio_json(Json, Errors)"
45 res = janus.query_once(query_str, {"Json": json_str})
46
47 errors = res["Errors"]
48 err_strs = [e.decode('utf-8') if isinstance(e, bytes) else str(e) for e in errors]
49
50 if not err_strs:
51 print("Guardrail Check Passed: Recommendation is SAFE.")
52 else:
53 print("Guardrail Check Failed! Violations found:")
54 for err_str in err_strs:
55 print(f" - {err_str}")
56 return err_strs
57
58 def main():
59 print("Consulting Prolog guardrail rules...")
60 janus.consult("prolog/guardrails.pl")
61
62 # Test valid case: all five rules must be silent
63 valid_errors = test_recommendation(
64 "Valid Senior Low-Risk Portfolio", VALID_RECOMMENDATION)
65 check(valid_errors == [],
66 "valid recommendation produces no violations")
67
68 # Test invalid case: all four expected violations must appear
69 invalid_errors = test_recommendation(
70 "Invalid Senior Low-Risk Portfolio", INVALID_RECOMMENDATION)
71 joined = "\n".join(invalid_errors)
72 check("Total allocation must sum to exactly 100%" in joined,
73 "violation: total allocation sum")
74 check("high-risk assets" in joined,
75 "violation: senior high-risk allocation")
76 check("speculative crypto assets" in joined,
77 "violation: crypto with low risk tolerance")
78 check("at least 50% in conservative assets" in joined,
79 "violation: low-risk conservative minimum")
80 check(len(invalid_errors) == 4,
81 "exactly four violations reported (asset negativity not triggered)")
82
83 if FAILURES:
84 print(f"\n{len(FAILURES)} assertion(s) FAILED")
85 sys.exit(1)
86 print("\nAll assertions passed.")
87 sys.exit(0)
88
89 if __name__ == '__main__':
90 main()
Running the Verification Script
You can execute the verification harness using uv. Navigate to source-code/llm_logic_guardrails and run:
1 $ uv run verify_llm.py
The console output demonstrates how the valid portfolio passes silently, whereas the invalid portfolio produces a clear list of the exact constraints violated:
1 Consulting Prolog guardrail rules...
2
3 Testing recommendation: Valid Senior Low-Risk Portfolio
4 LLM Output JSON:
5 {
6 "client_age": 70,
7 "risk_tolerance": "low",
8 "allocations": {
9 "stocks": 10,
10 "bonds": 60,
11 "crypto": 0,
12 "cash": 30
13 }
14 }
15 Guardrail Check Passed: Recommendation is SAFE.
16 [PASS] valid recommendation produces no violations
17
18 Testing recommendation: Invalid Senior Low-Risk Portfolio
19 LLM Output JSON:
20 {
21 "client_age": 72,
22 "risk_tolerance": "low",
23 "allocations": {
24 "stocks": 40,
25 "bonds": 30,
26 "crypto": 10,
27 "cash": 10
28 }
29 }
30 Guardrail Check Failed! Violations found:
31 - Total allocation must sum to exactly 100%
32 - Senior client (age 72) has 50% in high-risk assets (max: 30%)
33 - Low risk tolerance portfolio cannot contain speculative crypto assets
34 - Low risk tolerance requires at least 50% in conservative assets (currently 40%)
35 [PASS] violation: total allocation sum
36 [PASS] violation: senior high-risk allocation
37 [PASS] violation: crypto with low risk tolerance
38 [PASS] violation: low-risk conservative minimum
39 [PASS] exactly four violations reported (asset negativity not triggered)
40
41 All assertions passed.
The script asserts the expected behavior programmatically and exits with code 1 if any [FAIL] line fires, so it doubles as a test harness. A pure-Prolog plunit suite covers the same rules offline; run it with make test.
Key Design Decisions
Why use Janus and Python instead of writing everything in Prolog? While SWI-Prolog can handle network requests, parsing, and LLM integrations directly, Python has a much wider ecosystem of libraries for building production-grade LLM applications (such as LangChain, LlamaIndex, and the official Google GenAI SDK). Janus offers an ideal compromise: we can write our API plumbing and LLM pipelines in Python, but delegate the critical verification steps to Prolog, keeping our policy logic clean and declarative.
Using findall/3 for complete error lists. In typical Prolog programs, we rely on backtracking to find a solution. If a validation failed, Prolog would normally return false on the first rule violation and halt. However, when returning errors to a user or feeding them back to an LLM for correction, we want all violations reported at once. Using findall(Error, check_policy(Dict, Error), Errors) forces the engine to evaluate every safety check and collect all generated error strings into a single list. The code first classifies risk with risk_value/2, which canonicalises an atom-or-string value to a downcased atom, so the low-risk rules actually fire. Rules 3 and 4 once compared risk_tolerance with Risk == "low", which never matched because JSON parsing yields strings, not atoms; this was the bug fix.
Optional Practice Problems
- Blacklist Guardrail: In the
llm_logic_guardrailsproject, write a logic guardrail that scans the LLM output for forbidden names or sensitive words and flags the output if any are found. - Contradiction Detection: Implement a rule in
guardrails.plthat checks if the LLM output asserts a fact that contradicts any of the safe facts stored in the Prolog knowledge base.