Metadata-Version: 2.4
Name: guepard-relml
Version: 0.1.0
Summary: RelML — relational deep learning with a natural-language agent over your database.
Keywords: relational,machine-learning,graph-neural-network,graphsage,database,agent,duckdb
Author: Guepard Corp
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: C++
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Project-URL: Homepage, https://github.com/Guepard-Corp/relml
Project-URL: Repository, https://github.com/Guepard-Corp/relml
Project-URL: Issues, https://github.com/Guepard-Corp/relml/issues
Requires-Python: >=3.9
Requires-Dist: duckdb>=0.9
Requires-Dist: numpy
Requires-Dist: pandas
Requires-Dist: pytz
Requires-Dist: anthropic>=0.40
Requires-Dist: prompt_toolkit
Requires-Dist: scikit-learn
Provides-Extra: api
Requires-Dist: fastapi; extra == "api"
Requires-Dist: uvicorn; extra == "api"
Provides-Extra: plots
Requires-Dist: matplotlib; extra == "plots"
Description-Content-Type: text/markdown

# RelML

RelML is a relational machine learning engine built by Guepard. It trains a
heterogeneous graph neural network (HeteroGraphSAGE) directly on a relational
database: it detects foreign keys, builds the entity graph, encodes node
features, and trains end-to-end without any manual feature joining.
Predictions are locally explainable via a built-in LIME module.

The C++20 core handles encoding, message passing, training, and Adam
optimization. A Python layer built on DuckDB handles data loading and task
definition. A pybind11 binding connects the two. On Apple Silicon the BLAS
backend is Apple Accelerate (uses the AMX co-processor); elsewhere it falls
back to OpenBLAS.

---

## Quick start

```bash
git clone https://github.com/guepard/relml && cd relml
./install.sh                  # builds the C++ extension + installs the Python package
source .venv/bin/activate     # the installer creates this virtualenv
python -c "import guepard.qwery.relml as r; print('relml ok')"   # verify
```

