FrameX-Doc
FrameX is an offline, domain-neutral frame-logic engine with a Python host for tools and language models, developed by the Chair of Data Science and Natural Language Processing at the University of St. Gallen. It derives conclusions from facts and rules, answers queries and explains its reasoning.
In a hybrid system, models read, phrase and propose; rules decide which evidence counts and what may happen next. This documentation covers the public FrameX-Core 0.4.4 export and the coupling patterns introduced in Hybrid AI Studio EX06.
Tip
Start with Quick Start, then the language tutorial. For language-model integration, read Hybrid AI with FrameX and Tool Calling.
FrameX Syntax
- World Assumption
- Variables
- Comments
- Classes and Subclasses
- Membership
- Attribute Signatures
- Facts
- Rules
- External Requests
- Property Declarations
- Namespaces and Identity
- Paths
- Constraints
- Queries
- Expectations
- Truth Values
Python API
- Python API overview
- Installation and Quick Start
- Add FrameX to a uv Project
- Client
- Tool Calling
- Tool Reference
- Model Settings and Replay
- Dialogue and Interviews
Hybrid AI with FrameX
- Overview
- Foundations: Invariants and Moves
- Thirteen Coupling Patterns
- Choosing and Combining Patterns
- Sources and Version Scope
Examples
Tutorial
- Tutorial overview
- 1. Your first object, rule and question
- 2. A small family knowledge graph
- 3. Properties, identity and multiple values
- 4. Classes and inheritance
- 5. Parameterized methods are relations
- 6. Schemas and consistency checks
- 7. Navigate with visible joins
- 8. Recursion and the fixed point
- 9. Missing evidence: open and closed worlds
- 10. Close only what is complete
- 11. Ordered data and numeric conditions
- 12. Namespaces and modules are different
- 13. Inspect the object model
- 14. Provenance: a reason is not a probability
- 15. From knowledge to controlled agents
- Quick reference
- Practice and verification
Python API Exercise
- Exercise overview
- Task 1: Connect and Ask
- Task 2: Reading the Answer
- Task 3: Growing the Knowledge Base
- Task 4: Making the Engine Explain Itself
- Task 5: Revising Safely
- Task 6: Worlds and Validation
- Task 7: When Things Go Wrong
- Task 8: Solution and What Comes Next
Quick Start
To quickly get started with FrameX we recommend using FrameX-Workbench. This allows quick and easy tinkering with the logic engine and the features.
The example
Copy this complete program — four statements, including the world declaration: a world assumption, a fact, a rule, and a question:
world open.
socrates:Human.
?X:Mortal <- ?X:Human.
?- socrates:Mortal.
Read it aloud: Socrates is a human. A person is mortal if that person is
human. Is Socrates mortal? The answer is true. Every statement ends with
a period; ?X is a variable and world open. declares the world
assumption (see World Assumption).
Option 1: FrameX-Workbench
To get started using FrameX we recommend using the FrameX-Workbench. After a quick login you can start modeling the logic directly in the web-tool. You can find FrameX-Workbench here: https://framex.nlp-lab.ai/.
- Login to framex.nlp-lab.ai. If you dont have a user, please reach out to the Teaching Assistants.
- Start the Workspace using the Start Workspace button on the top right.
- Create a new file using the New file button in the FILES tab on the left side of the screen and name it
quick-start.fx. If its not visible, you may need to click Show files first to show the colapsed FILES section. - Write your FrameX F-Logic code in the created file. You can use the example from above to start tinkering.
- Save the file and press Run program on the top right to evaluate the file.
- The result shows the logical answer (
true); execution status is reported separately.
Option 2: FrameX-CLI
The engine and Python host are available in the public
FrameX-Core repository. Follow
Installation and Quick Start to build the engine
and put framex on your PATH. You can then run and test .fx files locally.
- Name the file
quick-start.fx. - Run it from a terminal:
framex run quick-start.fx
- Add
expect true: socrates:Mortal.to your file, then run the test:
framex test quick-start.fx
Next Steps
Congrats! You have created and run your first FrameX program. 🎉🎉🎉
Continue with the Tutorial to learn the language, or the Python API Exercise to drive a persistent session. To connect models and tools, read Hybrid AI with FrameX and Tool Calling.
Parts of FrameX
FrameX separates an offline reasoning engine, hosts that perform external operations, and interfaces for people. The public FrameX-Core repository contains the engine and the Python components described below.
FrameX-Workbench
The course FrameX-Workbench provides a browser
workspace for .fx and Python programs. It is the quickest route for course
participants who do not want a local installation. Follow Quick Start.
FrameX-CLI
The framex binary supports file-based execution, queries, validation,
expectation tests, explanations and an interactive framex repl. Use
framex --help for the command list. It also provides framex serve for
the Python client and framex agent for external-tool episodes.
Build it from the public source using the installation instructions. Ordinary reasoning, validation and receipt replay need no network or language model.
FrameX-Engine
The Rust engine evaluates facts and rules, manages explicit world and closure
assumptions, answers queries and retains provenance. It has no Rust
dependencies. An external declaration produces a request; the engine does
not itself make a model API call.
Python package and hosts
| Component | Responsibility |
|---|---|
framex.Client | Persistent JSON Lines session with framex serve |
framex.tools | Perform registered tool requests and record receipts |
framex.provider | Model-provider transport |
framex.dialogue and flow_notation | Rule-controlled dialogue and compact .flow notation |
framex.interview and guide_notation | Coverage-controlled interviews and .guide notation |
Rules govern both domain logic (what follows from accepted facts) and control logic (which call, question or transition is eligible). The host performs the requested operation and returns its outcome as data. See Hybrid AI with FrameX.
The public export is smaller than the development framework. Consult Sources and Version Scope before using components from the pattern handbook.
FrameX-Doc
This site is built with mdBook. Its source is maintained separately in FrameX-Doc. Engine and host source belong to FrameX-Core; documentation changes belong to FrameX-Doc.
FrameX Syntax
In the following chapters, the FrameX Syntax will be explained:
- World Assumption
- Variables
- Comments
- Classes and Subclasses
- Membership
- Attribute Signatures
- Facts
- Rules
- External Requests
- Property Declarations
- Namespaces and Identity
- Paths
- Constraints
- Queries
- Expectations
- Truth Values
World Assumption
Every complete FrameX program must explicitly declare world open. or
world closed.. There is no implicit default.
// The declaration is mandatory — silence about the world is not allowed.
world open.
To use it correctly, keep three things strictly apart: the world assumption, a stored Boolean value, and the truth value of a query. Confusing any two of them is the most common modeling mistake.
Three different things
1. The world assumption says what silence means — how the engine treats a statement it can neither support nor refute:
- Under
world open., an unsupported statement normally remainsunknown. A named closure declaration can make a specific class, slot or method complete (for example,closed slot reviewed.). - Under
world closed., statements that cannot be established are treated asfalse.
2. A stored Boolean value is ordinary data. Writing
rob[hasJob -> false]. asserts that the hasJob property has the value
false, the same way reserve[configurationRevision -> 4]. asserts a
number. It is not logical negation: it does not by itself make anything
false at the query level.
3. The truth value of a query (true, false or unknown) is the
engine’s answer about one asked statement under the program’s assumptions.
true means supported under the program. false means refuted under its
closed-scope semantics. unknown means the program cannot settle the
statement. expect unknown checks that last outcome; it does not assert a
negative fact.
The general concept
The distinction comes from knowledge representation and also appears in databases and logic programming:
- Open-world assumption (OWA): a statement that is not entailed by the
knowledge base is
unknown— it may still be true. Absence of a fact is not a negative fact. See the Open-world assumption on Wikipedia. - Closed-world assumption (CWA): a statement that is not entailed is
taken to be
false. What is not known to be true is assumed false — the classic example is a database query such as “list all flights from Zurich to Vienna”, where missing rows mean “no such flight”. See the Closed-world assumption on Wikipedia, originally formalised by Raymond Reiter.
Neither is “correct” in the abstract — it is a modelling choice about what silence means. FrameX forces you to state the choice up front.
Open world with selective closure
Open world does not mean “anything goes”. Each FrameX case additionally declares which inventories are complete, and a named closure declaration can complete one class, slot or method without closing the whole world. Typically complete are: the component inventory, the type hierarchy, requirement groups and their membership, action types, and the resource inventory. A scenario therefore cannot hide another usable antenna.
Still potentially unknown are: capability, qualification, and inspection
evidence. That split is what makes the starting snapshot interesting — the
device list is fixed and fully known, while the condition of reserve is
genuinely open:
// Open world with selective closure (see the last block).
world open.
// The device inventory is contractually complete:
// these three devices are all there are.
primary : DirectAntenna.
reserve : DirectAntenna.
relay : RelayTerminal.
// Supported evidence: the query is true.
relay[health -> true].
?- relay[health -> true].
// The recorded health value is false.
// This does not itself negate health -> true.
primary[health -> false].
// No health statement about reserve exists —
// under world open. this is unknown, not false.
?- reserve[health -> true].
// Named closure: the inspected slot is declared complete,
// so a missing value here is false instead of unknown.
closed slot inspected.
primary[inspected -> true].
?- reserve[inspected -> true].
The last query answers false: with the slot closed, absence counts as
refutation — the one case where silence legitimately means false.
The three answers in one file
The orientation example shows true, a concrete value, and unknown side
by side:
// Open world: unasserted observations stay unknown.
world open.
// Taxonomy and installation — stated facts.
CommunicationDevice extends Component.
DirectAntenna extends CommunicationDevice.
reserve:DirectAntenna.
odyssey:Spacecraft.
reserve[installedOn -> odyssey].
reserve[configurationRevision -> 4].
// An older inspection, kept as history (revision 3, device is at 4).
earlierInspection:Inspection.
earlierInspection[component -> reserve].
earlierInspection[configurationRevision -> 3].
earlierInspection[outcome -> "pass"].
// Inherited membership: true.
?- reserve:CommunicationDevice.
// Stated attribute: answers 4.
?- reserve[configurationRevision -> ?Revision].
// Unasserted observation: unknown under world open.
?- reserve[health -> true].
Two further consequences:
- An alias match establishes nothing. Two objects may share a display
name (the
cars#jaguar/animals#jaguarcase), and asking about one in place of the other answersunknownrather than guessing. - A missing output of your implementation is a defect, not an encoding
of
unknown. Distinguish “the engine answeredunknown” from “my program produced no answer”.
Rule of thumb
Silence means
unknownunderworld open.andfalseunderworld closed.. A storedfalseis data, not negation. If you need a logicalfalseunder an open world, close the specific class, slot or method it belongs to — never rely on a stored value to do the negating.
Variables
Variables always start with ?: ?Device, ?X, ?Revision. Without the
prefix, a name is a class (after :) or an object (reserve). Quantification
is implicit — there are no explicit for all / exists keywords.
// ?X is a variable; Mortal and Human are classes.
?X:Mortal <- ?X:Human.
// ?Revision is a variable the query fills in.
?- reserve[configurationRevision -> ?Revision].
Comments
// starts a comment that runs to the end of the line. Use comments to mark
the source of adapted examples and to separate blocks in longer files.
world open.
// This is a comment...
Classes and Subclasses
extends builds the taxonomy. A subclass inherits everything declared for
its superclasses, so membership in a subclass implies membership in every
superclass.
// Taxonomy: each level inherits from the one above.
Person { hasName: String }.
Man extends Person.
CommunicationDevice extends Component.
DirectAntenna extends CommunicationDevice.
Membership
object:Class states that an object is a member of a class. Membership is
inherited through the taxonomy: from rob : Man and Man extends Person,
rob : Person follows with no extra rule.
rob : Man.
reserve:DirectAntenna.
// Follows by inheritance — no rule needed.
?- rob : Person.
?- reserve:CommunicationDevice.
Attribute Signatures
Class { attribute: Type }. declares which attributes a class provides and
their value type. Every member of the class (and its subclasses) shares the
declared capability — but never another individual’s values.
// Every Person can carry a hasName string;
// every Company member the same empty shape.
Person { hasName: String }.
Company {}.
Facts
object[attribute -> value]. states a fact. Values can be objects, strings,
numbers, or booleans. Only direct facts are stated — everything else is
derived by rules.
// Attribute, reference, installation, revision, and boolean facts.
rob[hasName -> "Rob"].
rob[hasParent -> henryk].
reserve[installedOn -> odyssey].
reserve[configurationRevision -> 4].
rob[hasJob -> false].
Rules
head <- body. derives new knowledge: whenever the body holds, the head
holds. Body conditions are joined with AND; OR alternatives must be
explicitly grouped. Rules are written recursively rather than as listed
closures.
// From a taxonomy fact to a derived membership.
?X:Mortal <- ?X:Human.
// A derivation over two references.
?X[hasUncle -> ?U] <- ?X[hasParent -> ?P] AND ?P[hasBrother -> ?U].
// Recursive closure: one step plus the transitive step.
?D[requires -> ?R] <- ?D[needs -> ?M] AND ?M[requires -> ?R].
External Requests
external name(arguments) using tool[fields] <- body. derives a request
whose fields are known from the body. It does not execute the tool inside
the engine.
world open.
socrates:Human.
external checkHuman(?Person) using _query[query -> "?- socrates:Human."]
<- ?Person:Human.
?- `checkHuman(socrates)`[tool#requested -> true].
The query is true: the request exists. The tool has not yet run.
Its object also has tool#name -> "_query" and tool#arg1 -> socrates.
The # is a namespace separator, and backticks identify the request object.
Run a host to perform the request and write a receipt. Rules can then read
tool#completed, tool#status, result("key") and row(N, "key").
An external operation is performed at most once per request identity within
an episode; unlike an ordinary derived conclusion, its occurrence cannot be
undone by withdrawing a premise. New attempts need new identities.
Give rule subjects class conditions so they do not accidentally match the request objects that also carry input slots. Keep input field names distinct from derived receipt fields to avoid conflicting request values. See Tool Calling for the complete request/receipt loop, and the Tool Reference for available hosts.
Property Declarations
declare states how a property behaves, so the engine derives consequences
with no extra rules: hierarchy (subproperty), reversal (inverse), chaining
(transitive), and mirroring (symmetric).
// A son is a child.
declare subproperty hasSon hasChild.
// bob[hasParent -> rob] follows from rob[hasSon -> bob].
declare inverse hasChild hasParent.
// Chains collapse and mirror automatically.
declare transitive relatedTo.
declare symmetric relatedTo.
Namespaces and Identity
:- prefix name = "iri". binds a namespace; name#object addresses an object
inside it. Two objects may share a display name yet stay distinct — an alias
match never establishes which object is meant.
world open.
:- prefix cars = "https://example.org/cars#".
:- prefix animals = "https://example.org/animals#".
cars#Car {}.
animals#Animal {}.
cars#jaguar : cars#Car.
animals#jaguar : animals#Animal.
// Same display name, distinct objects: unknown, not false.
expect unknown: cars#jaguar : animals#Animal.
In FrameX 0.4.4, # qualifies a namespace. The distinct :: operator
declares a subclass relation; these pages use extends for that relation.
Paths
A path is a conjunction through an intermediate object: hop from one object
to the next by sharing a variable such as ?FxValue1. Paths express
multi-step traversals a single slot cannot. A bare conjunction is not a
standalone program — it belongs inside a query or a rule body.
world open.
rob[hasParent -> henryk].
henryk[hasBrother -> john].
// As a query: who sits between rob and john?
?- rob[hasParent -> ?FxValue1] AND ?FxValue1[hasBrother -> john].
// As a rule: name the two-step traversal.
?X[hasUncle -> ?U] <- ?X[hasParent -> ?FxValue1] AND ?FxValue1[hasBrother -> ?U].
Constraints
A constraint records a violation as data instead of failing silently. The
frame#record rule below fires whenever a person works at a company while
hasJob is false.
// Violation recorded as an ordinary derivable fact.
frame#record[frame#tuple(violation#job_conflict, ?X) -> true] <- ?X : Person AND ?X[worksAt -> ?C] AND ?C : Company AND ?X[hasJob -> false].
expect true: frame#record[frame#tuple(violation#job_conflict, rob) -> true].
Queries
?- goal. asks the engine a question. It can check a ground statement
(true / false / unknown) or fill in variables. The orientation example
shows all three answer shapes in one file.
// Inherited membership: true.
?- reserve:CommunicationDevice.
// Stated attribute: answers 4.
?- reserve[configurationRevision -> ?Revision].
// Unasserted observation under world open: unknown.
?- reserve[health -> true].
Expectations
expect <truth>: goal. states what the engine must derive — the machine-checked
contract of a .fx file, run with framex test <file>.fx. The worked language
file declares thirteen expectations and all of them must pass.
// Ground facts, inheritance, derivation, and paths.
expect true: rob : Person.
expect true: rob[hasUncle -> john].
expect true: rob[hasParent -> ?FxValue1] AND ?FxValue1[hasBrother -> john].
// Same display name, distinct objects: unknown, not false.
expect unknown: cars#jaguar : animals#Animal.
Truth Values
Every statement evaluates to true, false, or unknown (see
World Assumption). How conditions combine:
| Combination | True | False | Unknown |
|---|---|---|---|
| Required conditions with AND | Every condition is true | At least one condition is false | No false condition, at least one unknown |
| Alternatives with OR | At least one alternative is true | Every alternative is false in a complete candidate set | No true alternative, at least one unknown |
Two consequences: unknown evidence is preserved unless a false condition
already decides the result, and deleting a negative fact produces unknown,
never true.
Python API
The public FrameX-Core Python package contains the client for the reasoning engine, an external-tool host, model-provider integration, dialogue flows and a coverage interviewer. It requires Python 3.10 or newer and has no third-party runtime dependencies. The Rust engine is installed separately.
Where the FrameX Syntax describes what you write, the Python API describes how a program drives the engine: loading programs, asking queries and revising state.
from framex import Client
with Client() as client:
client.load("world open.\n socrates:Human.\n ?X:Mortal <- ?X:Human.")
result = client.query("?- socrates:Mortal.")
print(result) # {'status': 'true'}
In the following chapters, the Python API will be explained:
- Installation and Quick Start
- Add FrameX to a uv Project
- Client
- Tool Calling
- Tool Reference
- Model Settings and Replay
- Dialogue and Interviews
The Python API Exercise teaches the persistent client. The hybrid section explains how to choose a coupling and keep acceptance decisions in rules.
Installation and Quick Start
FrameX-Core is public at GitLab. It contains the Rust engine and Python client and tool hosts. These pages target 0.4.4; see the source notes for the tested public revision and the scope of the export.
In FrameX-Workbench
The course Workbench provides the engine and Python package. Create
main.py and use from framex import Client. For local use, install both
parts below.
Build the engine locally
You need Git, a Rust toolchain with Cargo, Python 3.10 or newer, and uv. From a directory where you keep projects:
git clone https://gitlab.com/ds-unisg/framex-core.git
cd framex-core
git checkout 48b5f077f542e0c44a18874871b82a899161128a
cargo build --release --offline
export PATH="$PWD/target/release:$PATH"
framex --version
The engine has no Rust dependencies. Once the toolchain is installed,
--offline builds without fetching crates. The binary is
target/release/framex; add that directory to PATH in each new shell or
configure your shell to retain it. The checkout pins the tested revision.
Install the Python package
Still inside the checkout:
uv venv
source .venv/bin/activate
uv pip install ./python
For an existing uv project, follow
Add FrameX to a uv Project for the complete workflow,
including the H2 template’s installer that builds the engine into .venv.
The Python dependency command is:
uv add "framex @ git+https://gitlab.com/ds-unisg/framex-core@48b5f077f542e0c44a18874871b82a899161128a#subdirectory=python"
Installing the Python package does not install the Rust binary. The package has no runtime dependencies; packaging tools may still be downloaded during installation.
Quick start
Save the following as main.py and run python main.py in the activated
environment. A Client starts the engine and closes it when the context ends.
from framex import Client
with Client() as client:
client.load("world open.\n socrates:Human.\n ?X:Mortal <- ?X:Human.")
result = client.query("?- socrates:Mortal.")
print(result) # {'status': 'true'}
This is the Quick Start program driven from Python: a world assumption, a fact, a rule, and a question.
Locating the binary
If framex is not on $PATH, pass the path explicitly:
client = Client(binary="/absolute/path/to/framex-core/target/release/framex")
Client uses PATH or its binary argument. The tool-host commands also
accept --binary and read FRAMEX_BINARY. Continue with
Tool Calling to run an external-tool episode and
Model Settings to connect a language model.
Add FrameX to a uv Project
This tutorial adds FrameX from its Git repository to an existing uv-managed Python codebase. It follows the setup used by the H2 template: uv installs the Python package, and an installer builds the matching Rust engine into the project’s virtual environment.
The engine and client are two separate installations:
| Part | Installed by | Location |
|---|---|---|
Python package, including Client and tool hosts | uv add / uv sync | Project .venv |
framex engine binary | scripts/install-engine.sh | .venv/bin/framex |
Commands below use a macOS/Linux shell. You need uv, Git, curl, a Rust toolchain with Cargo, and a project supporting Python 3.10 or newer. No model server or API key is needed for this tutorial.
1. Start in your project root
Use the directory containing your existing pyproject.toml. If you want to
try this in a new project first:
uv init my-framex-app --python 3.12
cd my-framex-app
Keep your application’s existing name, dependencies and build configuration. If its supported Python range includes versions below 3.10, update that range to match FrameX’s requirements before adding the dependency.
2. Add the Python package from Git
Run this in your project root:
uv add "framex @ git+https://gitlab.com/ds-unisg/framex-core@48b5f077f542e0c44a18874871b82a899161128a#subdirectory=python"
The commit is the public 0.4.4 revision used by these docs and the template.
subdirectory=python is required because the repository root is a Rust
project; the Python package lives under python/.
uv adds framex to your dependencies, records the Git source in
pyproject.toml, updates uv.lock, and installs the package into .venv.
The relevant configuration looks like this; merge these entries into your
existing tables rather than replacing the whole file:
[project]
requires-python = ">=3.10"
dependencies = [
"framex",
# Keep your other dependencies here.
]
[tool.uv.sources]
framex = { git = "https://gitlab.com/ds-unisg/framex-core", subdirectory = "python", rev = "48b5f077f542e0c44a18874871b82a899161128a" }
If you edit the TOML manually instead of using uv add, run uv sync to
resolve, lock and install it. For uv’s dependency and source behavior, see
the official dependency guide.
3. Install the engine using the template’s script
Reuse the H2 template’s
install-engine.sh.
Save it as scripts/install-engine.sh in your own project. To download that
reviewed revision directly:
mkdir -p scripts
curl -fsSL \
"https://raw.githubusercontent.com/unisg-ics-dsnlp/hybridai-h2-template/56b16cf1ae7f6673e8ec9322d1a79c24a14a31f3/scripts/install-engine.sh" \
-o scripts/install-engine.sh
sh scripts/install-engine.sh
The script reads rev from the inline framex source entry shown above and
runs Cargo against that revision of framex-core. It installs the engine
into .venv/bin/framex and prints its version. This keeps the client and
engine on the same commit without a global engine installation.
Run uv sync first if .venv does not exist. The script expects the default
project-local .venv and the one-line source entry with a hexadecimal commit
ID; a custom environment location or differently formatted TOML needs an
adapted installer. The first build requires network access and a working
Rust toolchain.
Confirm both installations:
uv run python -c "from importlib.metadata import version; print(version('framex'))"
uv run framex --version
The package and binary should both report 0.4.4. The explicit Git pin,
rather than the version number alone, records the source revision.
4. Ask a question from your application
Create framex_demo.py next to pyproject.toml:
from framex import Client
PROGRAM = """world open.
socrates:Human.
?Person:Mortal <- ?Person:Human.
"""
with Client() as client:
client.load(PROGRAM)
print(client.query("?- socrates:Mortal."))
Run it through uv:
uv run python framex_demo.py
Expected output:
{'status': 'true'}
uv run puts the project’s .venv/bin on PATH, so Client() finds the
engine installed in step 3. No environment activation is needed. The
context manager starts framex serve and closes it after the query.
If an IDE or another process runs outside uv run, select the project’s
Python interpreter and ensure the engine is discoverable, or pass its path
explicitly with Client(binary="/absolute/path/to/project/.venv/bin/framex").
The base Client uses PATH or binary=, not FRAMEX_BINARY automatically.
5. Make setup repeatable for your team
Commit pyproject.toml, uv.lock, your installer and application code.
Keep .venv/ in .gitignore; each machine builds its own engine.
After cloning your project, a teammate runs:
uv sync --locked
sh scripts/install-engine.sh
uv run --locked python framex_demo.py
--locked refuses an out-of-date lockfile rather than silently changing it.
See uv’s locking and syncing guide.
Python environment synchronization does not build the engine. If .venv
is deleted or recreated, run the installer again after syncing.
To upgrade FrameX, change the rev in [tool.uv.sources], run uv sync,
then rerun sh scripts/install-engine.sh. Review and commit the configuration
and lockfile changes together. Because the installer reads the pin, you do
not need to update a second revision inside the script.
Explore the H2 example
The H2 template is a complete application of this setup:
| File | What to reuse or study |
|---|---|
| pyproject.toml | Git source, package subdirectory and shared revision pin |
| scripts/install-engine.sh | Build the engine into the uv environment |
| src/runtime/client.py | Application-specific client extensions for staged bulk loading |
| src/runtime/session.py | A persistent session for a larger knowledge base |
To run the template itself, clone it and perform its two setup steps:
git clone https://github.com/unisg-ics-dsnlp/hybridai-h2-template.git
cd hybridai-h2-template
uv sync
sh scripts/install-engine.sh
The tiny framex_demo.py above verifies the integration without downloading
rail data. The template’s usage guide
then explains --init, queries and the REPL. Its rail-specific Session and
client extensions belong to the application; a small integration can use
framex.Client directly.
Common setup problems
| Symptom | Check |
|---|---|
| Git source has no Python project | Include #subdirectory=python in the dependency URL |
ModuleNotFoundError: framex | Run uv sync and launch with uv run |
FileNotFoundError for framex | Install the engine after syncing, then check uv run framex --version |
| Installer cannot find a revision | Keep the inline [tool.uv.sources] entry shown above, including rev |
| Installer cannot find Cargo | Install the Rust toolchain and make Cargo available in your shell |
| Lockfile is out of date | Run uv sync after an intentional dependency change and commit uv.lock |
Continue with Client for persistent sessions or Tool Calling to connect rules to tools and models.
Client
Client(binary="framex", *, request_timeout=30,
max_response_bytes=8 * 1024 * 1024,
max_transcript_bytes=16 * 1024 * 1024)
A Client is a persistent subprocess wrapping framex serve. It speaks JSON
Lines over stdin and stdout, so the engine state survives across calls: what
you load stays loaded until you replace or retract it.
from framex import Client
with Client() as client:
client.load("world open.\n socrates:Human.\n ?X:Mortal <- ?X:Human.")
client.add(source="plato:Human.")
print(client.query("?- ?X:Mortal."))
Methods
| Method | Description |
|---|---|
load(source) | Load a full program (replaces current state) |
add(*, source) | Incremental add of facts/rules |
retract(*, fact) | Withdraw a ground fact |
query(query) | F-Logic query, returns a dict with a status |
explain(fact) | Provenance chain for a fact |
explain_graph(fact, max_nodes=2000) | Structured explanation graph |
world(mode) | Switch open / closed world |
validate() | Run constraint checks |
diff() | Most recent state change |
compare(*, facts, before, after) | Compare fact sets across revisions |
retract_source(source) | Remove all facts from a source |
dependents(fact) | List facts that depend on this one |
why_not(fact) | Explanation when a fact does not hold |
rdf_import(*, source, format, document, module=None) | Import RDF data |
rdf_export(*, format, selection, module=None, predicate="rdf_quad") | Export as RDF |
request(command, **fields) | Access an engine protocol command without a convenience wrapper |
save_replay(path) | Write session transcript to file |
query returns a dictionary whose status is true, false or unknown
(see Truth Values), or bindings when the goal
contained variables. The bindings key is present only in that last case:
{'status': 'true'}
{'status': 'bindings', 'bindings': [{'G': 'elena'}]}
So read status before reaching for bindings. Each binding is one match, and
its keys are the variable names without the ? marker.
world switches the World Assumption of the
running session, and validate runs the declared
Constraints.
explain returns a formatted string; explain_graph returns structured data.
transcript is a property containing attempted requests, including rejected
ones. save_replay refuses to overwrite an existing file. These records are
separate from the model-answer cache and episode receipts.
For a model-facing read interface, the public package provides
call_tool(client, name, arguments). Its allowlist contains query,
explain, why_not, evidence_needs, schema, modules and validate.
It does not expose loading, mutation or world switching. A generated query
still needs the application’s admission checks; this helper is a dispatch
allowlist rather than a query-policy checker. The broader interface described
in the handbook is not entirely included in the public export.
Exceptions
FrameXError— the engine returned an error. Carries an.errordictionary withkindandmessage.FrameXTimeout— the request timed out and the process was killed.
A timed-out sent mutation has an uncertain outcome. Start a fresh session; do not retry that mutation blindly. Invalid response shape or size also breaks the client session. Each request is limited to 1 MiB; response and transcript bounds are configurable in the constructor.
from framex import Client, FrameXError, FrameXTimeout
try:
client.query("?- broken syntax")
except FrameXError as exc:
print(exc.error["kind"], exc.error["message"])
except FrameXTimeout:
print("engine did not answer in time")
Tool Calling
An external rule derives a request object. A Python host runs the named
tool and records a receipt. Rules read that receipt and decide what follows.
The reasoning engine itself stays offline.
| Stage | Standard model tool calling | FrameX tool calling |
|---|---|---|
| Choose | The model selects a tool | Rules derive a request |
| Execute | The host runs the tool | The host runs the tool |
| Return | The result goes to the model | The result becomes a receipt |
| Continue | The model interprets the result | Rules check evidence and decide |
A model can also call read operations on the engine. That is the opposite
direction from rules requesting _llm; keep their callers and checks explicit.
A complete example without a model
Save this as tool-query.fx:
world open.
socrates:Human.
external checkHuman(?Person) using _query[query -> "?- socrates:Human."]
<- ?Person:Human.
?Person[answer -> ?Status] <- ?Person:Human
AND ?Call[tool#name -> "_query"; tool#arg1 -> ?Person;
tool#completed -> true; tool#status -> "succeeded";
result("status") -> ?Status].
?- socrates[answer -> "true"].
Run it through the Python host:
python -m framex.tools tool-query.fx --receipts-out tool-query.receipts.fx
The host queries the current knowledge base and adds a receipt. The final
query returns true: the slot answer contains the string "true".
The event’s status: "succeeded" describes tool execution;
result("status") -> "true" describes the logical answer.
Recompute from the receipt without executing tools:
framex run tool-query.fx tool-query.receipts.fx
Running only framex run tool-query.fx derives the request but does not
execute it. Its answer question is then unknown.
Request identity and receipts
The example creates `checkHuman(socrates)` with
tool#name -> "_query", tool#requested -> true, and
tool#arg1 -> socrates. Rules join the receipt to its subject through the
argument link.
Each completed call has tool#completed -> true and a tool#status of
"succeeded" or "failed". Successful scalar fields become
result("key"); the reserved rows array becomes row(N, "key"), starting
at 1. Failure records error instead of result rows. Events retain inputs,
timing and available tool evidence.
Use flat JSON. The host allows at most 64 top-level fields and 64 rows of at
most 64 fields each. Nested row values fail the call. A top-level object or
list outside rows is ignored and marked tool#ignored("key") -> true.
JSON null adds no fact.
Within an episode, each request identity is performed at most once. Put an
attempt number or revision in its arguments when rules permit another
attempt. A fresh invocation starts a new episode; model-answer caching is
separate. Different request names enabled together need distinct numeric
tool#order values; the lowest runs first. A run ends at one derived
workflow[status -> ...] or at quiescence when no request remains. The
generic Python host also has a technical default limit of 32 actions.
Direct Python calls
execute returns (output, evidence):
from framex import Client
from framex.tools import execute
with Client() as client:
client.load("world open. socrates:Human.")
output, evidence = execute(
"_query", {"query": "?- socrates:Human."}, client=client
)
print(output["status"]) # true
For the studio’s model example, configure a running Ollama server and its
downloaded model, then call _llm:
import os
from framex.tools import execute
os.environ["FRAMEX_MODEL_PROVIDER"] = "ollama"
os.environ["FRAMEX_MODEL"] = "gpt-oss:20b"
output, evidence = execute(
"_llm",
{"prompt": 'Say hello. Return JSON: {"message": "..."}.'},
client=None,
)
print(output["message"])
print(evidence["model"])
This makes a live call or replays a stored answer. Direct execute calls do
not insert receipts or apply acceptance rules, and failures raise exceptions.
Use the episode host when rules should govern the workflow; it turns tool
exceptions into failure receipts.
Application tools
An adapter exposes TOOLS = {name: function}. Each function takes request
fields and returns (output, evidence). The operator registers it at startup:
python -m framex.tools application.fx --adapter application_tools.py
A .fx program can request a registered tool; it cannot load Python code.
See the Tool Reference for host-specific registrations and
Grounded Extraction for an acceptance
rule that checks receipt evidence.
Tool Reference
These contracts describe the public FrameX-Core 0.4.4 export. The handbook includes additional development components; appearing in a pattern does not mean a tool is registered in every host.
Generic host: framex.tools
Run programs with python -m framex.tools program.fx. Direct Python calls
use execute(name, parameters, client, adapters=None).
| Tool | Request fields | Successful output |
|---|---|---|
_llm | prompt and input fields; optional host fields below | Model scalars as result("key"); rows as row(N, "key") |
_query | query | result("status"): true, false, unknown or bindings; result("text"): the full answer as JSON text |
_evidence_needs | goal; optional max_states, max_assumptions | Bounded evidence-question report; preserve its search limits and completeness information |
_forgetAnswers | text | result("removed"): number of stored model-answer records deleted |
_fixture | response: a JSON object encoded as a string | Authored output, with evidence that no model was called |
_emit | message | result("delivered") -> true; the message in event evidence |
Direct _query calls need a Client; framex agent handles native tools
inside the engine. _emit records delivery in this host; it is not a general
messaging-service adapter.
_forgetAnswers removes matching model-answer cache records. It does not
retract accepted facts or erase receipts and transcripts. Model withdrawal
separately and revise the session when evidence is removed.
Fields read by the model host
| Field | Behavior |
|---|---|
prompt | Instruction; other named input fields form one JSON message |
input | Explicit user-message text; takes precedence over collecting other named fields |
schema | JSON schema in direct Python calls; constrains provider formatting where supported |
groundIn | Source text; adds quoteFound and valueQuoted to each returned row |
mustSay | Text for a lexical check; adds saysDecision based on the reply’s text |
contextKey | Step whose selectedFor statements rules selected as context |
tool#maxContext | Context bound, default 32 statements; excess fails the call |
The host strips tool#... fields and consumes grounding/context controls
before calling the model. A prompt may also be a named object:
external read(?D) using _llm with readPrompt[document -> ?T] <- ...,
where readPrompt:Prompt[text -> "..."] supplies the instruction.
quoteFound checks that a nonempty quote occurs verbatim in groundIn.
valueQuoted checks that a nonempty value occurs in the quote. These lexical
measurements do not establish that a quote supports the property or that
the source is authoritative. Acceptance rules must read the checks and
restrict which claims a source may establish.
With contextKey, the host sends selected statements in identifier order
and records IDs as tool#sent("id"). Rules can check that citations refer
to statements sent for that request and still current.
Dialogue host: framex.dialogue
These tools are registered by the dialogue driver. They are not automatically
available in framex.tools. See Dialogue and Interviews.
| Tool | Request fields | Successful output |
|---|---|---|
_turn | question, attempt | answer, spoken utterance, canonical question |
_judge | criterion, question, answer | verdict: yes, no or unclear; reason; label where returned |
_ask | question | question, answer; no model used |
_say | text | Displays the text and records delivery |
_repairTurn, _repairJudge | Original fields and repair information | Another attempt when the flow policy requests it |
unclear is a judge’s interpretation, stored as data; engine unknown is
a logical answer. Dialogue format problems may return formatError and
received for rules to inspect. Execution failure remains a failed call.
A successful call alone is not acceptance.
_checkCandidate needs additional code
The handbook’s adapter checks candidate rules against a fixed vocabulary,
allowed heads and regression cases. Results include status, feedback,
regressions and failedAfter. Checking does not adopt a proposal.
It is not shipped as a tool in the public 0.4.4 export. The export also
omits framex.candidates, which the handbook’s adapter imports. Copying that
adapter alone will fail. Use separately supplied course/development code or
implement and register a checker. Keep generated rules as candidate text
until checks and an explicit adoption decision permit a change.
Model Settings and Replay
The engine needs no model server. A host needs these settings only for live
calls such as _llm and dialogue wording and judging. Set them in the
environment of the Python process.
Local model
With Ollama installed and running:
ollama pull gpt-oss:20b
export FRAMEX_MODEL_PROVIDER=ollama
export FRAMEX_MODEL=gpt-oss:20b
export FRAMEX_MODEL_ENDPOINT=http://127.0.0.1:11434
python -m framex.tools extraction.fx --receipts-out extraction.receipts.fx
Use the grounded extraction program as
extraction.fx. Model choice and memory determine whether a local run is
practical. The example’s deterministic tests need no model.
Hosted or compatible server
Use the model ID and full chat-completions URL supplied by your provider:
export FRAMEX_MODEL_PROVIDER=openai
export FRAMEX_MODEL=your-model-id
export FRAMEX_MODEL_ENDPOINT=https://your-provider.example/v1/chat/completions
export FRAMEX_MODEL_KEY_ENV=OPENAI_API_KEY
Set OPENAI_API_KEY using your own secret configuration. The host reads it
from the variable named by FRAMEX_MODEL_KEY_ENV; keys do not enter receipts.
Keep them out of .fx programs, prompts and source control. If your project
uses .env, load it into the process environment yourself; FrameX does not
automatically load .env files.
Settings reference
| Variable | Meaning |
|---|---|
FRAMEX_MODEL_PROVIDER | ollama (default) or openai |
FRAMEX_MODEL | Model identifier; defaults to local for Ollama or gpt-5-mini for the compatible provider |
FRAMEX_MODEL_ENDPOINT | Ollama base URL or full compatible chat-completions URL |
FRAMEX_MODEL_KEY_ENV | Key variable name, default OPENAI_API_KEY |
FRAMEX_MODEL_CACHE | Answer store, default ~/.cache/framex/model |
FRAMEX_MODEL_REFRESH | 1 asks again instead of replaying |
FRAMEX_MODEL_TIMEOUT | Model-call timeout in seconds, from 1 to 600 |
FRAMEX_MODEL_SCHEMA | 0 disables provider JSON-schema constraints |
FRAMEX_MODEL_JSON_EXTRACT | 1 opts into extracting JSON from surrounding text; evidence marks json_extracted |
FRAMEX_MODEL_RESPONSE_FORMAT | json_object (default) or none |
FRAMEX_MODEL_THINK | true or false for Ollama and supported local compatible servers |
FRAMEX_MODEL_MAX_TOKENS | Positive output-token limit for the compatible provider |
FRAMEX_MODEL_HTTP_HOST | Permits plain HTTP to one named model host, in addition to loopback |
Keep provider-specific overrides unset unless your endpoint supports them. Response-format settings do not replace acceptance rules.
Three different records
| Record | Contents | Use |
|---|---|---|
| Model-answer cache | Provider, model, messages, timestamp, output and provider evidence | Replay an identical request without a live call |
| Episode receipts | Tool-result facts linked to request identities | Recompute conclusions offline with the original program |
Client.transcript | Attempted engine protocol requests, including rejected ones | Export with save_replay |
Cache identity includes provider, model and messages; an explicit THINK
setting also affects it. A different prompt or model can cause a live call.
Events mark evidence as mode: "live" or "replay". Retain source programs
and receipts alongside the cache to reproduce a decision.
Test failure paths with authored receipts. Provider failure, malformed JSON,
an unclear judgment and a logical unknown must keep distinct meanings;
see Foundations.
Dialogue and Interviews
framex.dialogue loads the bundled policies/flow.fx. Rules decide which
step is active, whether to ask again, which branch to take and when to stop.
Models phrase questions and interpret answers. The tool reference
distinguishes these dialogue tools from the generic host.
A scripted flow without a model
Save this as certificate.flow:
world open.
flow certificate starts collect.
ask collect "Which English certificate do you have?"
answered "Does the answer name an English certificate?"
attempts 2 then done.
say done "Thank you. The answer has been recorded." then end.
The compact .flow notation translates into ordinary F-Logic. To inspect
that translation:
python -m framex.flow_notation certificate.flow
Save the following as certificate-answers.json:
{"answers": ["I have a C1 English certificate."], "verdicts": ["yes"]}
Run from a shell with the installed Python package and framex on PATH:
python -m framex.dialogue certificate.flow \
--script certificate-answers.json --output certificate-run
--script supplies both answers and judgments, so this is a test of control
flow rather than a model evaluation. Choose a new output directory for each
run. The driver saves the transcript, receipts and outcome information.
For a live dialogue, configure model settings and
omit --script. --answers supplies scripted human answers while the models
remain live. The flow decides what follows from each receipt.
A verdict is not an admission decision
A Collect step checks whether an answer has been supplied. A substantive eligibility decision needs a separate criterion and rules. Merely naming an English certificate does not establish a C1 qualification.
The policy represents judge verdicts as slot states "true", "false" or
"unknown". These are stored strings describing an interpretation; they
are distinct from the engine’s logical answer about whether a fact holds.
Failed calls and invalid formats follow separate policy paths. See
Foundations.
Coverage interviewer
The public core also ships framex.interview, policies/coverage.fx, and
the compact .guide notation. A guide declares blocks, objectives and
budgets. An Observer assesses objective coverage, while a Speaker phrases
the question chosen by rules. Full coverage needs a quote from the
participant’s answer.
From the public framex-core checkout, after installation:
python -m framex.interview examples/interview/keynote.guide \
--script examples/interview/terse.json --output keynote-run
python -m framex.interview export keynote-run --output keynote-bundle
The scripted mode needs no model. --stdin reads participant answers from
the terminal; --live enables the Observer and Speaker model calls. The
interviewer defaults its binary to target/release/framex relative to the
working directory; use --binary or FRAMEX_BINARY when running elsewhere.
Hybrid AI with FrameX
FrameX gives language models and rules separate jobs. Models read ambiguous text, phrase questions and propose candidates. Rules determine what evidence counts, which action is allowed and when a workflow ends. The Python host connects them through requests and receipts.
There are two kinds of logic to model:
| Layer | Questions it answers | Example |
|---|---|---|
| Domain logic | What follows from the accepted facts? | Which connections satisfy the passenger’s constraints? |
| Control logic | What may happen next, and with which evidence? | Is the query admissible, must a person clarify, or may an answer be shown? |
A model’s proposed fact is an observation to assess. A proof shows what follows from accepted premises; it does not verify the premises in the world.
Read this section
- Foundations: five invariants, four moves and distinct failures.
- Thirteen Coupling Patterns: choose a named design and its acceptance boundary.
- Choosing and Combining Patterns: start from a requirement and test a composition.
- Sources and Version Scope: course material and public implementation boundaries.
For implementation, start with Tool Calling, consult the Tool Reference, and run Grounded Extraction. The existing syntax tutorial supplies the language prerequisites.
Foundations: Invariants and Moves
These principles summarize sections 2.2 and 2.3 of Siegfried Handschuh’s Hybrid AI Patterns (October 2026), also introduced in Studio EX06. They apply to every pattern and composition in this section.
Five invariants
| Invariant | Implementation consequence |
|---|---|
| Model text is data | Store proposed rules, queries and actions as candidates. Check and admit them before execution. |
| Model answers are dated observations | Retain prompt, inputs, model, timestamp and result so observations can be audited and replayed. |
| Rules decide | Encode acceptance, transitions and completion in executable rules. |
| Evidence decides acceptance | Require checkable evidence, such as a permitted property and a supporting source quote. Confidence is ordinary data. |
| Failures stay distinct | Preserve failed execution, malformed replies, unclear interpretations and logical unknowns. |
The host performs calls and measurements. It should not silently replace a model verdict with its own domain decision before the rules see the receipt. If a correction matters to the outcome, represent it explicitly so the proof can show it.
Four moves
| Move | What crosses the boundary |
|---|---|
| Request | A rule derives a request with the exact inputs the tool may see. |
| Receipt | The host records the outcome as facts linked to that request and its subject. |
| Accept | A rule admits a proposed claim, records a reason to refuse it, or leaves it unresolved. |
| Ask | A rule identifies a question for a person or the evidence that would settle an open goal; the answer becomes a receipt. |
A received field and an accepted domain fact are different stages. Merely
recording quoteFound provides no protection unless a rule requires it.
See Grounded Extraction for this boundary
in executable form.
Distinguish the outcomes
| Situation | Representation | Meaning |
|---|---|---|
| Provider or tool failure | tool#status -> "failed" with error | Execution did not produce usable output |
| Invalid response format | Generic host failure, or dialogue formatError and received fields | A reply arrived but broke the expected contract |
| Ambiguous interpretation | Judge result("verdict") -> "unclear" | The model could not settle the criterion |
| Logical unknown | Query status unknown | Current facts and completeness assumptions do not settle the goal |
| Evidence refused | An application rejection rule and its reason | A received proposal failed an acceptance condition |
These cases can call for different next steps: bounded format repair, additional evidence, human clarification, or termination. None should be converted into an affirmative answer merely to continue the workflow.
Stored values such as false or "unknown" remain data. See
World Assumption for query truth, and
Dialogue and Interviews for a flow that stores a
judge’s three-way interpretation as slot state.
Keep authority and evidence separate
A quote existing in a document is a lexical measurement. It does not show that the document is authoritative for that claim. A conversational source may establish a language preference while being insufficient evidence of a degree or qualification. Give sources explicit permissions and keep those permissions visible in the acceptance rules.
Likewise, an old approval does not authorize a new state. Include relevant revisions in evidence joins and request identities, as introduced in Lesson 15.
Thirteen Coupling Patterns
The catalogue in Handschuh’s Hybrid AI Patterns groups patterns by what crosses the model/rules boundary, who controls the process, how reasoning is shared, and how knowledge changes over time. Numbers retain the handbook’s identities, including patterns 12 and 13 in their conceptual groups.
This is an implementation-oriented guide. The complete catalogue and its measurements remain in the course reading.
At the boundary
| Pattern | Use it when | What the rules must decide |
|---|---|---|
| 1. Model as Sensor | Facts are expressed in documents, speech or other unstructured input | Which extracted values have sufficient evidence and may become facts |
| 2. Model as Author | A policy text needs a proposed formalization | Whether a draft passes syntax, vocabulary and regression checks before a person adopts it |
| 3. Model as Translator | A person asks a question or needs an explanation in ordinary language | Whether a proposed query may run and whether the answer stays tied to the symbolic result |
| 12. Model Selects, Rules Write | Displayed passages and values must be exact | Whether selected IDs identify permitted source passages; deterministic code copies their content |
For Sensor, retain the source and quote and make the checks part of
acceptance. The extraction example uses
groundIn, quoteFound and valueQuoted. A model’s confidence cannot replace
that evidence, and an exact quote still needs an appropriate interpretation.
For Author, use hidden cases and allowed rule heads to test a candidate
in isolation. A passing check offers a draft for adoption; it is not an
automatic policy change. The public export omits the handbook’s
_checkCandidate implementation.
For Translator, constrain the formal vocabulary and operations before execution. Check the proposed query and ground the wording of the response in the actual result and explanation. A plausible natural-language answer is not evidence that the underlying query was correct.
For Selects, Rules Write, ask the model for IDs rather than rewritten source text. Check them and assemble the answer from retained passages or facts. Exact copying prevents textual drift but does not prevent selection of an irrelevant passage.
Control
| Pattern | Use it when | What the rules must decide |
|---|---|---|
| 4. Model Proposes, Rules Dispose | A model proposes actions with external effects | Admit, refuse or narrow the proposal based on current permissions and evidence |
| 5. Rules Steer the Model | A protocol has explicit steps and stopping conditions | Which step is active, what context it sees, and what may happen next |
| 6. Critique and Repair | A checkable reply fails a condition | What feedback to return, how many attempts are allowed, and when repair has stalled |
| 7. Division by Competence | Some cases can be decided formally while others remain ambiguous | Which cases the rules settle and which require another reader or a person |
| 13. Fail-Safe Union | Several checks assess one case and an overly permissive result is costly | Combine verdicts under an explicit order; stricter evidence can only tighten the result |
For Proposes, Rules Dispose, permit effects only after checking the current state. Completion requires recorded evidence, not a model declaring itself finished. Include action scope and revision in the decision.
For Steer, keep transitions and context selection in the policy. Models can phrase a question or judge an answer without choosing the next step. The public dialogue driver supplies this structure.
For Repair, return the actual failed check. Use a new attempt identity and a fixed bound. Repeating the same unresolved failure must eventually stop or hand over; repair must not silently rewrite a verdict in Python.
For Division by Competence, route on explicit result and evidence states. A model’s claim of high confidence is not an authority to bypass the rules. An ambiguous case may need a specific missing fact rather than another unconstrained model response.
For Fail-Safe Union, declare the verdict ordering, for example
release < hold < block, and the required sources. Define what happens when
a source fails or has not returned; missing evidence must not imply release.
Expect more false alarms as stricter sources are added.
In the reasoning
| Pattern | Use it when | What the rules must decide |
|---|---|---|
| 8. Model as Hypothesis Generator | An observation needs candidate explanations | Which hypotheses survive explicit tests and what evidence would discriminate them |
| 9. Model as Critic of the Rules | A rule change needs review beyond available automated checks | Which grounded remarks become findings and whether unresolved findings block approval |
| 10. Rules Teach the Model | A model needs examples, labels or feedback from a formal domain | Labels, examples and grades derived with proofs |
For Hypothesis Generator, keep candidate generation distinct from testing. No surviving candidate after a bounded search does not prove that no explanation exists. Ask for evidence that separates remaining candidates.
For Critic, run deterministic checks first and give the model a fixed change to review. A critic supplements those checks; it cannot certify that all semantic faults have been found.
For Teach, keep evaluation cases independent of training examples and feedback. A learned response does not inherit the guarantee of the engine that supplied its label.
Over time
| Pattern | Use it when | What the rules must decide |
|---|---|---|
| 11. Rule-Governed Memory | Conversation-derived knowledge persists and changes across sessions | Which source may establish each fact; admission, supersession, withdrawal and context selection |
Memory storage uses Sensor; recall uses Steer. Retain the utterance, source, quote and time. Derive which statements are current and which are selected for a request; validate cited IDs against that selection. Forgetting a cache record alone does not withdraw an accepted claim.
Use Choosing and Combining Patterns to compose these designs. Every composition still needs all five invariants.
Choosing and Combining Patterns
Start by asking whether a hybrid is needed. A small, stable set of rules may be sufficient by itself. A language task without a formal decision boundary may need only a model. Combine them when you need both language handling and an explicit proof, action policy, auditable process, exact aggregation or testable rule changes.
The handbook reports gains and costs for particular tasks and models, not universal break-even thresholds. Test your own baseline for accuracy, unsafe outcomes, latency and cost.
From a requirement to patterns
These questions are independent; several answers can apply to one system.
| Requirement | Starting patterns |
|---|---|
| Read facts from documents or speech | 1 Sensor, with 6 Repair |
| Turn an explicit policy text into candidate rules | 2 Author, with checks and 6 Repair |
| Ask questions in everyday language or explain a proof | 3 Translator |
| Propose external actions | 4 Proposes/Dispose, often with 5 Steer |
| Follow a multistep protocol or conversation | 5 Steer, 1 Sensor, 6 Repair, 7 Division |
| Escalate ambiguous cases | 7 Division |
| Explain an observation with candidate causes | 8 Hypothesis Generator, often with 5 Steer |
| Review a policy change beyond automated checks | 9 Critic, supplementing 2 Author |
| Supply a model with formal labels and feedback | 10 Teach |
| Remember claims across sessions and revise them | 11 Memory, composed with 1 and 5 |
| Display exact passages or values | 12 Selects/Write |
| Combine several judgments under a conservative ordering | 13 Fail-Safe Union, with 7 for unresolved cases |
Example: a passenger-rail assistant
Studio EX06 uses a symbolic Swiss passenger-rail system as the setting for connecting language and knowledge. A possible composition is:
- Translator (3) maps a passenger’s question into a candidate query.
- A check limits its vocabulary and operations; the engine evaluates the admitted query against accepted timetable facts.
- Steer (5) selects the next step: answer, obtain a missing constraint, or explain why a requested connection is unsupported.
- Division (7) routes ambiguity to a person. Repair (6) handles invalid output within a fixed attempt limit.
- Selects/Write (12) can assemble the final times and station names from exact result fields instead of asking a model to reproduce them.
If disruption information comes from text, add Sensor (1) with source checks. Keep base timetable knowledge, proposed observations and accepted updates distinguishable. An answer must retain the data revision that supported it. See the H2 starter project for the course’s application setting; its setup and data belong to that project rather than FrameX-Core.
Failure patterns to avoid
Section 8 of the handbook names five recurring mistakes:
| Failure pattern | What goes wrong | Concrete correction |
|---|---|---|
| Silent Rule Override | Host code changes a verdict outside the proof | Record the observation and derive the correction explicitly |
| Circular Evaluation | The evaluated model or developer also supplies its own gold labels | Fix independent cases and criteria before measuring |
| Self-Judge | An answer and its assessment share the same blind spots | Use checkable external evidence and independent assessment |
| Prose Policy | Decisions exist only as instructions in a prompt | Put the decisive conditions and transitions into testable rules |
| Decorative Check | Evidence is logged but does not affect acceptance | Require the check in the rule that admits the claim or action |
Test the boundary
Before making live calls, provide receipts for a valid answer, invalid evidence, malformed output, failed execution, ambiguous judgment and an exhausted repair budget. Check both the intended conclusion and the absence of permission when evidence is insufficient.
Preserve programs, input revisions, requests, receipts and acceptance decisions. Measure the composition against a rules-only or model-only baseline where appropriate. Passing a symbolic test verifies behavior under those premises; it does not establish a real-world source claim.
Sources and Version Scope
These pages combine the public implementation with the October 2026 course materials. Where a handbook example needs components outside the public export, the implementation pages state that explicitly.
Public implementation
FrameX-Core, version 0.4.4, is
the source for the installation, client and tool contracts. Examples were
checked against public commit
48b5f077f542e0c44a18874871b82a899161128a.
Its EXPORTED_FROM file identifies the originating development revision.
| Included in the public export | Additional development/course components |
|---|---|
Rust engine; Python Client, tools, provider, dialogue, flow_notation, interview, guide_notation; flow and coverage policies; an interview example | framex.candidates and its candidate-checking adapter, broader rule-learning and casework components, other notations, MCP integration |
Namespace qualification in this version uses #, for example
family#isaac and tool#status. :: is a subclass relation. The
documentation uses extends for subclass declarations and # for namespaces.
Course materials
Hybrid AI Studio, EX06, 20 October 2026, Prof. Dr. Siegfried Handschuh,
Götz-Henrik Wiegand and Lorena Raichle. The supplied 30-page deck introduces
control and domain logic, tool calling, invariants, the four moves and the
H2 language/knowledge integration exercise. The tool list explicitly notes
that _checkCandidate is unavailable in 0.4.4.
Siegfried Handschuh, Hybrid AI Patterns: Thirteen Ways to Couple Language Models with Rules, and How to Choose Among Them, October 2026. The supplied 85-page reading is the source for the pattern names and conceptual guidance:
| Reading section | Documentation |
|---|---|
| 2.1, pp. 3–5: requests, receipts and hosts | Tool Calling, Tool Reference |
| 2.2–2.3, pp. 5–6: invariants and moves | Foundations |
| 3–6, pp. 7–59: thirteen patterns | Pattern guide |
| 7–8, pp. 60–61: compositions and failure patterns | Choosing patterns |
| 10, pp. 65–68: decision guide and measurements | Choosing patterns |
| Appendix A, pp. 69–73: installation, replay and debugging | Installation, Model Settings |
Use the copies supplied with Studio EX06 or on the course learning platform.
The handbook’s companion scripts and recordings are separate course
materials, not files in this documentation repository or the slim public
core export. Its development-repository install instructions do not imply
that all those components ship in framex-core.
The documentation’s executable examples are deliberately small teaching examples. They do not reproduce assessed-task solutions or claim the handbook’s measurements as guarantees for other applications.
Examples
This part of the documentation shows examples how to use FrameX in different usecases.
Examples:
- Family Tree
- Grounded Extraction: a model proposes facts; rules accept only permitted, quote-checked values. Includes offline tests.
Family Tree
This example builds a small family model step by step. Each step adds one
piece of FrameX logic: taxonomy, facts, derivation rules, property
declarations, and finally checks with expect and queries with ?-.
Five people are enough to show everything: elena is the parent of the
siblings anna and bruno; bruno is the parent of dario and anna is
the parent of felix. That makes dario and felix cousins.
The family in one picture — solid arrows are the stated hasParent facts,
everything else on this page is derived from them:
flowchart TD
elena["Elena"] --> anna["Anna"]
elena --> bruno["Bruno"]
bruno --> dario["Dario"]
anna --> felix["Felix"]
Step 1 — Declare the world and the taxonomy
Every complete program declares its world. Then define the classes:
world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
Man and Woman inherit from Person, so every Man or Woman is also a
Person. Membership shares the declared capability (hasName); it does not
share any individual’s condition.
Step 2 — Add the people
Objects use stable IDs. Display names are plain attributes and may collide; IDs may not:
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
elena[hasName -> "Elena"].
anna[hasName -> "Anna"].
bruno[hasName -> "Bruno"].
dario[hasName -> "Dario"].
felix[hasName -> "Felix"].
Step 3 — State the parent facts
Only direct parents are stated. Everything else is derived:
elenais the parent ofannaandbrunobrunois the parent ofdarioannais the parent offelix
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
Step 4 — Derive siblings, brothers, and sisters
A sibling shares a parent. Brothers and sisters add a class check. The
?X != ?Y guard excludes self-relations: without it, the sibling rule also
matches a person with themselves (bruno shares a parent with bruno), and
that self-match would propagate — dario would become his own cousin.
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?X[hasBrother -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?Y : Man AND ?X != ?Y.
?X[hasSister -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?Y : Woman AND ?X != ?Y.
declare symmetric hasSibling.
Step 5 — Derive grandparents, uncles, and aunts
One step up through an intermediate object:
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
?C[hasUncle -> ?U] <- ?C[hasParent -> ?P] AND ?P[hasBrother -> ?U].
?C[hasAunt -> ?A] <- ?C[hasParent -> ?P] AND ?P[hasSister -> ?A].
This gives, for example: dario’s parent bruno has sister anna, so
dario[hasAunt -> anna]. felix’s parent anna has brother bruno, so
felix[hasUncle -> bruno].
Step 6 — Declare inverse and transitive relations
Children follow from parents automatically, and ancestry chains recurse:
declare inverse hasParent hasChild.
declare transitive ancestor.
?A[ancestor -> ?D] <- ?D[hasParent -> ?A].
?A[ancestor -> ?D] <- ?D[hasParent -> ?P] AND ?A[ancestor -> ?P].
declare inverse means bruno[hasChild -> dario] follows from
dario[hasParent -> bruno] with no extra rule. The two ancestor rules cover
the direct case and the recursive case. Note the direction: the rule reads “A
is an ancestor of D” (?A[ancestor -> ?D]), so elena is felix’s ancestor,
reached via anna — never the other way round.
Step 7 — Derive cousins
Cousins are the children of siblings. The ?X != ?Y guard keeps the
relation strict: it excludes self-matches that the sibling rule would
otherwise admit.
?X[hasCousin -> ?Y] <- ?X[hasParent -> ?PX] AND ?Y[hasParent -> ?PY] AND ?PX[hasSibling -> ?PY] AND ?X != ?Y.
declare symmetric hasCousin.
dario’s parent bruno is the sibling of felix’s parent anna, so
dario[hasCousin -> felix] — and by symmetry the reverse holds too.
Step 8 — Check expectations and ask queries
expect states what the engine must derive. ?- asks an open query:
expect true: anna : Person.
expect true: dario[hasGrandparent -> elena].
expect true: anna[hasSibling -> bruno].
expect true: felix[hasUncle -> bruno].
expect true: dario[hasAunt -> anna].
expect true: dario[hasCousin -> felix].
expect true: felix[hasCousin -> dario].
expect true: elena[ancestor -> felix].
expect true: bruno[hasChild -> dario].
expect unknown: dario[hasParent -> anna].
?- dario[hasGrandparent -> ?G].
?- felix[hasUncle -> ?U].
?- dario[hasCousin -> ?C].
Two things to read carefully here. First, direction matters: the ancestor
rule derives ?A[ancestor -> ?D] (“A is an ancestor of D”), so the check is
elena[ancestor -> felix]. The reversed form felix[ancestor -> elena]
would ask whether Felix is Elena’s ancestor — correctly answered unknown.
Second, dario[hasParent -> anna] is unknown, not false. Under the open
world, a question the program can neither support nor refute stays open. Do
not confuse this with a stored false value such as rob[hasJob -> false]
elsewhere: that is an ordinary asserted value about a property, not a
logical negation of the statement asked here. FrameX keeps stored values and
logical truth strictly apart.
Complete file
// Step 1: world and taxonomy — every program declares its world,
// then defines the classes it reasons about.
world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
// Step 2a: the people — stable IDs, one object per person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
// Step 2b: display names — plain attributes, may collide across objects.
elena[hasName -> "Elena"].
anna[hasName -> "Anna"].
bruno[hasName -> "Bruno"].
dario[hasName -> "Dario"].
felix[hasName -> "Felix"].
// Step 3: parent facts — only direct parents are stated,
// everything else is derived.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
// Step 4: siblings — two distinct people sharing a parent;
// brothers and sisters add a class check.
// The ?X != ?Y guard excludes self-relations (and hence own cousins).
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?X[hasBrother -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?Y : Man AND ?X != ?Y.
?X[hasSister -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?Y : Woman AND ?X != ?Y.
// Step 5: grandparents, uncles, aunts — one step up
// through an intermediate object.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
?C[hasUncle -> ?U] <- ?C[hasParent -> ?P] AND ?P[hasBrother -> ?U].
?C[hasAunt -> ?A] <- ?C[hasParent -> ?P] AND ?P[hasSister -> ?A].
// Step 6: ancestry — "A is an ancestor of D": direct case plus recursion
// through an intermediate parent.
?A[ancestor -> ?D] <- ?D[hasParent -> ?A].
?A[ancestor -> ?D] <- ?D[hasParent -> ?P] AND ?A[ancestor -> ?P].
// Step 7: cousins — children of distinct siblings; the guard excludes
// self-matches.
?X[hasCousin -> ?Y] <- ?X[hasParent -> ?PX] AND ?Y[hasParent -> ?PY] AND ?PX[hasSibling -> ?PY] AND ?X != ?Y.
// Property declarations — symmetry, inverse, and transitivity
// the engine applies on top of the rules above.
declare symmetric hasSibling.
declare symmetric hasCousin.
declare inverse hasParent hasChild.
declare transitive ancestor.
// Step 8a: checks — what the engine must derive.
// Note the ancestor direction: elena is felix's ancestor, not vice versa.
expect true: anna : Person.
expect true: dario[hasGrandparent -> elena].
expect true: anna[hasSibling -> bruno].
expect true: felix[hasUncle -> bruno].
expect true: dario[hasAunt -> anna].
expect true: dario[hasCousin -> felix].
expect true: felix[hasCousin -> dario].
expect true: elena[ancestor -> felix].
expect true: bruno[hasChild -> dario].
expect unknown: dario[hasParent -> anna].
// Step 8b: open queries — ask the engine to fill in ?G, ?U, ?C.
?- dario[hasGrandparent -> ?G].
?- felix[hasUncle -> ?U].
?- dario[hasCousin -> ?C].
To Run it, download the complete file and save it as family-tree.fx. Then run it with the FrameX-CLI:
framex test family-tree.fx
You can also put this example in the FrameX-Workbench and run it there.
All ten expectations should pass.
What to try next
- Remove
felix[hasParent -> anna].and explain whyfelix[hasUncle -> bruno]becomesunknownrather thanfalse. - Add
expect true: elena[hasChild -> anna].and explain which declaration makes it follow with no extra rule. - Add a second child for
annaand check who becomes cousins withdario. - Remove one
?X != ?Yguard and predict which self-relation reappears (check with?- dario[hasCousin -> ?C].).
Grounded Extraction
This example implements Model as Sensor (1). A model proposes a customer name and quote; the host measures their occurrence in the source; rules decide whether to accept the claim. It makes the request, receipt and accept moves visible.
The program
Download grounded-extraction.fx, or copy this complete file:
world open.
doc1:Document[text -> "Acme AG (the Customer)."].
external readCustomer(?Document) using _llm[
prompt -> "Extract the customer. Return JSON with rows of slot, value and quote. Use slot customer, copy the value and quote exactly, and return no other claims.";
document -> ?Text; groundIn -> ?Text]
<- ?Document:Document[text -> ?Text].
// Only the customer field is admitted, and both host checks must hold.
?Document[accepted(?Row) -> ?Value] <- ?Document:Document
AND ?Call[tool#name -> "_llm"; tool#arg1 -> ?Document;
tool#completed -> true; tool#status -> "succeeded";
row(?Row, "slot") -> "customer";
row(?Row, "value") -> ?Value;
row(?Row, "quoteFound") -> true;
row(?Row, "valueQuoted") -> true].
?Document[customer -> ?Value] <- ?Document:Document
AND ?Document[accepted(?Row) -> ?Value].
?Document[readError -> ?Message] <- ?Document:Document
AND ?Call[tool#name -> "_llm"; tool#arg1 -> ?Document;
tool#completed -> true; tool#status -> "failed";
error -> ?Message].
?- doc1[customer -> ?Customer].
?- doc1[readError -> ?Message].
The Document class condition restricts the subject. The receipt joins back
to it through tool#arg1. Acceptance requires a successful completed call,
the allowed slot customer, and both quoteFound and valueQuoted.
This is a small lexical grounding example. It does not prove that a source is authoritative or that a copied value is semantically the customer. A real application needs source permissions and suitable domain checks. Model confidence is not an acceptance condition.
Test without a model
The following authored receipts simulate the host’s output. They test rule behavior; they do not measure a model or independently test quote matching. Save the program and fixtures together, then run each case separately:
| Fixture | What it tests |
|---|---|
| grounded-extraction.accepted.test.fx | Valid evidence produces the customer fact |
| grounded-extraction.rejected.test.fx | A missing source quote, an unquoted value or a different slot prevents acceptance |
| grounded-extraction.failed.test.fx | Failure remains an error and does not establish the customer |
framex test grounded-extraction.fx grounded-extraction.accepted.test.fx
framex test grounded-extraction.fx grounded-extraction.rejected.test.fx
framex test grounded-extraction.fx grounded-extraction.failed.test.fx
Do not concatenate conflicting scenario receipts. Under this open-world
program, a refused customer claim stays unknown; refusal does not establish
the opposite claim.
Run against a model
After installation and model configuration:
python -m framex.tools grounded-extraction.fx --receipts-out extraction.receipts.fx
framex run grounded-extraction.fx extraction.receipts.fx
framex explain grounded-extraction.fx extraction.receipts.fx 'doc1[customer -> "Acme AG"]'
The first command runs the host. The second replays receipt facts offline; the third inspects the conclusion if the model supplied accepted evidence. Live output depends on the model and is not guaranteed to match the authored success case.
The host’s groundIn checks compute the two Boolean fields; a model cannot
authorize its own claim by writing quoteFound: true. A success event still
does not bypass the acceptance rule. If no acceptable value is returned,
the application should derive a bounded repair or a question for a person.
Tutorial
This tutorial introduces FrameX from the beginning. You will model objects and their relationships, write rules, ask questions and explain the results. A small family knowledge graph provides the first examples; later lessons introduce incomplete evidence and controlled agents. No prior knowledge of a logic programming system is required.
FrameX is an object-oriented rule engine for hybrid and neurosymbolic AI teaching. Its offline core derives consequences from supplied knowledge. An external host connects it to language models and tools. Neither a model answer nor a logical proof establishes that an input claim is true in the real world.
FrameX uses one property arrow, explicit variable markers and explicit world assumptions. Learn these through the examples before turning to the quick reference. Each new concept builds on the preceding lessons.
How to study
Read a program, predict its answer, run it, and change one fact. Explain why the answer changed. The early examples deliberately ask questions before introducing expectations. Later examples add executable checks. Every printed program is a complete file; do not concatenate files that declare different worlds.
Relationship to the handbooks
The family and small paper examples here are public worked exercises, not solutions to assessed tasks.
Lessons
- 1. Your first object, rule and question
- 2. A small family knowledge graph
- 3. Properties, identity and multiple values
- 4. Classes and inheritance
- 5. Parameterized methods are relations
- 6. Schemas and consistency checks
- 7. Navigate with visible joins
- 8. Recursion and the fixed point
- 9. Missing evidence: open and closed worlds
- 10. Close only what is complete
- 11. Ordered data and numeric conditions
- 12. Namespaces and modules are different
- 13. Inspect the object model
- 14. Provenance: a reason is not a probability
- 15. From knowledge to controlled agents
- Quick reference: choosing the right construct
- Practice and verification
Your first object, rule and question
world open.
socrates:Human.
?Person:Mortal <- ?Person:Human.
?- socrates:Mortal.
Read the three meaningful statements aloud: Socrates is a human. A person is mortal if that person is human. Is Socrates mortal?
socrates is an object identifier. Human and Mortal are class names.
?Person is a variable; its question-mark marker makes the distinction
visible. A class name after a colon is literal, even though it starts with a
capital letter.
The answer is true. The rule does not say that every mortal is human. An
implication is directional. Nor does asking the question add a fact.
Run it
With FrameX on your path, save the listing as first.fx and run:
framex run first.fx
In FrameX-Workbench, open the file and choose Run program. The query is inside the file. The result contains the logical answer; execution status is a separate success/failure indicator.
The CLI may also report fact and rule counts. These describe the evaluated representation, including derived facts and internal rules for object semantics. They are not a count of the sentences you typed. This example has one authored implication. A successful process exit alone does not mean every possible query is true.
Every statement ends with a period. Newlines do not end statements.
world open. makes incomplete knowledge explicit; its consequences return in
Lesson 9.
Try it
Remove the human fact, retaining the rule and question. Predict the answer under an open world. Then restore the fact.
A small family knowledge graph
world open.
abraham:Man.
sarah:Woman.
isaac:Man[father -> abraham; mother -> sarah].
jacob:Man[father -> isaac; mother -> rebekah].
esau:Man[father -> isaac; mother -> rebekah].
rebekah:Woman.
?Parent[son -> ?Child] <- ?Child:Man AND (
?Child[father -> ?Parent] OR ?Child[mother -> ?Parent]
).
?- ?Mother:Woman AND ?Mother[son -> ?Son]
AND ?Son[father -> abraham].
expect true: sarah[son -> isaac].
A frame bundles named properties of one object. Isaac’s frame records two relationships, to Abraham and Sarah. A semicolon separates properties; it does not mean sequence or choice.
The rule reads: a parent’s son is a male child whose father or mother is
that parent. OR is inclusive. The query joins the mother, son and father by
reusing variables. Its answer is Mother = sarah, Son = isaac. Binding
labels in displayed output may omit the question-mark marker used in source.
The final expect is an executable assertion about the answer. It is not
evidence that makes that answer true. Use framex test with the same
filename to check expectations explicitly; changing expect true to another
expected truth value should make a mismatching test fail.
Try it
Add a second male child with the same parents. Predict how many query rows appear and explain the shared bindings.
Properties, identity and multiple values
world open.
jacob:Person[child -> {joseph, benjamin}; label -> "Jacob"].
?- jacob[child -> ?Child].
?- jacob[child -> {joseph, benjamin}].
expect unknown: jacob[child -> anotherChild].
The variable query returns Benjamin and Joseph as separate bindings. The ground question about both values returns true. Neither question claims that no other child exists.
-> means that a property has a value. It does not promise a single value
and it is not assignment. Adding another child preserves earlier children.
Repeating the same fact does not create another logical copy.
The braces abbreviate multiple facts; they do not store a list or a set-valued object. They carry no ordering information. Bundles in conditions require all their members. Express alternatives with explicitly grouped OR.
The identifier jacob is different from the label string "Jacob". Labels
are human-facing data. Two objects with the same label do not automatically
become the same object. Stable identity matters when merging sources and
knowledge graphs.
Naming convention
Use descriptive lower-camel-case object and property names, capitalized class
names, quoted strings, and ?Name variables. Do not rely on capitalization
alone to mark a variable. For an uppercase entity in a value position, use a
backtick-delimited identifier. Integers, finite decimal numbers, booleans and
strings remain different kinds of values; avoid assuming text-to-number
conversion.
Try it
Add another child and rerun the ground question. Why does the answer remain true? What extra assumption would you need to claim the list was complete?
Classes and inheritance
world open.
Person { label: String }.
Man extends Person.
Researcher extends Person.
isaac:Man[label -> "Isaac"].
isaac:Researcher.
?- isaac:Person.
?- isaac:Researcher.
expect true: isaac:Person.
Isaac belongs to both Man and Researcher. Both classes extend Person, so Isaac is a Person. Class membership, inheritance and property assertions are distinct relationships.
The field declaration describes a type expectation for Person’s label. It does not invent labels for every person. Inheritance supports shared class structure; it is not an imperative constructor or an instruction to allocate an object.
An object can have several class memberships. Use extends for the
hierarchy. Do not confuse a namespace separator with a subclass declaration.
Objects without properties
To introduce an entity, give it an appropriate class membership. Empty frames are outside the teaching profile. Avoid adding meaningless properties merely to force an object into existence.
Classes as data
FrameX exposes membership and hierarchy through reserved metadata properties. Lesson 13 shows a membership query. This provides a bounded form of introspection; it is not arbitrary higher-order formula evaluation.
Try it
Remove the direct Researcher membership. Does the Person conclusion survive? Name the remaining reason.
Parameterized methods are relations
world open.
birth1:Birth[father -> jacob; mother -> rachel;
child -> joseph; order -> 11].
birth2:Birth[father -> jacob; mother -> rachel;
child -> benjamin; order -> 12].
?Father[childWith(?Mother) -> ?Child] <-
?Birth:Birth[father -> ?Father; mother -> ?Mother;
child -> ?Child].
?- jacob[childWith(rachel) -> ?Child].
expect true: jacob[childWith(rachel) -> joseph].
A birth is represented as an identified object with named roles. The rule projects these roles into a parameterized method: Jacob’s child with Rachel is Joseph or Benjamin. The query returns both children.
The parentheses belong to the method’s parameters. They do not invoke Python or execute an object method. A method result is stored or derived logical knowledge. The same method can relate an object and argument to several results.
Birth objects are useful when a relationship itself needs a date, order,
source or uncertainty annotation. Give each observation a stable ID. FrameX
can also use structured identifiers such as birth(parent, child), but this
example uses explicit names to keep the evidence easy to inspect. Function
terms do not execute external code; recursive term generation needs bounds.
Choosing a representation
Use a simple slot for a simple binary relationship. Use a method when an argument naturally selects a relation. Use a named relationship object when you need to attach evidence or several roles. Favor the representation students can explain without memorizing positional arguments.
Try it
Add a birth with a different mother. Query that method argument and predict why the existing query is unchanged.
Schemas and consistency checks
world open.
Person { label: String, father: Person }.
declare required Person label.
declare functional father.
abraham:Person[label -> "Abraham"].
isaac:Person[label -> "Isaac"; father -> abraham].
?- isaac[father -> abraham].
expect true: abraham:Person.
The schema describes value types. The required declaration demands a label for Person objects. The functional declaration limits distinct values for the father slot. These are three different checks.
Run framex validate with this filename. This example has no reported
schema violations. Under the open world, the functional constraint can still
be unresolved; no reported violation is not a global consistency
certificate. For an experiment, work on a copy: remove Isaac’s label, use a
string as his father, or add a second father value. Validate after each
change.
Validation reports problems; it does not repair the data, choose a preferred father, or turn a missing label into a value. A functional declaration applies to the named property in its module, not merely to the class whose declaration happens to be nearby.
Typing is also distinct from inference declarations. A range rule can derive a class membership; a schema type constraint checks one. Neither one verifies a real-world claim.
A trustworthy knowledge base may preserve conflicting reports as separate evidence objects before deciding which assertions to accept. Do not silently overwrite disagreement to make validation pass.
Try it
Explain the difference between an absent value, a wrong type, two distinct values and an explicitly closed property. Which declaration addresses each?
Navigate with visible joins
world open.
jacob[father -> isaac].
isaac[father -> abraham].
?Child[grandfather -> ?Grandfather] <-
?Child[father -> ?Father]
AND ?Father[father -> ?Grandfather].
?- jacob[grandfather -> ?Grandfather].
expect true: jacob[grandfather -> abraham].
The intermediate variable makes the path explicit: start at a child, follow
father, then follow father again. The answer is Grandfather = abraham.
Bracket joins make each relationship along a path visible. The intermediate object can be named, queried and inspected. This is especially useful when more than one value is present or evidence is incomplete.
A join is equality of the reused binding, not a textual similarity match. If the first fact pointed to another identifier with label “Isaac”, the second fact would not join solely because of that label.
Check each edge
When a path query is unexpectedly empty, query each edge individually. Check identifiers, property names and module context before changing the rule. Under an open world, a missing edge is not evidence that the real-world relationship does not exist.
Try it
Add a second father edge in a copy and inspect the new grandfather results. Then discuss whether a functional constraint should flag that input.
Recursion and the fixed point
world open.
jacob[father -> isaac; mother -> rebekah].
isaac[father -> abraham; mother -> sarah].
sarah:Woman.
rebekah:Woman.
?Child[parent -> ?Parent] <- (
?Child[father -> ?Parent] OR ?Child[mother -> ?Parent]
).
?Child[ancestor -> ?Parent] <- ?Child[parent -> ?Parent].
?Child[ancestor -> ?Ancestor] <-
?Child[parent -> ?Parent]
AND ?Parent[ancestor -> ?Ancestor].
?- jacob[ancestor -> ?Ancestor] AND ?Ancestor:Woman.
expect true: jacob[ancestor -> sarah].
First derive parent edges. Then derive direct ancestors and repeatedly extend them through parents. The query returns Rebekah and Sarah. Sarah requires a path through Isaac.
FrameX evaluates finite rule consequences in synchronous rounds until no new facts are added. Source order is not an execution schedule. A recursive rule does not mean “call this procedure again” or perform Prolog-style backtracking.
Every variable in the head must be positively bound in each applicable branch of the body. This keeps the rule grounded in supplied knowledge. No new people are generated by recursion.
Explain an answer
Use framex explain with this file and the quoted query
'jacob[ancestor -> sarah]'. Identify the source facts and intermediate
parent/ancestor conclusions. More than one proof can support the same logical
fact.
Try it
Draw the graph and mark which relationships are input facts and which are derived. Predict the effect of removing Isaac’s mother edge.
Missing evidence: open and closed worlds
world open.
p1:Paper.
p2:Paper.
p1[reviewed -> true].
?Paper:NeedsReview <-
?Paper:Paper AND NOT ?Paper[reviewed -> true].
?- p2:NeedsReview.
?- ?Paper:Paper AND unknown { ?Paper[reviewed -> true] }.
expect unknown: p2:NeedsReview.
Paper p2 has no review evidence. The first answer is unknown. The second query returns p2 as a positively identified Paper whose review statement is unknown. Its candidate domain is explicit; the engine is not enumerating every imaginable object.
NOT does not turn unknown into true. The rule therefore does not derive
NeedsReview for p2 in this open-world program. A scope warning points out
that absence cannot be established. This is intentional evidence about the
modeling assumption, not a reason to suppress the warning.
Three outcomes
True means supported under the program. False means refuted under its
closed-scope semantics. Unknown means the program cannot settle the
statement under its current assumptions. expect unknown checks that last
outcome; it does not assert a negative fact.
A stored boolean false is also distinct: a property with value false is an ordinary assertion about that value, not automatically the negation of the same property with value true.
See also World Assumption.
Try it
Replace the world declaration with world closed. in a copy. Predict both
queries before running. The NeedsReview answer becomes true; the
unknown-filter query has no p2 binding.
Close only what is complete
world open.
closed slot reviewed.
p1:Paper.
p2:Paper.
p1[reviewed -> true].
?Paper:NeedsReview <-
?Paper:Paper AND NOT ?Paper[reviewed -> true].
?- p2:NeedsReview.
expect false: p2[reviewed -> true].
expect true: p2:NeedsReview.
expect unknown: p2:Cited.
The review slot is declared complete within this program’s module. Missing review evidence for p2 is now false, so the rule derives NeedsReview. Cited remains unknown: the whole world was not closed.
Completeness is a modeling contract. A closed review slot is appropriate only if the supplied review information is complete for the intended scope. It does not claim that all literature everywhere has been searched.
Negation variables must be bound by positive conditions. Avoid negative cycles: a conclusion must not depend negatively on itself. FrameX rejects unsafe or unstratified programs rather than treating rule order as a repair.
Universal questions via counterexamples
To express “all required items are present”, identify the required items and derive a counterexample when a required item is absent in a complete evidence scope. Then test the counterexample projection under an explicit completeness contract.
Do not mistake absence of a counterexample in an open source for proof of completeness. Likewise, absence of an ancestor path does not justify “these people are unrelated” without an adequate closed model.
Try it
Explain why closing reviewed does not make p2:Cited false. Then describe a
situation where closing reviewed would be an invalid assumption.
Ordered data and numeric conditions
world open.
step1:Step[position -> 1; next -> step2].
step2:Step[position -> 2; next -> step3].
step3:Step[position -> 3].
?Step:EarlyStep <- ?Step:Step[position -> ?Position]
AND ?Position < 3.
?- ?Step:EarlyStep.
?- step1[next -> ?Next].
expect true: step2:EarlyStep.
The first query returns step1 and step2; the second returns step2. The numeric variable is bound by a frame before it is compared.
A sequence can be modeled with named nodes, position values and next edges. This finite graph is inspectable and can carry provenance. It does not implement Prolog list terms, head/tail unification or arbitrary term construction.
The next edge describes an order. Merely asserting it does not execute a step, consume a token or prevent two steps from running. An external executor must respect the permissions and transitions derived by the rules.
Use rules to compare values that are already available. FrameX 0.4.4 also
supports arithmetic bindings such as ?Next := ?Position + 1 once the input
is bound. Parsing and external access belong in explicit adapters with
recorded inputs and outputs. Aggregates are outside this core teaching
profile.
Try it
Add a fourth step. Which facts describe ordering, and which rule classifies early steps? Does either perform an action?
Namespaces and modules are different
world open.
:- prefix family = "urn:family:".
:- prefix alias = "urn:family:".
:- prefix archive = "urn:archive:".
family#isaac[label -> "Isaac"].
archive#isaac[label -> "Isaac"].
?- alias#isaac[label -> "Isaac"].
expect true: family#isaac != archive#isaac.
The alias resolves to the same expanded identity as family, so its query is true. The archive identity remains distinct despite having the same label. Prefixes organize identifiers; they do not retrieve data from the Internet.
A namespace answers “which entity does this name denote?” A module answers “in which knowledge context is this assertion evaluated?” Naming an entity does not make its facts available in every module. Positive imports do not import a source module’s completeness assumptions.
FrameX uses prefix declarations and qualified identifiers to keep names unambiguous. Use explicit prefixes when integrating graphs. Exact external identifiers may be represented with backticks. Do not assume arbitrary URI spelling differences identify the same resource.
Module-local completeness is useful for snapshots, institutions or evidence sources. Keep the context visible in a query rather than treating namespace qualification as a substitute for context.
Try it
Give archive#isaac a different label. Why is the family query unaffected?
What would an identity-resolution decision need beyond matching labels?
Inspect the object model
world open.
isaac:Person[father -> abraham; label -> "Isaac"].
?- isaac[?Property -> ?Value].
?- isaac[frame#memberOf -> ?Class].
expect true: isaac[father -> abraham].
The first query returns the known ordinary property/value pairs: father and label. The second queries the reserved membership view and returns the Person class identifier.
The ordinary slot query does not silently return every internal record. FrameX’s reserved metadata properties provide explicit access to supported structural information. They allow useful meta-queries while retaining a bounded language.
FrameX 0.4.4 also supports class variables: ?- isaac:?Class. asks for
membership directly. The reserved frame#memberOf view keeps the metadata
relationship explicit in this example.
Meta-queries help explain what was loaded, inspect schemas, and build teaching tools. A variable property name makes dependency analysis less specific, so warnings and stratification may need a more conservative treatment than with named properties.
Try it
Add another ordinary property and predict which query changes. Explain why metadata and domain properties are kept distinguishable.
Provenance: a reason is not a probability
world open.
report7:Report[text -> "The antenna passed inspection."].
antenna[healthy -> true].
annotate antenna[healthy -> true] source "inspection-7"
time "2026-09-14" weight 0.9.
antenna:Ready <- antenna[healthy -> true].
?- antenna:Ready.
expect true: antenna:Ready.
The annotation attaches a source name, timestamp and weight to an assertion.
The readiness rule derives a conclusion from the health fact. Ask
framex explain about 'antenna:Ready' to inspect its support.
The source name is an identifier, not a verification service. It should connect to a retained report or observation in the surrounding application. The explicit report object carries source text; an application should keep that text and its assertion records aligned.
A weight of 0.9 does not change logical true into “ninety percent true”. Raw model confidence, provenance algebra values and calibrated probabilities are different concepts. A proof records how supplied premises support a conclusion. It does not validate the observation itself.
For language-model extraction, retain the source, relevant quote, proposed interpretation, model and request identity, acceptance decision and resulting fact. Validate the returned structure and apply explicit acceptance rules before relying on a proposed fact.
Try it
Describe what must be checked before accepting an LLM’s claim that an antenna is healthy. Which checks are structural, which are logical, and which require evidence beyond the model?
From knowledge to controlled agents
world open.
mission[currentRevision -> r2].
check1:Check[revision -> r1; approved -> true].
mission:MayProceed <- mission[currentRevision -> ?Revision]
AND ?Check:Check[revision -> ?Revision; approved -> true].
?- mission:MayProceed.
expect unknown: mission:MayProceed.
The check approves an older revision. The current revision is r2, so the rule cannot derive permission. In this open model the question is unknown. It is not safe to proceed merely because some approval exists somewhere in the database.
FrameX models preconditions, evidence requirements, workflow states and permission rules. An external host invokes tools, records receipts and updates observations; the engine then recomputes consequences. Revision matching prevents a stale approval from authorizing a new state.
For H2, a model can propose an interpretation or extracted fact for FrameX to check. In the other direction, derived answers and provenance can constrain a prompt that asks the model to explain or request clarification. For H3, rules determine the next eligible action and the conditions for adaptation or termination. Python supplies generic adapters rather than hiding domain policy in a convenience function.
External declarations need the documented tool host; an offline rule run is not evidence of a real model call.
Continue with Tool Calling for a runnable host episode, or Hybrid AI with FrameX for the invariants and patterns used to design the boundary between models and rules.
Try it
Change the current revision to r1 and predict the permission. Then explain what an executor must check again immediately before acting.
Quick reference: choosing the right construct
Use this page to choose a representation for the question you want to answer. Return to the corresponding lesson for a complete executable example.
| Your modeling task | FrameX construct and meaning |
|---|---|
| Identify an entity | Give it a stable object name. A label is a property, not its identity. |
| Classify an object | Membership uses a colon. An object can belong to several classes. |
| Describe relationships | Use named slots inside brackets. The property arrow asserts a value; it does not replace existing values. |
| Record several values | Use a value bundle or separate slot facts. Neither implies order or completeness. |
| Organize classes | Use extends for inheritance. Class structure is distinct from namespace qualification. |
| Describe a relation with an argument | Use a parameterized method. A logical method result does not execute a procedure. |
| Attach evidence to a relationship | Model the relationship as a named object with explicit roles and source information. |
| Derive a consequence | Write a rule with a head and a body. Bind variables positively and group alternatives explicitly. |
| Follow a graph path | Join frames through a shared variable. Use positive recursion for paths of arbitrary finite length. |
| Ask a question | Use an embedded query. Bind candidate objects before selecting unknown answers. |
| Check an expected answer | Use an expectation. It checks a result; it does not establish evidence. |
| Reason about absence | Declare the world and close only features whose evidence is complete in the intended scope. |
| Check data quality | Separate field types, required values and functional constraints. Validation reports issues rather than repairing them. |
| Combine knowledge sources | Use namespaces for identity and modules for knowledge contexts. Preserve evidence and completeness boundaries. |
| Understand a conclusion | Inspect its explanation and source annotations. Logical support is distinct from calibrated probability. |
| Control an agent | Derive permissions from current state and evidence. An external host performs authorized tool operations and records results. |
Practice and verification
A five-step reading exercise
- Identify every object, class, property and variable in the family example.
- Predict a new child binding before running the modified file.
- Explain Sarah’s ancestor relationship through the two supporting edges.
- Contrast p2’s review status in the open and selectively closed examples.
- Explain why an old approval does not authorize the current mission revision.
Discussion notes
A shared variable joins facts about the same identifier. Multiple values produce separate bindings, not assignment. Recursion propagates consequences through a finite graph. Unknown is not false; closure is a completeness commitment. Permission is about the current state, not the existence of any earlier approval. These are the conceptual checks to master before connecting a language model.
Reproduce the executable checks
python3 scripts/check-examples.py --binary /path/to/framex
Run this from the FrameX-Doc repository after installing the engine and Python package. The checker verifies tutorial query rows and expectations, the complete family program, the namespace and external-request examples, the extraction receipt scenarios, and the Python exercise outputs. It also runs the tool-query episode and scripted dialogue without a model.
For the schema lesson, experiment with framex validate on a saved copy as
described in Lesson 6. The new
grounded extraction example includes
downloadable receipts for valid evidence, refused evidence and a failed call.
Python API Exercise
This section is a hands-on exercise track for the Python API. It assumes you can already read and write basic FrameX, and teaches you to drive the engine from a Python program.
Seven tasks build one program step by step. Each task adds a few lines to the file you wrote in the task before, so work through them in order and keep your file.
How the solutions work
The solution to a task is at the top of the next page, folded away behind a Show the full program and what it prints toggle. So you reach an answer only by turning the page and then opening it, and you can read on past a solution without seeing it.
Every solution is the complete program as it stands at that point, together with the output it really prints. If your file has drifted, you can pick the thread back up from any page.
Before you start
Create main.py in your FrameX-Workbench workspace and press Run program to
run it; what your program prints appears in the result panel. framex is
already installed there, so from framex import Client works as it stands.
The running example is the family model from
Family Tree, cut down to two rules. Only the four
hasParent facts are stated — every sibling and grandparent answer you will
see is derived.
Tasks
- Task 1: Connect and Ask
- Task 2: Reading the Answer
- Task 3: Growing the Knowledge Base
- Task 4: Making the Engine Explain Itself
- Task 5: Revising Safely
- Task 6: Worlds and Validation
- Task 7: When Things Go Wrong
- Task 8: Solution and What Comes Next
Task 1: Connect and Ask
Theory: The Client
Client starts the framex engine as a separate process and talks to it over
its standard input and output. One Client is one session. What you load stays
loaded until you replace it, so the calls you make build on each other.
Use it as a context manager. The with block shuts the engine down when it
ends, including when your program raises:
from framex import Client
with Client() as client:
...
load sends a complete program and replaces whatever the session held before.
It answers with the number of facts the engine now holds, counting the facts it
derived for itself:
{'facts': 26}
query sends one question and returns a dictionary. A question about a fact
with no variables answers {'status': 'true'}.
Task
Create main.py in your Workbench workspace and press Run program.
- Import
Clientfromframex. - Put the family program below in a module-level string called
PROGRAM. It is the model from Family Tree, cut down to two rules. - Open a
Clientin awithblock andloadthe program. Print whatloadreturned. - Ask whether Elena is Dario’s grandparent, and print the answer.
world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
Only the four hasParent facts are stated. Every sibling and grandparent
answer in this exercise is derived from them.
Keep this file. Every later task grows it.
Optional Extra Task
Ask a second question, this time one the program cannot support: whether Felix is Dario’s grandparent. Predict the answer before you run it.
If the engine is not on your PATH, Client() cannot start it. Point at it
explicitly instead, as Installation and Quick Start
shows:
client = Client(binary="/usr/local/bin/framex")
Task 2: Reading the Answer
Solution to Task 1
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
Theory: Four Shapes of Answer
Every query returns a dictionary with a status. There are four values:
status | Meaning |
|---|---|
true | The engine derived the fact |
false | The engine refuted it |
unknown | The engine can neither support nor refute it |
bindings | The question had variables; the matches are in bindings |
The bindings key is present only when status is bindings:
{'status': 'true'}
{'status': 'bindings', 'bindings': [{'G': 'elena'}]}
So result["bindings"] raises KeyError on a question without variables. Read
status first, then reach for bindings.
bindings is a list of dictionaries, one per match. The keys are the variable
names without the ? marker: ?G in the query comes back as G. An empty
list means the question had variables but nothing matched.
unknown is not false. Under world open. a question the program can
neither support nor refute stays open, and the engine says so rather than
guessing. See World Assumption.
Task
Add a function describe(result) above main that turns a result dictionary
into a short readable string:
- when
statusisbindings, report how many matches there were and show them - otherwise, report the status itself
Then use it to ask four questions and print each answer:
?- dario[hasGrandparent -> elena].— a fact the rules derive?- dario[hasGrandparent -> ?G].— the same question with a variable?- dario[hasParent -> anna].— something the program never states?- ?X[hasSibling -> ?Y].— two variables at once
Before running, predict which of the four comes back unknown, and how many
matches the last one finds.
Optional Extra Task
The sibling question returns Anna and Bruno twice, once in each direction. Print the matches as one line each instead of one long list, and explain in a comment why both directions appear.
Task 3: Growing the Knowledge Base
Solution to Task 2
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
Theory: Adding Without Reloading
There are two ways to put knowledge into a session, and they are not the same.
load replaces the session. Everything the engine held is gone, and the
new program takes its place.
add extends it. The facts and rules you pass join what is already there:
client.add(source="carla : Woman.\n")
Note the keyword: add(source=...), not add(...). The same holds for
retract(fact=...) later.
add answers with the change it caused:
{'added': [...], 'removed': [...]}
Both lists include derived facts, not just the ones you wrote. Adding one
person who shares a parent makes the sibling rule fire, and the new sibling
facts appear in added alongside the class membership the engine inferred. The
engine recomputes consequences as soon as the knowledge changes; you never ask
it to.
Task
Give Elena a third child.
- Add a module-level string
NEW_FACTSstating thatcarlais aWomanand that Carla’s parent iselena. - Call
addwith it, and print theaddedandremovedlists separately. - Ask who Carla’s siblings are, and print the answer with
describe.
You wrote two facts. Count how many entries come back in added, and work out
where each one came from.
Optional Extra Task
Add a rule rather than facts, and watch a larger cascade. Give the program an aunt rule:
?C[hasAunt -> ?A] <- ?C[hasParent -> ?P] AND ?P[hasSibling -> ?A] AND ?A : Woman.
Predict how many facts it derives before you run it. Remember that Carla is now a sibling of both Anna and Bruno.
Task 4: Making the Engine Explain Itself
Solution to Task 3
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
NEW_FACTS = """carla : Woman.
carla[hasParent -> elena].
"""
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
change = client.add(source=NEW_FACTS)
print("added :", change["added"])
print("removed:", change["removed"])
print("carla :", describe(client.query("?- carla[hasSibling -> ?Y].")))
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
added : ['carla:Person', 'anna[hasSibling -> carla]', 'bruno[hasSibling -> carla]', 'carla[hasSibling -> anna]', 'carla[hasSibling -> bruno]']
removed: []
carla : 2 match(es): [{'Y': 'anna'}, {'Y': 'bruno'}]
Theory: Asking Why
Two calls ask the engine about its own reasoning.
explain(fact) returns a string, not a dictionary. It is a ready-formatted
proof tree, so print it directly:
print(client.explain("dario[hasGrandparent -> elena]"))
It names the rule that fired, the bindings it used, and the asserted facts
underneath, down to the source line each came from. Note the argument: a bare
fact, with no ?- and no trailing period.
why_not(fact) handles the opposite case and returns a dictionary:
{'fact': ..., 'nodes': [...], 'complete': True, 'scope': '...'}
Each entry in nodes carries a goal and its status. The scope field is a
disclaimer the engine states about its own answer:
bounded first-blocker inspection; not exhaustive minimal repairs
It reports the first thing that blocked the derivation. It does not enumerate every possible repair, and a returned explanation is not evidence that a claim holds in the real world.
Task
Make the program account for two of its earlier answers.
- Print the explanation for
dario[hasGrandparent -> elena]. - Call
why_notfordario[hasParent -> anna]— the question that came backunknownin Task 2. - From the result, print the status of the first entry in
nodes, and print thescopefield.
Read the proof tree against the program. The rule it names should be the
grandparent rule, and the two facts underneath should be the two hasParent
facts that chain through Bruno.
Optional Extra Task
Combine a query with an explanation. Ask ?- ?X[hasSibling -> carla]., then
loop over the bindings and print an explanation for each match. Build each fact
string from the binding you got back.
Task 5: Revising Safely
Solution to Task 4
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
NEW_FACTS = """carla : Woman.
carla[hasParent -> elena].
"""
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
change = client.add(source=NEW_FACTS)
print("added :", change["added"])
print("removed:", change["removed"])
print("carla :", describe(client.query("?- carla[hasSibling -> ?Y].")))
print()
print(client.explain("dario[hasGrandparent -> elena]"))
blocked = client.why_not("dario[hasParent -> anna]")
print("why_not status:", blocked["nodes"][0]["status"])
print("why_not scope :", blocked["scope"])
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
added : ['carla:Person', 'anna[hasSibling -> carla]', 'bruno[hasSibling -> carla]', 'carla[hasSibling -> anna]', 'carla[hasSibling -> bruno]']
removed: []
carla : 2 match(es): [{'Y': 'anna'}, {'Y': 'bruno'}]
World assumption: open
dario[hasGrandparent -> elena]
Derived by rule #1: ?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
Bindings: C = dario, G = elena, P = bruno
Because:
dario[hasParent -> bruno]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:15:1
bruno[hasParent -> elena]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:14:1
why_not status: unknown
why_not scope : bounded first-blocker inspection; not exhaustive minimal repairs
Theory: Look Before You Retract
Removing a stated fact also removes everything derived from it. dependents
shows that reach before you commit to it:
client.dependents("dario[hasParent -> bruno]")
It returns a plain list of fact strings — the facts that would lose their support:
['dario[hasGrandparent -> elena]']
retract(fact=...) then withdraws the stated fact and answers with the same
added / removed shape that add used. The derived facts appear in
removed; you withdrew one fact and the engine dropped its consequences.
diff() repeats the most recent change. It takes no arguments and is useful
when the call that caused the change is far from the code inspecting it.
After a retraction the question that used to answer true answers unknown,
not false. Withdrawing evidence removes a reason to believe something. It
does not create a reason to disbelieve it.
Task
Take Dario’s parent away and watch the consequences.
- Print the dependents of
dario[hasParent -> bruno]before touching it. - Retract the fact, and print the
removedlist from the result. - Print the
removedlist fromdiff()as well, and compare the two. - Ask
?- dario[hasGrandparent -> elena].again and print the answer.
Predict the final status before you run it. It was true in Task 1.
Optional Extra Task
retract_source(source) removes everything that came from one source at once,
rather than one fact at a time. compare(facts=..., before=..., after=...)
checks a set of facts across two revisions.
Use dependents on bruno[hasParent -> elena] instead, and predict how many
facts that retraction would remove before you try it. It sits one step higher in
the chain than the fact you just withdrew.
Task 6: Worlds and Validation
Solution to Task 5
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
NEW_FACTS = """carla : Woman.
carla[hasParent -> elena].
"""
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
change = client.add(source=NEW_FACTS)
print("added :", change["added"])
print("removed:", change["removed"])
print("carla :", describe(client.query("?- carla[hasSibling -> ?Y].")))
print()
print(client.explain("dario[hasGrandparent -> elena]"))
blocked = client.why_not("dario[hasParent -> anna]")
print("why_not status:", blocked["nodes"][0]["status"])
print("why_not scope :", blocked["scope"])
print()
print("dependents:", client.dependents("dario[hasParent -> bruno]"))
removal = client.retract(fact="dario[hasParent -> bruno]")
print("retracted :", removal["removed"])
print("diff :", client.diff()["removed"])
print("after :", describe(client.query("?- dario[hasGrandparent -> elena].")))
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
added : ['carla:Person', 'anna[hasSibling -> carla]', 'bruno[hasSibling -> carla]', 'carla[hasSibling -> anna]', 'carla[hasSibling -> bruno]']
removed: []
carla : 2 match(es): [{'Y': 'anna'}, {'Y': 'bruno'}]
World assumption: open
dario[hasGrandparent -> elena]
Derived by rule #1: ?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
Bindings: C = dario, G = elena, P = bruno
Because:
dario[hasParent -> bruno]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:15:1
bruno[hasParent -> elena]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:14:1
why_not status: unknown
why_not scope : bounded first-blocker inspection; not exhaustive minimal repairs
dependents: ['dario[hasGrandparent -> elena]']
retracted : ['dario[hasGrandparent -> elena]']
diff : ['dario[hasGrandparent -> elena]']
after : unknown
Theory: Closing the World
world(mode) switches the session’s assumption between "open" and
"closed", and answers with the same added / removed diff as any other
change.
The switch changes what missing evidence means. Under world open. a fact the
program cannot support is unknown. Under world closed. the program is taken
to be complete, so the same fact is false:
open : unknown
closed : false
Close a world only when the data really is complete. Closing an incomplete
program turns every gap in it into a confident false.
validate() runs the declared checks and returns four keys:
{'violations': [...], 'schema_checks': [...], 'constraint_checks': [...], 'coverage': '...'}
An empty violations list means nothing the engine checked was violated. Read
coverage before concluding more than that — it states what validation did and
did not cover, and it is explicit that there is no global consistency or
completeness claim.
Task
Your program currently ends with a question that answers unknown. Close the
world under it.
- Call
world("closed")and print theremovedlist it returns. - Ask
?- dario[hasGrandparent -> elena].again and print the answer. - Call
validate()and print theviolationslist.
The same question has now given three different answers across the exercise:
true in Task 1, unknown in Task 5, false here. Be able to say why each
one changed.
Optional Extra Task
Print the coverage string in full and read it. Then give the program
something to find: state a fact that conflicts with the hasName: String
signature on Person, re-run validate(), and see whether it appears in
violations or in schema_checks.
Task 7: When Things Go Wrong
Solution to Task 6
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
NEW_FACTS = """carla : Woman.
carla[hasParent -> elena].
"""
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
change = client.add(source=NEW_FACTS)
print("added :", change["added"])
print("removed:", change["removed"])
print("carla :", describe(client.query("?- carla[hasSibling -> ?Y].")))
print()
print(client.explain("dario[hasGrandparent -> elena]"))
blocked = client.why_not("dario[hasParent -> anna]")
print("why_not status:", blocked["nodes"][0]["status"])
print("why_not scope :", blocked["scope"])
print()
print("dependents:", client.dependents("dario[hasParent -> bruno]"))
removal = client.retract(fact="dario[hasParent -> bruno]")
print("retracted :", removal["removed"])
print("diff :", client.diff()["removed"])
print("after :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print()
switch = client.world("closed")
print("closing removed:", switch["removed"])
print("closed :", describe(client.query("?- dario[hasGrandparent -> elena].")))
report = client.validate()
print("violations :", report["violations"])
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
added : ['carla:Person', 'anna[hasSibling -> carla]', 'bruno[hasSibling -> carla]', 'carla[hasSibling -> anna]', 'carla[hasSibling -> bruno]']
removed: []
carla : 2 match(es): [{'Y': 'anna'}, {'Y': 'bruno'}]
World assumption: open
dario[hasGrandparent -> elena]
Derived by rule #1: ?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
Bindings: C = dario, G = elena, P = bruno
Because:
dario[hasParent -> bruno]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:15:1
bruno[hasParent -> elena]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:14:1
why_not status: unknown
why_not scope : bounded first-blocker inspection; not exhaustive minimal repairs
dependents: ['dario[hasGrandparent -> elena]']
retracted : ['dario[hasGrandparent -> elena]']
diff : ['dario[hasGrandparent -> elena]']
after : unknown
closing removed: []
closed : false
violations : []
Theory: Failures and the Record
Two exceptions come out of the client, and they mean different things.
FrameXError is the engine rejecting a request. The session is fine and you
can keep using it. The detail is on .error:
except FrameXError as error:
print(error.error["kind"], error.error["message"])
FrameXTimeout is different. The request passed request_timeout seconds
(30 by default), so the client killed the process. If the timed-out request
was a mutation, its outcome is unknown: it may have been applied. Do not retry
it — start a new session.
client = Client(request_timeout=60)
Every attempted request is recorded, failures included. transcript is a
property, not a method, and gives back a list of dictionaries:
[entry["command"] for entry in client.transcript]
save_replay(path) writes that record to a file. It opens the file in mode
"x", so it refuses to overwrite — a second run raises FileExistsError.
In the Workbench, where you press Run program repeatedly, catch it.
Task
Make the program survive a bad request and then record what it did.
- Import
FrameXErroralongsideClient. - Add a helper
ask(client, query)that runs a query throughdescribeand, onFrameXError, returns the error’skindandmessageinstead of raising. - Send something that is not valid FrameX and print what comes back.
- Print the list of commands in the transcript.
- Call
save_replay("session.jsonl"), catchingFileExistsErrorand printing a clear message instead.
Run the program twice. The second run should report that the replay file already exists, and should not crash.
Optional Extra Task
Find the rejected query in the transcript. The engine refused it, yet it is still recorded — the transcript holds what was attempted, not what succeeded. Print the last entry in full and confirm it is the broken query.
Then raise the failure deliberately: build a Client(request_timeout=...) with
a very small timeout and see what a FrameXTimeout does to the session.
Task 8: Solution and What Comes Next
Solution to Task 7
Show the full program and what it prints
"""Drive a small family knowledge base from Python."""
from framex import Client, FrameXError
PROGRAM = """world open.
Person { hasName: String }.
Man extends Person.
Woman extends Person.
elena : Woman.
anna : Woman.
bruno : Man.
dario : Man.
felix : Man.
anna[hasParent -> elena].
bruno[hasParent -> elena].
dario[hasParent -> bruno].
felix[hasParent -> anna].
?X[hasSibling -> ?Y] <- ?X[hasParent -> ?P] AND ?Y[hasParent -> ?P] AND ?X != ?Y.
?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
"""
def describe(result):
status = result["status"]
if status == "bindings":
rows = result["bindings"]
return f"{len(rows)} match(es): {rows}"
return status
NEW_FACTS = """carla : Woman.
carla[hasParent -> elena].
"""
def ask(client, query):
try:
return describe(client.query(query))
except FrameXError as error:
return f"rejected ({error.error['kind']}): {error.error['message']}"
def main():
with Client() as client:
loaded = client.load(PROGRAM)
print("loaded:", loaded)
result = client.query("?- dario[hasGrandparent -> elena].")
print("grandparent:", result)
print("ground :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print("binding:", describe(client.query("?- dario[hasGrandparent -> ?G].")))
print("open :", describe(client.query("?- dario[hasParent -> anna].")))
print("many :", describe(client.query("?- ?X[hasSibling -> ?Y].")))
change = client.add(source=NEW_FACTS)
print("added :", change["added"])
print("removed:", change["removed"])
print("carla :", describe(client.query("?- carla[hasSibling -> ?Y].")))
print()
print(client.explain("dario[hasGrandparent -> elena]"))
blocked = client.why_not("dario[hasParent -> anna]")
print("why_not status:", blocked["nodes"][0]["status"])
print("why_not scope :", blocked["scope"])
print()
print("dependents:", client.dependents("dario[hasParent -> bruno]"))
removal = client.retract(fact="dario[hasParent -> bruno]")
print("retracted :", removal["removed"])
print("diff :", client.diff()["removed"])
print("after :", describe(client.query("?- dario[hasGrandparent -> elena].")))
print()
switch = client.world("closed")
print("closing removed:", switch["removed"])
print("closed :", describe(client.query("?- dario[hasGrandparent -> elena].")))
report = client.validate()
print("violations :", report["violations"])
print()
print("bad query:", ask(client, "?- this is not FrameX"))
print("commands :", [entry["command"] for entry in client.transcript])
try:
client.save_replay("session.jsonl")
print("replay : written to session.jsonl")
except FileExistsError:
print("replay : session.jsonl already exists; delete it first")
if __name__ == "__main__":
main()
Running it prints:
loaded: {'facts': 26}
grandparent: {'status': 'true'}
ground : true
binding: 1 match(es): [{'G': 'elena'}]
open : unknown
many : 2 match(es): [{'X': 'anna', 'Y': 'bruno'}, {'X': 'bruno', 'Y': 'anna'}]
added : ['carla:Person', 'anna[hasSibling -> carla]', 'bruno[hasSibling -> carla]', 'carla[hasSibling -> anna]', 'carla[hasSibling -> bruno]']
removed: []
carla : 2 match(es): [{'Y': 'anna'}, {'Y': 'bruno'}]
World assumption: open
dario[hasGrandparent -> elena]
Derived by rule #1: ?C[hasGrandparent -> ?G] <- ?C[hasParent -> ?P] AND ?P[hasParent -> ?G].
Bindings: C = dario, G = elena, P = bruno
Because:
dario[hasParent -> bruno]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:15:1
bruno[hasParent -> elena]
Asserted fact
Source: <json-source> | time: unspecified | weight: None | <json-source>:14:1
why_not status: unknown
why_not scope : bounded first-blocker inspection; not exhaustive minimal repairs
dependents: ['dario[hasGrandparent -> elena]']
retracted : ['dario[hasGrandparent -> elena]']
diff : ['dario[hasGrandparent -> elena]']
after : unknown
closing removed: []
closed : false
violations : []
bad query: rejected (SyntaxError): expected comparison operator
commands : ['load', 'query', 'query', 'query', 'query', 'query', 'add', 'query', 'explain', 'why_not', 'dependents', 'retract', 'diff', 'query', 'world', 'query', 'validate', 'query']
replay : written to session.jsonl
More to Come
That is the core of the Python API: start a session, load a program, ask
questions, grow and revise what the engine knows, make it explain itself,
switch the world assumption, and keep a record of everything you sent. The
Client page lists the methods in one table, including
the ones this exercise did not use — compare, retract_source, rdf_import
and rdf_export.
Continue with Tool Calling: rules derive a request, Python runs a registered tool, and its receipt returns to the rules. The Grounded Extraction example adds evidence-based acceptance and offline failure tests.
The hybrid pattern guide describes further designs, including query translation and candidate-rule checking. The public export has a smaller scope than the development framework; consult Sources and Version Scope before using a component from the handbook.