Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Python API

Hybrid AI with FrameX

Examples

Tutorial

Python API Exercise


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/.

  1. Login to framex.nlp-lab.ai. If you dont have a user, please reach out to the Teaching Assistants.
  2. Start the Workspace using the Start Workspace button on the top right.
  3. 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.
  4. Write your FrameX F-Logic code in the created file. You can use the example from above to start tinkering.
  5. Save the file and press Run program on the top right to evaluate the file.
  6. 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.

  1. Name the file quick-start.fx.
  2. Run it from a terminal:
framex run quick-start.fx
  1. 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

ComponentResponsibility
framex.ClientPersistent JSON Lines session with framex serve
framex.toolsPerform registered tool requests and record receipts
framex.providerModel-provider transport
framex.dialogue and flow_notationRule-controlled dialogue and compact .flow notation
framex.interview and guide_notationCoverage-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

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 remains unknown. 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 as false.

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:

  1. An alias match establishes nothing. Two objects may share a display name (the cars#jaguar / animals#jaguar case), and asking about one in place of the other answers unknown rather than guessing.
  2. A missing output of your implementation is a defect, not an encoding of unknown. Distinguish “the engine answered unknown” from “my program produced no answer”.

Rule of thumb

Silence means unknown under world open. and false under world closed.. A stored false is data, not negation. If you need a logical false under 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:

CombinationTrueFalseUnknown
Required conditions with ANDEvery condition is trueAt least one condition is falseNo false condition, at least one unknown
Alternatives with ORAt least one alternative is trueEvery alternative is false in a complete candidate setNo 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:

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:

PartInstalled byLocation
Python package, including Client and tool hostsuv add / uv syncProject .venv
framex engine binaryscripts/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:

FileWhat to reuse or study
pyproject.tomlGit source, package subdirectory and shared revision pin
scripts/install-engine.shBuild the engine into the uv environment
src/runtime/client.pyApplication-specific client extensions for staged bulk loading
src/runtime/session.pyA 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

SymptomCheck
Git source has no Python projectInclude #subdirectory=python in the dependency URL
ModuleNotFoundError: framexRun uv sync and launch with uv run
FileNotFoundError for framexInstall the engine after syncing, then check uv run framex --version
Installer cannot find a revisionKeep the inline [tool.uv.sources] entry shown above, including rev
Installer cannot find CargoInstall the Rust toolchain and make Cargo available in your shell
Lockfile is out of dateRun 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

MethodDescription
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 .error dictionary with kind and message.
  • 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.

StageStandard model tool callingFrameX tool calling
ChooseThe model selects a toolRules derive a request
ExecuteThe host runs the toolThe host runs the tool
ReturnThe result goes to the modelThe result becomes a receipt
ContinueThe model interprets the resultRules 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).