`./install.sh` needs CMake, a C++20 compiler, and Python ≥ 3.9
(`xcode-select --install && brew install cmake python` on macOS;
`sudo apt-get install build-essential cmake python3-dev python3-venv` on
Debian/Ubuntu). To build by hand instead, or to configure the natural-language
agent, see **[Installation](#installation)** below.

---

## Repository layout

```
RelML/
├── README.md                   ← this file (canonical entry point)
├── CMakeLists.txt              ← C++ build
├── benchmarks/                 ← comparisons vs PyG, baselines, applied demos
│   ├── proposal/               ← canonical RelML-vs-PyG suite
│   │   ├── f1_relbench_compare.py    ← F1 driver-top3 comparison
│   │   ├── ml1m_compare.py           ← MovieLens-1M comparison
│   │   ├── f1_compare.py             ← legacy F1 (Postgres-backed) comparison
│   │   ├── sunnyside_compare.py      ← regression comparison
│   │   ├── relml_pyg.py              ← PyG worker (HeteroGraphSAGE mirror)
│   │   ├── _common.py                ← shared helpers (metrics, subprocess tee)
│   │   └── make_plots.py             ← regenerates the docs/figures/ PDFs
│   ├── f1_podium/              ← F1 podium prediction + analysis (Postgres `f1`)
│   │   ├── predict_montreal.py            ← forecast next race (binary podium)
│   │   ├── predict_montreal_position.py   ← forecast via position regression
│   │   ├── eval_podium.py / eval_formonly.py / eval_enriched.py / eval_regression.py
│   │   └── README.md                      ← methodology + findings
│   ├── baselines/              ← simpler baselines (flat MLP, etc.)
│   └── requirements.txt
├── bindings/                   ← pybind11 → _relml_core
├── docs/
│   ├── integration.md          ← detailed integration guide
│   ├── proposal.tex            ← AWS grant proposal
│   └── figures/                ← plot PDFs (regenerated from benchmark CSVs)
├── data/                       ← (gitignored) datasets — see Section 2 below
├── draft/                      ← (gitignored) working artifacts, history CSVs
├── include/relml/              ← public C++ headers
│   ├── encoding/HeteroEncoder.h
│   ├── gnn/HeteroGraphSAGE.h, SAGEKernels.h, MLPHead.h
│   ├── graph/HeteroGraph.h, GraphBuilder.h
│   └── training/Trainer.h, Adam.h, TaskSpec.h
├── python/
│   ├── pyproject.toml          ← package + console scripts (relml-agent/-serve/-schema)
│   ├── examples/               ← end-to-end Python examples
│   │   ├── ml1m_ratings.py            ← MovieLens-1M demo (CSV)
│   │   ├── binary_f1_example.py       ← F1 qualifying top-3 demo (CSV)
│   │   ├── f1_podium.py               ← F1 podium task def (Postgres `f1`)
│   │   ├── sunny_side_demand.py       ← daily-demand regression demo (CSV)
│   │   └── sunnyside_v2_demand.py     ← daily order-count forecast (Postgres
│   │                                    `sunnyside_v2`): holidays + weather +
│   │                                    lags, residual modelling, 20-day backtest
│   └── guepard/
│       ├── qwery/relml/        ← core Python package (TaskSpec, LIMEExplainer, …)
│       └── tools/              ← developer & agent tools (one folder per tool)
│           ├── README.md              ← tools index
│           ├── agent/                 ← NL agent: REPL + orchestrator/subagents,
│           │                            dashboards, live server, JSON-RPC sidecar
│           └── schema_introspect/     ← describe a DB as JSON: schema/PK/FK/
│               │                        stats/samples + RelML dataset_schema
│               ├── core.py            ← describe_database() + CLI
│               ├── api.py             ← FastAPI HTTP wrapper
│               ├── tests.py           ← tests (CSV; PG via env var)
│               └── README.md
├── scripts/                    ← data import helpers (relbench → CSV)
├── src/                        ← C++ implementation
│   ├── encoding/                  HeteroEncoder, type-aware column encoders
│   ├── gnn/                       HeteroGraphSAGE, SAGEKernels, MLPHead
│   ├── graph/                     HeteroGraph, GraphBuilder, FKDetector
│   ├── training/                  Trainer, Adam, Metrics, TaskSpec
│   ├── agent/                     LLM agent layer (TaskParser, RelMLSystem)
│   └── example_tasks/             C++ end-to-end examples
└── tests/                      ← C++ tests + bench
    ├── test_grad_check.cpp        gradient check vs central differences
    ├── test_training.cpp          end-to-end MovieLens-1M training
    ├── test_gnn.cpp / test_encoding.cpp / test_graph.cpp / test_database.cpp
    └── bench_gnn.cpp              microbench of the per-epoch hot path
```

---

## Installation

RelML has two halves: a **C++ core** compiled into a Python extension
(`_relml_core`), and a **Python package** (`guepard`) that ships the DuckDB
data layer, the LIME explainer, and the `relml-agent` CLI.

### Prerequisites

| Need | macOS | Debian / Ubuntu |
|------|-------|-----------------|
| C++20 compiler + CMake ≥ 3.20 | `xcode-select --install && brew install cmake` | `sudo apt-get install build-essential cmake` |
| Python ≥ 3.9 with `pip` | `brew install python` | `sudo apt-get install python3-pip python3-venv` |
| Ruby (for the `uplot` plotter) | `brew install ruby` | `sudo apt-get install ruby` |
| *optional:* OpenMP + logo tools | `brew install libomp chafa librsvg` | `sudo apt-get install libomp-dev chafa librsvg2-bin` |

The first build needs a network connection (CMake fetches pybind11 and
nlohmann/json).

### One command

```bash
git clone https://github.com/guepard/relml && cd relml
./install.sh
source .venv/bin/activate          # the installer creates this virtualenv
```

`install.sh` creates a **virtualenv** (`.venv`), builds `_relml_core` against
it, installs the Python package (giving you the `relml-agent`, `relml-serve`
and `relml-schema` commands), installs the external CLI tools, and seeds a
`.env`.
It never touches the system Python and is idempotent — re-run it anytime.
Flags: `--no-venv`, `--no-editable`, `--skip-external`, `--jobs N`; env
`PYTHON=python3.11 ./install.sh` picks the interpreter, `VENV=path` the
location. If a virtualenv is already active, the installer uses it as-is.

### Manual install

Always install into a virtualenv so the build, the extension, and the CLI all
share one interpreter:

```bash
# 0. Create and activate a virtualenv.
python3 -m venv .venv && source .venv/bin/activate

# 1. Build the Python extension against THIS interpreter. RELML_BUILD_EXTRAS=OFF
#    skips the C++ tests, example tasks, and the libcurl-dependent agent lib.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DRELML_BUILD_EXTRAS=OFF \
      -DPython_EXECUTABLE="$(which python)"
cmake --build build --target _relml_core -j

# 2. Install the Python package (editable) — adds the CLI commands.
pip install -e python/

# 3. External CLI tools the agent shells out to (YouPlot, chafa, librsvg).
bash external/install.sh

# 4. Credentials for the agent.
cp .env.example .env   # then edit
```

The build copies `_relml_core.*.so` into `python/guepard/qwery/relml/`
automatically.

### Verify

With the virtualenv active:

```bash
python -c "import guepard.qwery.relml as r; print('relml ok')"
python -m guepard.tools.agent.tests          # offline test suite
```

### Configure the agent

The agent talks to Claude via **AWS Bedrock** (bearer token) or the **Anthropic
API**. Put one of these in `.env` (see [`.env.example`](.env.example) for the
full list of options):

```ini
AWS_BEARER_TOKEN_BEDROCK=...   # selects the Bedrock backend
# or
ANTHROPIC_API_KEY=...          # first-party Anthropic API
```

`.env` is gitignored — never commit it.

### Run

With the virtualenv active (`source .venv/bin/activate`):

```bash
relml-agent --source "dbname=sunnyside_v2"                        # connect to a database
relml-agent                                                       # bare REPL, then /connect <db>
relml-agent "who will podium in Montreal?" --source "dbname=f1"   # one-shot question
```

A source can be a Postgres DSN, a CSV/Parquet file, or a folder of them. The
agent, its full REPL command set, and the live dashboards are documented in
**[The natural-language agent](#the-natural-language-agent)** below.

### Developer build (C++ tests & benchmarks)

Building with extras **on** (the default) compiles the C++ test suite, gradient
check, example tasks, and the standalone agent library (this path also needs
libcurl — `brew install curl` / `sudo apt-get install libcurl4-openssl-dev`):

```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release      # RELML_BUILD_EXTRAS=ON
cmake --build build -j
./build/test_grad_check                             # gradient correctness

# benchmark vs PyTorch Geometric (downloads relbench F1 CSVs once)
OMP_NUM_THREADS=8 python benchmarks/proposal/f1_relbench_compare.py \
    --data-dir data/rel-f1-data --epochs 50 --history-csv draft/f1_proposal.csv
```

For the full library tutorial (task definition, training, prediction,
explanation, a worked MovieLens example), keep reading — or see
[`docs/integration.md`](docs/integration.md).

---

## The natural-language agent

Everything above is the RelML **library**. RelML also ships a natural-language
**agent**: point it at a database, ask a question in English, and a Claude
orchestrator explores the schema, designs a `TaskSpec`, trains a model,
evaluates it, and answers — delegating to schema-analyst, task-designer and
evaluator subagents along the way. It is installed by `install.sh` /
`pip install -e python/` as three console commands:

| Command | What it is |
|---|---|
| `relml-agent` | The interactive CLI / REPL (and one-shot mode). **Start here.** |
| `relml-serve` | A headless JSON-RPC sidecar over stdio, for embedding RelML in another host (e.g. the qwery TUI). Not interactive. |
| `relml-schema` | Describe any database as JSON (schema, PK/FK, per-column stats, samples) — see [`python/guepard/tools/schema_introspect/`](python/guepard/tools/schema_introspect/). |

First, configure a backend — see [Configure the agent](#configure-the-agent)
(one of `AWS_BEARER_TOKEN_BEDROCK` or `ANTHROPIC_API_KEY` in `.env`).

### Launching `relml-agent`

```bash
# Interactive REPL, already connected to a database:
relml-agent --source "dbname=sunnyside_v2"

# Bare REPL — start with nothing, then /connect from inside:
relml-agent

# One-shot: answer a single question, then drop into the REPL (TTY) or exit:
relml-agent "who will podium in Montreal?" --source "dbname=f1"

# Truly headless one-shot (no REPL afterward):
relml-agent "forecast next week" --source ./data --once
```

A `--source` is a **Postgres DSN** (`dbname=f1`, or a full
`dbname=f1 user=me host=…`), a **CSV/Parquet file**, or a **folder** of them.
A bare Postgres database name is accepted and expanded to `dbname=<name>`.

**All flags:**

| Flag | Default | Purpose |
|---|---|---|
| `question` (positional) | — | The question to ask. Omit for an interactive REPL. A one-shot question **requires** `--source` (or `$RELML_AGENT_SOURCE`). |
| `--source <dsn\|path>` | `$RELML_AGENT_SOURCE` | Database/file/folder to connect to. |
| `--pg-schema <name>` | all schemas | Restrict a Postgres source to one schema. |
| `--backend bedrock\|anthropic` | auto | Force a backend (otherwise inferred from which credential is set). |
| `--model <id>` | per backend | Override the Claude model id. |
| `--region <aws-region>` | `us-east-1` | Bedrock region. |
| `--max-steps <n>` | `40` | Cap the agent's tool-call iterations per question. |
| `--quiet` | off | Don't stream the subagents' thinking / tool calls. |
| `--once` | off | Answer and exit; don't drop into the REPL afterward. |
| `--json` | off | Headless **NDJSON** mode: one JSON event per line, then a final `{"event":"result",…}`. No banner/colors/REPL. Requires a one-shot question. For embedding in another tool. |

### Connecting to a database

**Your data does not have to be in Postgres.** The agent runs on an in-memory
**DuckDB** engine and can ingest three kinds of source — pick whichever you
already have:

| Source | Example | What happens |
|---|---|---|
| **Postgres** (DSN or bare name) | `dbname=f1` · `dbname=f1 user=me host=db.internal port=5432` · `postgresql://me@host/f1` | DuckDB's `postgres` extension **attaches the database read-only**, then **copies every base table** into the in-memory DuckDB. The Postgres server must be **running and reachable** at connect time; your DB is never written to. |
| **CSV / Parquet file** | `./ratings.csv` · `./events.parquet` | The file is read into one DuckDB table named after the file (e.g. `ratings`). |
| **Folder of files** | `./data` | Every `*.csv` / `*.parquet` in the folder becomes a table (named after each file). |

So the answer to *"does it need to be in Postgres first?"* is **no** — Postgres
is only one option. If your data lives in Postgres, point the agent at it and it
attaches read-only; if it's in flat files, point at the file or folder directly.
Either way the agent operates on an **in-memory copy**, so it can never modify
your source, and you can run any read-only SQL against it with `/sql`.

**Two ways to attach:**

```bash
# 1. At launch, with --source:
relml-agent --source "dbname=sunnyside_v2"
relml-agent --source ./data           # a folder of CSV/Parquet
```

```text
# 2. From inside the REPL, with /connect (start bare, then attach):
relml ❯ /databases                    # list Postgres databases on the server (works disconnected)
relml ❯ /connect sunnyside_v2         # bare name → expanded to dbname=sunnyside_v2
relml ❯ /connect dbname=f1 user=me    # full DSN
relml ❯ /connect ./ratings.csv        # a file or folder
```

If you don't know the database name, run **`/databases`** first — it probes the
local Postgres server (or a DSN you pass, e.g. `/databases "dbname=postgres
host=…"`) and lists what's available, then `/connect <name>`. `/connect` also
**switches** datasources mid-session, and accepts a bare name, a full DSN, or a
file/folder path. On connect, any models previously trained for that source are
**restored automatically** (see [`/models`](#repl-commands)). To restrict a
multi-schema Postgres database to one schema, pass `--pg-schema <name>` at
launch.

> **Prerequisite for Postgres sources:** the Postgres server must be running and
> the DSN must reach it (host/port/user/password as needed) — connection uses
> DuckDB's `postgres` extension, fetched on first use (needs network once).
> CSV/Parquet sources need nothing beyond the files.

### REPL commands

Inside the REPL, type a question in plain English to run the agent, or use a
`/command`. Commands accept **unique-prefix matching** (`/con` → `/connect`)
and Tab-completion (command names, and model ids for `/model`, `/predict`,
`/evaluate`, `/plot`). Aliases: `/?` = `/help`, `/source` = `/connect`,
`/exit` = `/quit`.

| Command | Arguments | Description |
|---|---|---|
| `/help`, `/?` | — | List commands. |
| `/connect`, `/source` | `<dsn\|path>` | Attach (or switch) a datasource — Postgres DSN, CSV/Parquet, or folder. |
| `/databases` | `[dsn]` | List databases on the Postgres server. Works **disconnected** (probes `dbname=postgres`, or a DSN you pass). |
| `/tables` | — | Tables in the connected DB with row counts, primary keys, FK counts, and a kind classification. |
| `/schema` | `[table]` | Columns, types, RelML types, and keys — all tables, or one. |
| `/sql` | `<query>` | Run a **read-only** SQL query (SELECT / WITH / DESCRIBE / EXPLAIN / PRAGMA / SHOW / SUMMARIZE). |
| `/models` | — | Models trained this session (or restored for this DB) with held-out MAE/R² and feature counts. |
| `/model` | `<id>` | One model's full details: task, target, split, hyperparams, features, dropped edges, held-out metrics. |
| `/delete` | `<id>` \| `all` | Delete a model (or all) from disk for this database; also removes any dashboards that referenced it. |
| `/predict` | `<id> [k]` | Predict with a model and show the top-`k` rows (default 20). Materializes a `<task>__pred` table. |
| `/evaluate` | `<id> [where]` | Critique a model: metrics, vs-baseline %, by-segment breakdown, and prioritized suggestions. Optional holdout `WHERE` filter. |
| `/explain` | `<id> [where]` | Why a prediction — LIME feature contributions. Supports a date (`/explain "date = '2026-05-19'"`) or a window (`/explain each`, `/explain model_1 last 20 days`). |
| `/plot` | `[id [where] \| <sql>]` | ASCII chart of actual vs predicted (defaults to the backtest window / best model), or the numeric columns of a SQL query. |
| `/serve` | `[port \| stop]` | Open dashboards **live** in the browser — see below. |
| `/reset` | — | Clear the conversation (trained models are kept). |
| `/clear` | — | Clear the screen. |
| `/quit`, `/exit` | — | Leave (stops the live server if running). |

Trained models are persisted per database under `~/.relml/agent_models`
(override with `RELML_MODEL_DIR`) and **restored automatically** when you
reconnect to the same source, so `/models`, `/predict`, `/explain` and `/plot`
work without retraining.

### Live dashboards (`/serve`)

A **dashboard** is a self-contained HTML page built from a manifest of blocks
(`text`, `metric`, `line`, `bar`, `scatter`, `table`), each backed by read-only
SQL whose results are baked in — so the saved `.html` opens offline. Ask the
agent to build one in natural language:

```text
relml ❯ generate a dashboard for model_0
relml ❯ add a bar chart of total orders by weekday to that dashboard
```

Dashboards are saved per datasource under `~/.relml/dashboards/<datasource>/`
(`<name>.json` is the source of truth; `<name>.html` is rendered from it).
Override the location with `RELML_DASHBOARD_DIR`.

`/serve` makes them **interactive**:

```text
relml ❯ /serve            # starts a local server (default port 8765) and opens the browser
relml ❯ /serve 8790       # pick a different port
relml ❯ /serve stop       # shut the server down
```

Served over http, each page becomes editable — an **"Add to dashboard"** box
(natural language → the agent builds a block), an **×** to remove a block, and
**drag-to-reorder**. The page also has a **dark-mode toggle**, **interactive
chart tooltips/crosshair**, and **CSV export** on table cards. The default port
is `RELML_DASHBOARD_PORT` (8765); the server reuses the REPL's warm agent, so
no second model load.

> **Important:** the Add/Update dock only works from the **http link** that
> `/serve` prints (e.g. `http://127.0.0.1:8765/d/<db>/<name>.html`) — **not**
> from double-clicking the saved `.html` file, which is a read-only report.

### Headless embedding (`relml-serve`)

`relml-serve` is a long-lived sidecar that drives the agent over a framed
JSON-RPC channel on stdio — for embedding RelML in a host application. It is not
interactive; see [`python/guepard/tools/agent/rpc/PROTOCOL.md`](python/guepard/tools/agent/rpc/PROTOCOL.md)
for the wire protocol and method list (including the deterministic, no-LLM
`explain` and `dashboard` methods). The same `--backend / --model / --region /
--max-steps` flags apply.

### Configuration & environment variables

Credentials and overrides are read from the environment or the nearest `.env`
(searched upward from the package). See [`.env.example`](.env.example) for the
annotated list.

| Variable | Default | Effect |
|---|---|---|
| `AWS_BEARER_TOKEN_BEDROCK` | — | Use AWS Bedrock; setting it selects the Bedrock backend. |
| `ANTHROPIC_API_KEY` | — | Use the first-party Anthropic API (when no Bedrock token is set). |
| `RELML_AGENT_BACKEND` | auto | Force `bedrock` or `anthropic`. |
| `RELML_AGENT_MODEL` | per backend | Override the Claude model id. |
| `RELML_AGENT_REGION` / `AWS_REGION` | `us-east-1` | Bedrock region. |
| `RELML_AGENT_SOURCE` | — | Default `--source` if none is passed. |
| `RELML_MODEL_DIR` | `~/.relml/agent_models` | Where trained models are persisted per database. |
| `RELML_DASHBOARD_DIR` | `~/.relml/dashboards` | Where dashboards are saved. |
| `RELML_DASHBOARD_PORT` | `8765` | Default port for `/serve`. |
| `RELML_VERBOSE` | off | Show full subagent reasoning + exploratory tables. |
| `RELML_NO_LOGO` | off | Skip the banner logo image in the REPL. |

### Tests

```bash
python -m guepard.tools.agent.tests        # offline agent test suite (no DB, no network)
```

---

## Documentation

- This README — install, repo layout, the natural-language agent, end-to-end
  library tutorial.
- [The natural-language agent](#the-natural-language-agent) — `relml-agent`
  launch modes and flags, the full REPL command set, live dashboards
  (`/serve`), the `relml-serve` sidecar, and every environment variable.
- [`docs/integration.md`](docs/integration.md) — detailed integration guide:
  TaskSpec, inference schemas for agents, model persistence, LIME.
- [`benchmarks/proposal/README.md`](benchmarks/proposal/README.md) —
  how the RelML-vs-PyG comparison works, what each script does, how the
  CSVs and plots are produced.
- [`benchmarks/f1_podium/README.md`](benchmarks/f1_podium/README.md) —
  applied F1 podium prediction on a live Postgres DB: methodology,
  validation (≈70% hit-rate), feature-ceiling findings, and how to forecast
  an upcoming race.
- [`python/guepard/tools/`](python/guepard/tools/) — developer & agent tools,
  one folder per tool. Includes [`schema_introspect/`](python/guepard/tools/schema_introspect/): point
  it at a Postgres DSN or CSV/Parquet folder and get organized JSON of every
  table (schema, PK/FK, per-column stats, samples) plus a ready-to-use RelML
  `dataset_schema` — as a library, CLI, or FastAPI HTTP API.
- [`docs/proposal.tex`](docs/proposal.tex) — the AWS grant proposal that
  cites the benchmark results in this repo.

### Applied demos on live Postgres databases

Two examples train directly against local Postgres databases (attached
read-only via DuckDB) rather than CSVs — they are not part of CI but are
documented, working references:

- **F1 podium** (`dbname=f1`) — [`benchmarks/f1_podium/`](benchmarks/f1_podium/),
  task defined in [`python/examples/f1_podium.py`](python/examples/f1_podium.py).
- **Sunny Side v2 demand** (`dbname=sunnyside_v2`) —
  [`python/examples/sunnyside_v2_demand.py`](python/examples/sunnyside_v2_demand.py):
  daily order-count forecast with holiday/weather/lag features, residual
  modelling, and a strict 20-day backtest (beats the seasonal baseline by ~56%).

---

## Table of contents

- [1. Build](#1-build)
- [2. Data loading](#2-data-loading)
- [3. Task definition](#3-task-definition)
- [4. Training](#4-training)
- [5. Saving and loading a model](#5-saving-and-loading-a-model)
- [6. Prediction](#6-prediction)
- [7. Explanation (LIME)](#7-explanation-lime)
- [8. Worked example: MovieLens-1M](#8-worked-example-movielens-1m)
- [9. Architecture parameters](#9-architecture-parameters)
- [10. Supported task types](#10-supported-task-types)

---

## 1. Build

See [Installation](#installation) above for the full guide. The short version,
into a virtualenv:

```bash
git clone https://github.com/guepard/relml && cd relml
./install.sh && source .venv/bin/activate
```

That builds `_relml_core` and installs the Python package (which pulls in
`scikit-learn` for LIME). The build copies `_relml_core.*.so` into
`python/guepard/qwery/relml/` automatically.

---

## 2. Data loading

RelML uses DuckDB as the data layer. You load your tables into an in-memory DuckDB connection and pass it to `relml.train()`. DuckDB handles any SQL transformation you need before the data reaches the GNN.

```python
import duckdb

conn = duckdb.connect(":memory:")

conn.execute('CREATE TABLE users   AS SELECT * FROM read_csv_auto("users.csv",   header=true)')
conn.execute('CREATE TABLE movies  AS SELECT * FROM read_csv_auto("movies.csv",  header=true)')
conn.execute('CREATE TABLE ratings AS SELECT * FROM read_csv_auto("ratings.csv", header=true)')
```

Any DuckDB-compatible source works: Parquet, JSON, remote files, or SQL-derived tables. The only requirement is that the connection exposes the tables by name before you call `relml.train()`.

---

## 3. Task definition

A `TaskSpec` describes what to predict and how to split the data. The `sql` field defines the **task table**: the table whose rows will be scored, one prediction per row.

```python
from guepard.qwery.relml import TaskSpec

task = TaskSpec(
    sql             = "SELECT * FROM ratings",
    task_table_name = "ratings",
    target_column   = "rating",
    task_type       = "binary_classification",
    label_transform = {"kind": "threshold", "threshold": 4.0, "inclusive": True},
    split_strategy  = "temporal",
    time_col        = "timestamp",
)
```

### `sql`

Any SQL query. The result is materialized as `task_table_name` before training. This is where you can add lag features, rolling aggregates, or any derived columns:

```python
sql = """
    SELECT
        r.*,
        LAG(rating, 1) OVER (PARTITION BY userId ORDER BY timestamp) AS prev_rating
    FROM ratings r
"""
```

### `task_type`

| Value | Use case |
|---|---|
| `"binary_classification"` | Will a user churn? Will an ad be clicked? |
| `"regression"` | How many orders tomorrow? What price? |
| `"multiclass_classification"` | Home win / Draw / Away win? |

### `label_transform`

| Kind | Effect |
|---|---|
| `{"kind": "threshold", "threshold": 4.0}` | Label = 1 if value >= 4.0 |
| `{"kind": "normalize"}` | Standardize to zero mean, unit variance |
| `{"kind": "buckets", "buckets": [0.5, 1.5]}` | Label = class index (0, 1, 2, ...) |

### `split_strategy`

`"temporal"` sorts rows by `time_col` before splitting 70/15/15. `"random"` uses a deterministic shuffle. Temporal split is strongly recommended whenever your data has a time axis.

### `dataset_schema`

When you know your schema, declare it explicitly. This skips heuristic FK detection, guarantees the GNN sees exactly the graph you intend, and is significantly faster:

```python
task = TaskSpec(
    ...
    dataset_schema = {
        "users":   {"pk": "userId",   "fks": []},
        "movies":  {"pk": "movieId",  "fks": []},
        "ratings": {"pk": "ratingId", "fks": [
            {"column": "userId",  "references": "users"},
            {"column": "movieId", "references": "movies"},
        ]},
    }
)
```

When `dataset_schema` is `None`, RelML infers PKs from column names and FKs from name matching and value coverage (≥ 99%). Explicit schema is recommended for production.

---

## 4. Training

```python
import guepard.qwery.relml as relml

model = relml.train(
    conn       = conn,
    task       = task,
    channels   = 64,
    gnn_layers = 2,
    hidden     = 64,
    dropout    = 0.3,
    lr         = 3e-4,
    epochs     = 30,
    batch_size = 0,
)
```

Training prints a per-epoch table with train loss and validation metrics (AP + AUC for classification, RMSE + MAE + R² for regression), then the best validation and final test metrics.

`batch_size = 0` means full-batch training. For large datasets set it to a positive integer such as `4096`: the encoder and GNN always run on the full graph, but the MLP head loss and backward pass are computed in chunks.

---

## 5. Saving and loading a model

```python
# Save after training
relml.save_model(model, "my_model.bin")

# Load on a subsequent run — skips training entirely
model = relml.load_model(
    conn       = conn,       # same connection, same tables
    task       = task,       # same TaskSpec
    path       = "my_model.bin",
    channels   = 64,         # must match training values exactly
    gnn_layers = 2,
    hidden     = 64,
    dropout    = 0.3,
    lr         = 3e-4,
)
```

`load_model` rebuilds the encoder vocabularies and graph from the data (required to match parameter shapes), then overwrites the random initialisation with the saved weights. Architecture parameters must be identical to those used during `train()`.

---

## 6. Prediction

### Score all rows in the task table

```python
predictions = model.predict_all()
# Returns a list of floats, one per row, in task table insertion order.
# Binary classification : probability in [0, 1].
# Regression            : original-scale value (denormalized automatically).
# Multiclass            : predicted class index as a float.
```

### Entity synthesis

Predict for a hypothetical combination of entities that may not exist as a row:

```python
prob = model.predict_entity({"userId": "42", "movieId": "1193"})
```

The model looks up each entity's GNN embedding, mean-pools them, and runs the MLP head. Useful for recommendation: "would user 42 like movie 1193?"

### Row-based aggregation

Filter and aggregate over existing rows:

```python
spec = TaskSpec(
    ...
    inference_mode    = "row_based",
    inference_filters = [{"column": "userId", "op": "=", "value": "42"}],
    inference_agg     = "mean",
)
result = spec.apply_inference(db, predictions)
print(result.aggregate)   # mean predicted rating for user 42
```

`inference_agg` options: `"mean"`, `"fraction"` (positive rate), `"count"` (predicted positives), `"none"` (per-row).

---

## 7. Explanation (LIME)

RelML includes a LIME explainer that works with any model and any task table. It perturbs features in the task table, runs the full forward pass for each perturbation, and fits a weighted Ridge surrogate to identify which features drove the prediction.

```python
from guepard.qwery.relml.explainer import LIMEExplainer

explainer = LIMEExplainer(
    model           = model,
    task_table      = "ratings",
    target_column   = "rating",
    exclude_columns = ["ratingId", "timestamp"],  # PKs and time columns
    n_samples       = 200,
)

exp = explainer.explain(row_index=500)
print(exp)
exp.save_html("explanation_row500.html")
```

### Explaining multiple rows

```python
explanations = explainer.explain_many([42, 101, 205], num_features=10)
```

### Reliability

The surrogate R² indicates how well the linear approximation captures the model locally:

| R² | Interpretation |
|---|---|
| ≥ 0.7 | Reliable |
| 0.5 – 0.7 | Moderate, interpret with care |
| < 0.5 | Poor fit, highly non-linear region |

LIME only perturbs features in the task table. Signal arriving via GNN message passing from neighbour tables is held fixed across all perturbations. R² will be lower when that graph-aggregated signal dominates the prediction.

---

## 8. Worked example: MovieLens-1M

This section walks through a complete run on the MovieLens-1M dataset so you can see exactly what the data looks like, what RelML does with it, and what output to expect.

### Dataset

Three CSV files, 6040 users, 3706 movies, 1 000 209 ratings.

**`users.csv`** — one row per user

| userId | gender | age | occupation | zip |
|---|---|---|---|---|
| 1 | F | 1 | 10 | 48067 |
| 2 | M | 56 | 16 | 70072 |
| 3 | M | 25 | 15 | 55117 |

**`movies.csv`** — one row per movie

| movieId | title | genres |
|---|---|---|
| 1 | Toy Story (1995) | Animation\|Children's\|Comedy |
| 2 | Jumanji (1995) | Adventure\|Children's\|Fantasy |
| 1193 | One Flew Over the Cuckoo's Nest (1975) | Drama |

**`ratings.csv`** — one row per (user, movie) interaction

| ratingId | userId | movieId | rating | timestamp |
|---|---|---|---|---|
| 1 | 1 | 1193 | 5 | 978300760 |
| 2 | 1 | 661 | 3 | 978302109 |
| 3 | 1 | 914 | 3 | 978301968 |

The task is to predict whether a user will give a movie 4 or 5 stars (positive) vs 1, 2, or 3 stars (negative).

### Relational graph

RelML builds the following entity graph from the declared schema:

```
users ←── [userId] ──── ratings ──── [movieId] ──→ movies
              (6 040 nodes)  (1 000 209 nodes)  (3 706 nodes)
```

Each rating row is a node. Its two FK edges connect it to its user node and its movie node. With 2 GNN layers, information flows:

- Layer 1: rating nodes absorb user demographics and movie genre. User nodes absorb all their rating history. Movie nodes absorb all ratings they received.
- Layer 2: rating nodes absorb the now-enriched user and movie embeddings, giving each rating access to collaborative signals (what else this user rated, what other users rated this movie).

### Step-by-step code

```python
import duckdb
from pathlib import Path
import guepard.qwery.relml as relml
from guepard.qwery.relml import TaskSpec
from guepard.qwery.relml.explainer import LIMEExplainer

DATA_DIR   = Path("data/ml-1m-data")
MODEL_PATH = "ml1m.bin"

SCHEMA = {
    "users":   {"pk": "userId",   "fks": []},
    "movies":  {"pk": "movieId",  "fks": []},
    "ratings": {"pk": "ratingId", "fks": [
        {"column": "userId",  "references": "users"},
        {"column": "movieId", "references": "movies"},
    ]},
}

# ── 1. Load ────────────────────────────────────────────────────────────────
conn = duckdb.connect(":memory:")
for name in ("users", "movies", "ratings"):
    conn.execute(
        f'CREATE TABLE {name} AS '
        f'SELECT * FROM read_csv_auto("{DATA_DIR / name}.csv", header=true)'
    )

# ── 2. Task ────────────────────────────────────────────────────────────────
task = TaskSpec(
    sql             = "SELECT * FROM ratings",
    task_table_name = "ratings",
    target_column   = "rating",
    task_type       = "binary_classification",
    label_transform = {"kind": "threshold", "threshold": 4.0, "inclusive": True},
    split_strategy  = "temporal",
    time_col        = "timestamp",
    dataset_schema  = SCHEMA,
)

# ── 3. Train or load ───────────────────────────────────────────────────────
if Path(MODEL_PATH).exists():
    model = relml.load_model(
        conn=conn, task=task, path=MODEL_PATH,
        channels=64, gnn_layers=2, hidden=64, dropout=0.3, lr=3e-4,
    )
else:
    model = relml.train(
        conn=conn, task=task,
        channels=64, gnn_layers=2, hidden=64, dropout=0.3,
        lr=3e-4, epochs=30,
    )
    relml.save_model(model, MODEL_PATH)

# ── 4. Predict all ─────────────────────────────────────────────────────────
predictions = model.predict_all()
n_pos = sum(p > 0.5 for p in predictions)
print(f"Predicted positive rate: {n_pos / len(predictions):.2%}")
# → Predicted positive rate: 57.43%

# ── 5. Entity synthesis ────────────────────────────────────────────────────
# Does user 1 like One Flew Over the Cuckoo's Nest (movieId=1193)?
prob = model.predict_entity({"userId": "1", "movieId": "1193"})
print(f"P(user 1 likes movie 1193) = {prob:.4f}")
# → P(user 1 likes movie 1193) = 0.8241
# (user 1 actually gave it 5 stars — the model is confident)

# ── 6. Explain ─────────────────────────────────────────────────────────────
explainer = LIMEExplainer(
    model           = model,
    task_table      = "ratings",
    target_column   = "rating",
    exclude_columns = ["ratingId", "timestamp"],
    n_samples       = 200,
)

exp = explainer.explain(row_index=0)  # first rating in the task table
print(exp)
exp.save_html("ml1m_explanation_row0.html")
```

### What training prints

```
  Trainable tensors: 214  total floats: 1 847 296

----------------------------------------------------------------------------------------
 Epoch |  Train Loss  |   Val AP  |  Val AUC  |  Val Acc | Time (s)
----------------------------------------------------------------------------------------
    1  | 0.687412     |   0.6823  |   0.7104  |   0.6531 | 3.21
    2  | 0.661083     |   0.7012  |   0.7298  |   0.6714 | 3.18
   ...
   30  | 0.581244     |   0.7489  |   0.7801  |   0.7102 | 3.24
----------------------------------------------------------------------------------------

Best val:
  loss              : 0.5731
  average_precision : 0.7512
  roc_auc           : 0.7834
  accuracy          : 0.7143
  f1                : 0.7089

Test:
  loss              : 0.5884
  average_precision : 0.7401
  roc_auc           : 0.7712
  accuracy          : 0.7089
  f1                : 0.7031
```

### What an explanation looks like

```
LIMEExplanation
  table      : ratings
  target     : rating
  row        : 0
  prediction : 0.8241
  R²         : 0.871  (reliable)
  n_samples  : 200
  kw         : 2.90

  Feature                          Value              Importance
  -----------------------------------------------------------------
  + genres                         Drama                   +3.1042
  + age                            1                       +1.9231
  + gender                         F                       +0.8812
  - occupation                     10                      -0.7104
```

Row 0 is user 1 rating movie 1193 (Drama). The model is confident (0.82) because Drama is the genre most correlated with high ratings in the training data, and age group 1 (under 18) has a strong positive prior toward this movie in particular. The LIME surrogate fits well (R² = 0.871), meaning the linear approximation captures the model's local behaviour reliably here.

---

## 9. Architecture parameters

| Parameter | Default | Description |
|---|---|---|
| `channels` | `64` | Embedding dimension for all node types |
| `gnn_layers` | `2` | Number of HeteroGraphSAGE message-passing layers |
| `hidden` | `64` | Hidden dimension of the MLP head |
| `dropout` | `0.3` | Dropout rate in the MLP head (0 = disabled) |
| `lr` | `3e-4` | Adam learning rate |
| `epochs` | `30` | Training epochs |
| `batch_size` | `0` | MLP head mini-batch size (0 = full batch) |

**Tuning guidance:**

- Small datasets (< 5k rows): `channels=32`, `hidden=64`, `epochs=200–500`, `dropout=0.2`.
- Large datasets (> 100k rows): `channels=64–128`, `batch_size=4096–16384`, `epochs=20–50`.
- Overfitting (train loss drops, val metric flat): increase `dropout`, reduce `channels`.
- Underfitting (both losses stay high): increase `channels`, `gnn_layers`, or `epochs`.

---

## 10. Supported task types

| Task type | Label transform | Output | Metrics |
|---|---|---|---|
| `binary_classification` | `threshold` | Probability [0, 1] | AP, ROC-AUC, accuracy, F1 |
| `regression` | `normalize` | Original-scale value | RMSE, MAE, R² |
| `multiclass_classification` | `buckets` | Class index | Accuracy, macro-F1 |

For multiclass, `buckets` defines the class boundaries. Three classes require two boundaries:

```python
label_transform = {"kind": "buckets", "buckets": [0.5, 1.5]}
# raw=0 → class 0,  raw=1 → class 1,  raw=2 → class 2
```