Skip to main content
Glama
aqalalwa

ariadnet

by aqalalwa
README.md
# ariadnet

[![PyPI Version](https://img.shields.io/pypi/v/ariadnet.svg)](https://pypi.org/project/ariadnet/)
[![Python Versions](https://img.shields.io/pypi/pyversions/ariadnet.svg)](https://pypi.org/project/ariadnet/)
[![License](https://img.shields.io/github/license/aqalalwa/ariadnet.svg)](https://github.com/aqalalwa/ariadnet/blob/main/LICENSE)
[![CI](https://github.com/aqalalwa/ariadnet/actions/workflows/ci.yml/badge.svg)](https://github.com/aqalalwa/ariadnet/actions/workflows/ci.yml)

**A deterministic solver for AI agents that work with interdependent variables.**

<p align="center">
  <img src="https://raw.githubusercontent.com/aqalalwa/ariadnet/main/docs/demo.svg" alt="ariadnet in a terminal: it lists the inputs still needed to find P, derives every value with its provenance tree, and reports a conflict when the inputs contradict Ohm's law">
</p>

Language models are good at conversation and bad at bookkeeping: long chains of derived
values, exact arithmetic, noticing contradictions, and knowing what is still missing.
ariadnet takes that job away from the model. You describe the variables of a domain and
the relations between them (a *hypergraph*: each relation links any number of variables),
and ariadnet

- **derives** every value that follows from what is known, in any direction (solve
  `V = I * R` for whichever variable is missing), including groups of equations that must
  be solved together;
- **reports conflicts** and constraint violations instead of silently picking a side;
- **plans**: tells the agent the smallest sets of inputs it still needs to ask for;
- **explains** every derived value with its full provenance tree.

The name joins Ariadne, who gave Theseus the thread that led him out of the labyrinth, with
*net*: every value keeps a thread you can follow back to where it came from.

The model keeps the parts it's good at: understanding the user, choosing what to ask,
explaining results. ariadnet can be used three ways, all backed by the same engine:

| Use it as | When |
|---|---|
| A Python library | Your agent (or any program) runs in Python |
| Tool definitions + a dispatcher | You drive a model API directly (Claude, OpenAI, ...) |
| An MCP server | Any MCP client: Claude Code, Claude Desktop, agent frameworks, other languages |

## Quickstart

```bash
pip install ariadnet
```

```python
import ariadnet as an

graph = an.Graph(
    [an.Variable("V", unit="volt"), an.Variable("I", unit="ampere"),
     an.Variable("R", unit="ohm"), an.Variable("P", unit="watt")],
    [an.Equation("ohm", "V = I * R"), an.Equation("power", "P = V * I")],
)

session = graph.session()
print(session.update({"V": 12, "R": 6}).known)   # {'V': 12, 'I': 2, 'R': 6, 'P': 24}
print(session.explain("P"))                      # how P was derived, step by step

conflicted = graph.session().update({"V": 12, "I": 2, "R": 5})
print(conflicted.issues[0].message)              # known values contradict ohm (V = I * R): ...
```

No graph file, API key or model needed. Requires Python 3.10+; the only dependencies are
`sympy` and `pyyaml`. For the MCP server, add the extra: `pip install "ariadnet[mcp]"`.
The terminal session above runs the same graph from a file,
[`examples/electrical.yaml`](examples/electrical.yaml); the next section describes the format.

## Describe a domain

A graph file lists variables and relations. It is data, not code: expressions use a small,
safe language (no `eval`), so graph files can be written by domain experts, reviewed like
configuration, and shared between agents.

```yaml
name: electrical
version: "1.0"
description: DC circuit with a single resistive load.

variables:
  V: {unit: volt, description: Voltage across the load}
  I: {unit: ampere, domain: positive, description: Current through the load}
  R: {unit: ohm, domain: positive, description: Resistance of the load}
  P: {unit: watt, domain: positive, description: Power dissipated by the load}

relations:
  - {id: ohm, kind: equation, expr: "V = I * R"}
  - {id: power, kind: equation, expr: "P = V * I"}
  - {id: fuse, kind: constraint, expr: "I <= 16", message: current exceeds the 16 A fuse}

tests:
  - given: {V: 12, R: 6}
    expect: {I: 2, P: 24}
  - given: {V: 12, I: 2, R: 5}
    expect_issues: [conflict]
```

### Variables

| Field | Meaning |
|---|---|
| `type` | `number` (default), `integer`, `string`, or `boolean` |
| `description`, `unit` | Shown to the model in tool schemas and the system prompt, so write them carefully |
| `domain` | For numbers: `real` (default), `positive`, `nonnegative`, `negative`. It also tells the solver which roots are valid (`x**2 = 9` with `positive` gives 3, not ±3) |
| `min`, `max` | Bounds, checked on every value |
| `choices` | Allowed values. String matching ignores case and normalizes to the listed spelling |
| `guess` | Starting point for numeric root finding when an equation has no closed form |

A bare string is shorthand for a number with that description: `V: Voltage across the load`.

### Relations

| `kind` | Fields | Derives | Example |
|---|---|---|---|
| `equation` | `expr` | Any one of its variables from the others (symbolically via sympy, numerically when there's no closed form) | `V = I * R` |
| `function` | `output`, and `expr` or `fn` (+ `inputs`) | `output` from its inputs (one direction) | `0.15 if seats >= 100 else 0` |
| `table` | `keys`, `rows` | The other columns of the first matching row (`"*"` matches anything) | price list, tax rates |
| `constraint` | `expr`, optional `message` | Nothing; it must hold once its variables are known | `discount <= 0.3` |

Once all of a relation's variables are known, it also serves as a **consistency check**. If equations can't be solved one at a time, the solver tries solving
groups of them jointly (`a + b = 10`, `a - b = 2` gives `a = 6`, `b = 4`).

**Expression language.** Arithmetic (`+ - * / // % **`), comparisons (chainable),
`and`/`or`/`not`, `x if cond else y`, `in` with literal lists, string concatenation, the
constants `pi` and `e`, and the functions `abs sqrt exp log log10 sin cos tan asin acos atan
atan2 floor ceil min max round`. Equations use the numeric subset.

**Python code as a relation.** A `function` relation can call your code, such as a
database lookup or an API call. Reference it by name in the file and pass it in when
loading. If the function raises, the error is reported as an `error` issue and doesn't
crash the engine.

```yaml
  - {id: fx, kind: function, output: rate, fn: lookup_fx_rate, inputs: [currency]}
```

```python
graph = ariadnet.load("pricing.yaml", functions={"lookup_fx_rate": lookup_fx_rate})
```

**Tests travel with the graph.** `tests:` entries (`given`, `expect`, `expect_issues`; an
expected value of `null` means "must stay unknown") run without any model:

```bash
ariadnet check examples/*.yaml
```

The examples folder has three complete domains: [`electrical`](examples/electrical.yaml)
(equations and a joint solve), [`pricing`](examples/pricing.yaml) (tables, tiered rules,
approval constraints) and [`loan`](examples/loan.yaml) (numeric root finding).

## Use it from Python

```python
import ariadnet as an

graph = an.load("examples/electrical.yaml")   # immutable; share it freely
session = graph.session()                      # the values for one task

solution = session.update({"V": 12, "R": 6})
solution["P"]                  # 24
solution.records["I"]          # Value(name='I', value=2, source='derived', via=('ohm',), inputs=('V', 'R'))
solution.issues                # ()  -- conflicts, violations, ambiguities, ...
print(session.explain("P"))    # provenance tree

session.set("R", 3)            # changing an input recomputes everything downstream
session.unset("R")             # retracting one drops what was derived from it
session.missing_for("P")       # [('I',), ('R',)] -- the smallest sets of inputs still needed
```

A session stores only asserted values (each with its `source`: `"user"`, `"document"`,
`"agent"`, ...). Derived values are recomputed whenever something changes, so they can
never go stale. Graphs can also be built in code with `an.Graph`, `an.Variable`,
`an.Equation`, `an.Function`, `an.Table` and `an.Constraint`
(see [`examples/quickstart.py`](examples/quickstart.py)). Subclass `an.Relation` to add a new kind of
relation, and register it for graph files with `an.load(..., kinds={"mykind": factory})`.

## Give it to a model as tools

`Toolkit` turns a graph into tool definitions, a system prompt, and a dispatcher:

```python
from ariadnet.tools import Toolkit

toolkit = Toolkit(an.load("examples/loan.yaml"))
toolkit.definitions()             # Anthropic format; definitions("openai") for Chat Completions
toolkit.system_prompt()           # rules for using the tools + variable glossary + relations
toolkit.call(name, input, session_id=conversation_id)   # -> JSON-serializable dict
```

| Tool | Does |
|---|---|
| `set_values` | Records values (with a source) and returns what is now known, what changed, what is still unknown, and any issues |
| `unset_values` | Retracts values; everything derived from them is recomputed |
| `get_state` | Returns the current state |
| `missing_for` | Returns the smallest sets of variables that would determine a target |
| `explain` | Returns the provenance tree of a value |

The schemas are generated from the graph: variable names become enums, and each variable's
type, unit, domain and description go into the schema. Invalid input never raises. It comes
back as `{"error": "unknown variable 'Vx'; did you mean 'V'?"}` so the model can correct
itself, and you should send it back as an error tool result (`is_error: true` for Claude).

[`examples/claude_agent.py`](examples/claude_agent.py) is a complete conversational agent
on the Claude API, in one short file:

```bash
pip install "anthropic>=1.8"
python examples/claude_agent.py "What would I pay monthly on a 300k loan at 6%?"
```

Sessions live in a `SessionStore` (in memory by default). To share state between processes
or agents, implement its three methods (`load`, `save`, `delete`) over Redis or a database.
`save` receives the revision the session was loaded at, so two agents writing at once get
`ConcurrentModificationError` instead of overwriting each other.

## Serve it over MCP

```bash
ariadnet mcp examples/pricing.yaml                                 # stdio
ariadnet mcp examples/pricing.yaml --transport streamable-http --port 8000
```

Claude Code: `claude mcp add pricing -- ariadnet mcp /path/to/pricing.yaml`.
Claude Desktop and other clients, in their MCP configuration:

```json
{
  "mcpServers": {
    "pricing": { "command": "ariadnet", "args": ["mcp", "/path/to/pricing.yaml"] }
  }
}
```

The server exposes the same five tools plus `describe_graph`. Every tool takes an optional
`session` argument, so one server can keep several conversations apart. The server's
instructions carry the same system prompt the `Toolkit` generates. To embed the server in
your own application, call `ariadnet.mcp_server.create_server(graph, store=...)`.

## Command line

```text
ariadnet check GRAPH...                  validate graph files and run their tests
ariadnet solve GRAPH NAME=VALUE...       print everything that follows (--explain, --missing, --json)
ariadnet tools GRAPH [--format openai]   print tool definitions
ariadnet prompt GRAPH                    print the system prompt
ariadnet mcp GRAPH                       run an MCP server
```

## How it works

1. **Forward propagation.** Repeatedly find a relation where everything but one target is
   known, compute the target, and record which relation and inputs produced it. Equations
   are solved symbolically once per target and cached. Candidate values outside the target's
   type, domain or bounds are dropped. If several valid ones remain, the result is an
   `ambiguous` issue rather than a guess.
2. **Joint solving.** When propagation stalls, groups of equations that share unknowns are
   handed to sympy together, and the values the group fully determines are kept.
3. **Consistency checks.** Every relation whose variables are all known is checked (with a
   tolerance). Failures become `conflict` or `violation` issues that list the values
   involved and where each came from.
4. **Backward planning.** `missing_for` searches the hypergraph from the target back to the
   known values, and returns the minimal sets of inputs that would close the gap.

Limits worth knowing: planning is structural, so an option can still fail at run time (for
example, a table has no matching row). Joint solving is limited to `settings.max_system_size`
equations (default 6). Units are metadata only; there is no unit conversion yet.

## Security

Graph files never execute code: expressions are parsed with `ast` and compiled from a
whitelist, and `fn:` names resolve only to callables you pass in explicitly. A hostile graph
file can still describe equations that are expensive to solve, so treat graph files from
untrusted sources the way you'd treat any untrusted input. See [SECURITY.md](SECURITY.md).

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). In short: `uv sync`, then `uv run pytest`,
`uv run ruff check .`, and `uv run mypy`.

## License

MIT