ToolRequest fieldsSuccessful output
_llmprompt and input fields; optional host fields belowModel scalars as result("key"); rows as row(N, "key")
_queryqueryresult("status"): true, false, unknown or bindings; result("text"): the full answer as JSON text
_evidence_needsgoal; optional max_states, max_assumptionsBounded evidence-question report; preserve its search limits and completeness information
_forgetAnswerstextresult("removed"): number of stored model-answer records deleted
_fixtureresponse: a JSON object encoded as a stringAuthored output, with evidence that no model was called
_emitmessageresult("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

FieldBehavior
promptInstruction; other named input fields form one JSON message
inputExplicit user-message text; takes precedence over collecting other named fields
schemaJSON schema in direct Python calls; constrains provider formatting where supported
groundInSource text; adds quoteFound and valueQuoted to each returned row
mustSayText for a lexical check; adds saysDecision based on the reply’s text
contextKeyStep whose selectedFor statements rules selected as context
tool#maxContextContext 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.

ToolRequest fieldsSuccessful output
_turnquestion, attemptanswer, spoken utterance, canonical question
_judgecriterion, question, answerverdict: yes, no or unclear; reason; label where returned
_askquestionquestion, answer; no model used
_saytextDisplays the text and records delivery
_repairTurn, _repairJudgeOriginal fields and repair informationAnother 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

VariableMeaning
FRAMEX_MODEL_PROVIDERollama (default) or openai
FRAMEX_MODELModel identifier; defaults to local for Ollama or gpt-5-mini for the compatible provider
FRAMEX_MODEL_ENDPOINTOllama base URL or full compatible chat-completions URL
FRAMEX_MODEL_KEY_ENVKey variable name, default OPENAI_API_KEY
FRAMEX_MODEL_CACHEAnswer store, default ~/.cache/framex/model
FRAMEX_MODEL_REFRESH1 asks again instead of replaying
FRAMEX_MODEL_TIMEOUTModel-call timeout in seconds, from 1 to 600
FRAMEX_MODEL_SCHEMA0 disables provider JSON-schema constraints
FRAMEX_MODEL_JSON_EXTRACT1 opts into extracting JSON from surrounding text; evidence marks json_extracted
FRAMEX_MODEL_RESPONSE_FORMATjson_object (default) or none
FRAMEX_MODEL_THINKtrue or false for Ollama and supported local compatible servers
FRAMEX_MODEL_MAX_TOKENSPositive output-token limit for the compatible provider
FRAMEX_MODEL_HTTP_HOSTPermits 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

RecordContentsUse
Model-answer cacheProvider, model, messages, timestamp, output and provider evidenceReplay an identical request without a live call
Episode receiptsTool-result facts linked to request identitiesRecompute conclusions offline with the original program
Client.transcriptAttempted engine protocol requests, including rejected onesExport 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:

LayerQuestions it answersExample
Domain logicWhat follows from the accepted facts?Which connections satisfy the passenger’s constraints?
Control logicWhat 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

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

InvariantImplementation consequence
Model text is dataStore proposed rules, queries and actions as candidates. Check and admit them before execution.
Model answers are dated observationsRetain prompt, inputs, model, timestamp and result so observations can be audited and replayed.
Rules decideEncode acceptance, transitions and completion in executable rules.
Evidence decides acceptanceRequire checkable evidence, such as a permitted property and a supporting source quote. Confidence is ordinary data.
Failures stay distinctPreserve 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

MoveWhat crosses the boundary
RequestA rule derives a request with the exact inputs the tool may see.
ReceiptThe host records the outcome as facts linked to that request and its subject.
AcceptA rule admits a proposed claim, records a reason to refuse it, or leaves it unresolved.
AskA 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

SituationRepresentationMeaning
Provider or tool failuretool#status -> "failed" with errorExecution did not produce usable output
Invalid response formatGeneric host failure, or dialogue formatError and received fieldsA reply arrived but broke the expected contract
Ambiguous interpretationJudge result("verdict") -> "unclear"The model could not settle the criterion
Logical unknownQuery status unknownCurrent facts and completeness assumptions do not settle the goal
Evidence refusedAn application rejection rule and its reasonA 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

PatternUse it whenWhat the rules must decide
1. Model as SensorFacts are expressed in documents, speech or other unstructured inputWhich extracted values have sufficient evidence and may become facts
2. Model as AuthorA policy text needs a proposed formalizationWhether a draft passes syntax, vocabulary and regression checks before a person adopts it
3. Model as TranslatorA person asks a question or needs an explanation in ordinary languageWhether a proposed query may run and whether the answer stays tied to the symbolic result
12. Model Selects, Rules WriteDisplayed passages and values must be exactWhether 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

PatternUse it whenWhat the rules must decide
4. Model Proposes, Rules DisposeA model proposes actions with external effectsAdmit, refuse or narrow the proposal based on current permissions and evidence
5. Rules Steer the ModelA protocol has explicit steps and stopping conditionsWhich step is active, what context it sees, and what may happen next
6. Critique and RepairA checkable reply fails a conditionWhat feedback to return, how many attempts are allowed, and when repair has stalled
7. Division by CompetenceSome cases can be decided formally while others remain ambiguousWhich cases the rules settle and which require another reader or a person
13. Fail-Safe UnionSeveral checks assess one case and an overly permissive result is costlyCombine 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

PatternUse it whenWhat the rules must decide
8. Model as Hypothesis GeneratorAn observation needs candidate explanationsWhich hypotheses survive explicit tests and what evidence would discriminate them
9. Model as Critic of the RulesA rule change needs review beyond available automated checksWhich grounded remarks become findings and whether unresolved findings block approval
10. Rules Teach the ModelA model needs examples, labels or feedback from a formal domainLabels, 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

PatternUse it whenWhat the rules must decide
11. Rule-Governed MemoryConversation-derived knowledge persists and changes across sessionsWhich 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.

RequirementStarting patterns
Read facts from documents or speech1 Sensor, with 6 Repair
Turn an explicit policy text into candidate rules2 Author, with checks and 6 Repair
Ask questions in everyday language or explain a proof3 Translator
Propose external actions4 Proposes/Dispose, often with 5 Steer
Follow a multistep protocol or conversation5 Steer, 1 Sensor, 6 Repair, 7 Division
Escalate ambiguous cases7 Division
Explain an observation with candidate causes8 Hypothesis Generator, often with 5 Steer
Review a policy change beyond automated checks9 Critic, supplementing 2 Author
Supply a model with formal labels and feedback10 Teach
Remember claims across sessions and revise them11 Memory, composed with 1 and 5
Display exact passages or values12 Selects/Write
Combine several judgments under a conservative ordering13 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:

  1. Translator (3) maps a passenger’s question into a candidate query.
  2. A check limits its vocabulary and operations; the engine evaluates the admitted query against accepted timetable facts.
  3. Steer (5) selects the next step: answer, obtain a missing constraint, or explain why a requested connection is unsupported.
  4. Division (7) routes ambiguity to a person. Repair (6) handles invalid output within a fixed attempt limit.
  5. 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 patternWhat goes wrongConcrete correction
Silent Rule OverrideHost code changes a verdict outside the proofRecord the observation and derive the correction explicitly
Circular EvaluationThe evaluated model or developer also supplies its own gold labelsFix independent cases and criteria before measuring
Self-JudgeAn answer and its assessment share the same blind spotsUse checkable external evidence and independent assessment
Prose PolicyDecisions exist only as instructions in a promptPut the decisive conditions and transitions into testable rules
Decorative CheckEvidence is logged but does not affect acceptanceRequire 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 exportAdditional development/course components
Rust engine; Python Client, tools, provider, dialogue, flow_notation, interview, guide_notation; flow and coverage policies; an interview exampleframex.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 sectionDocumentation
2.1, pp. 3–5: requests, receipts and hostsTool Calling, Tool Reference
2.2–2.3, pp. 5–6: invariants and movesFoundations
3–6, pp. 7–59: thirteen patternsPattern guide
7–8, pp. 60–61: compositions and failure patternsChoosing patterns
10, pp. 65–68: decision guide and measurementsChoosing patterns
Appendix A, pp. 69–73: installation, replay and debuggingInstallation, 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

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:

  • elena is the parent of anna and bruno
  • bruno is the parent of dario
  • anna is the parent of felix
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 why felix[hasUncle -> bruno] becomes unknown rather than false.
  • Add expect true: elena[hasChild -> anna]. and explain which declaration makes it follow with no extra rule.
  • Add a second child for anna and check who becomes cousins with dario.
  • Remove one ?X != ?Y guard 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:

FixtureWhat it tests
grounded-extraction.accepted.test.fxValid evidence produces the customer fact
grounded-extraction.rejected.test.fxA missing source quote, an unquoted value or a different slot prevents acceptance
grounded-extraction.failed.test.fxFailure 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

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 taskFrameX construct and meaning
Identify an entityGive it a stable object name. A label is a property, not its identity.
Classify an objectMembership uses a colon. An object can belong to several classes.
Describe relationshipsUse named slots inside brackets. The property arrow asserts a value; it does not replace existing values.
Record several valuesUse a value bundle or separate slot facts. Neither implies order or completeness.
Organize classesUse extends for inheritance. Class structure is distinct from namespace qualification.
Describe a relation with an argumentUse a parameterized method. A logical method result does not execute a procedure.
Attach evidence to a relationshipModel the relationship as a named object with explicit roles and source information.
Derive a consequenceWrite a rule with a head and a body. Bind variables positively and group alternatives explicitly.
Follow a graph pathJoin frames through a shared variable. Use positive recursion for paths of arbitrary finite length.
Ask a questionUse an embedded query. Bind candidate objects before selecting unknown answers.
Check an expected answerUse an expectation. It checks a result; it does not establish evidence.
Reason about absenceDeclare the world and close only features whose evidence is complete in the intended scope.
Check data qualitySeparate field types, required values and functional constraints. Validation reports issues rather than repairing them.
Combine knowledge sourcesUse namespaces for identity and modules for knowledge contexts. Preserve evidence and completeness boundaries.
Understand a conclusionInspect its explanation and source annotations. Logical support is distinct from calibrated probability.
Control an agentDerive permissions from current state and evidence. An external host performs authorized tool operations and records results.

Practice and verification

A five-step reading exercise

  1. Identify every object, class, property and variable in the family example.
  2. Predict a new child binding before running the modified file.
  3. Explain Sarah’s ancestor relationship through the two supporting edges.
  4. Contrast p2’s review status in the open and selectively closed examples.
  5. 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

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.

  1. Import Client from framex.
  2. Put the family program below in a module-level string called PROGRAM. It is the model from Family Tree, cut down to two rules.
  3. Open a Client in a with block and load the program. Print what load returned.
  4. 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:

statusMeaning
trueThe engine derived the fact
falseThe engine refuted it
unknownThe engine can neither support nor refute it
bindingsThe 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 status is bindings, 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:

  1. ?- dario[hasGrandparent -> elena]. — a fact the rules derive
  2. ?- dario[hasGrandparent -> ?G]. — the same question with a variable
  3. ?- dario[hasParent -> anna]. — something the program never states
  4. ?- ?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.

  1. Add a module-level string NEW_FACTS stating that carla is a Woman and that Carla’s parent is elena.
  2. Call add with it, and print the added and removed lists separately.
  3. 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.

  1. Print the explanation for dario[hasGrandparent -> elena].
  2. Call why_not for dario[hasParent -> anna] — the question that came back unknown in Task 2.
  3. From the result, print the status of the first entry in nodes, and print the scope field.

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.

  1. Print the dependents of dario[hasParent -> bruno] before touching it.
  2. Retract the fact, and print the removed list from the result.
  3. Print the removed list from diff() as well, and compare the two.
  4. 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.

  1. Call world("closed") and print the removed list it returns.
  2. Ask ?- dario[hasGrandparent -> elena]. again and print the answer.
  3. Call validate() and print the violations list.

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.

  1. Import FrameXError alongside Client.
  2. Add a helper ask(client, query) that runs a query through describe and, on FrameXError, returns the error’s kind and message instead of raising.
  3. Send something that is not valid FrameX and print what comes back.
  4. Print the list of commands in the transcript.
  5. Call save_replay("session.jsonl"), catching FileExistsError and 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.