# reasoninglayer
Rust client SDK for the [Reasoning Layer](https://github.com/kortexya/reasoninglayer) API — a direct
port of the TypeScript SDK (`@kortexya/reasoninglayer`). Same HTTP contract, same auth headers,
same retry/backoff behaviour, same resource surface; only the method names use `snake_case` and
`?` is available for error propagation.
## Status
**Phase 1 + Phase 2 complete.** Every resource surfaced by the backend has an accessor on
[`ReasoningLayerClient`] with the correct HTTP verb and path. Request/response DTOs are fully typed
for the core resources (sorts, terms, inference, query) and the twenty Phase 2a resources
(health, admin, osfql, context, rl_training, rag, generation, image_extraction, extract, preferences,
discovery, functions, ontology, analysis, scenarios, spaces, row, sources, ui, optimize). The
remaining ~25 Phase 2 resources (fuzzy, causal, statistical, namespaces, collections, reviews,
ingestion, cognitive, oversight, etc.) currently expose `serde_json::Value` on both sides; typed
DTO upgrades land in patch releases without breaking the wire contract.
WebSocket (auto-reconnect, exponential backoff, 10 attempts, 30 s cap) and SSE streaming are built
in. See [`reasoninglayer::ws`] and [`reasoninglayer::sse`].
## Install
```toml
[dependencies]
reasoninglayer = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
TLS backend: `rustls` by default. Switch with features:
```toml
reasoninglayer = { version = "0.1", default-features = false, features = ["native-tls"] }
```
### Rust toolchain
This crate **tracks current stable Rust** and does not maintain a fixed MSRV. The Rust async ecosystem (`tokio`, `reqwest`, the `idna` → `icu_*` chain) bumps minimum Rust versions in patch releases, so a fixed MSRV would either be a claim we can't keep, or would force us to lag dep upgrades and miss bug/security fixes. CI runs against the latest stable Rust on every push. If you have a non-current toolchain constraint, open an issue.
## Quick start
```rust
use reasoninglayer::{
psi, var, guard, constrained, GuardOp,
AddRuleRequest, BackwardChainRequest, ClientConfig, CreateSortRequest,
ReasoningLayerClient,
};
#[tokio::main]
async fn main() -> Result<(), reasoninglayer::Error> {
let client = ReasoningLayerClient::new(
ClientConfig::new("http://localhost:8083", "your-tenant-uuid"),
)?;
// Sort hierarchy.
let person = client.sorts().create_sort(
CreateSortRequest::with_name("person"),
None,
).await?;
let mut employee_req = CreateSortRequest::with_name("employee");
employee_req.parents = vec![person.id.clone()];
let _employee = client.sorts().create_sort(employee_req, None).await?;
// Rule: well_paid(X) :- employee(name = X, salary = S), S > 80_000.
client.inference().add_rule(
AddRuleRequest {
term: psi("well_paid", [("person", var("?X"))]),
antecedents: vec![psi("employee", [
("name", var("?X")),
("salary", constrained("?S", guard(GuardOp::Gt, 80_000_i64))),
])],
certainty: None,
},
None,
).await?;
// Query: who is well paid?
let req = BackwardChainRequest {
goal: Some(psi("well_paid", [("person", var("?Who"))])),
max_solutions: Some(10),
..Default::default()
};
let result = client.inference().backward_chain(req, None).await?;
for solution in result.solutions {
for binding in solution.substitution.bindings {
println!("{} = {}", binding.variable_name.as_deref().unwrap_or("?"), binding.bound_to_display);
}
}
Ok(())
}
```
## Configuration
| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `base_url` | `String` | — | Required. Trailing `/` stripped. |
| `tenant_id` | `String` | — | Required. Sent as `X-Tenant-Id`; not overridable per call. |
| `user_id` | `Option<String>` | `None` | `X-User-Id`; overridable per call. |
| `namespace_id` | `Option<String>` | `None` | `X-Namespace-Id`; overridable per call. |
| `authenticated_user` | `Option<String>` | `None` | `X-Authenticated-User`. |
| `bearer_token` | `Option<String>` | `None` | `Authorization: Bearer …`. |
| `timeout` | `Duration` | 30 s | Per-call override via `RequestOptions`. |
| `max_retries` | `u32` | 3 | Per-call override via `RequestOptions`. |
| `retry_on_503` | `bool` | `false` | Retry on Service Unavailable. |
Every request also sends `X-SDK-Version` and `X-SDK-Language: rust` so the backend can
distinguish Rust clients from TypeScript ones.
### Per-call options
```rust
use std::time::Duration;
use reasoninglayer::RequestOptions;
let opts = RequestOptions::new()
.with_user_id("admin-user")
.with_timeout(Duration::from_secs(60))
.with_retries(0);
client.sorts().list_sorts(Some(&opts)).await?;
```
Cancellation: drop the future (for example with `tokio::select!`) to abort the request in flight.
## Two serialization formats
The Reasoning Layer API uses two serialization formats depending on the endpoint:
| Endpoint family | Format | Types |
| --- | --- | --- |
| Term CRUD, query, fuzzy | **Tagged** `ValueDto` | `{"type": "String", "value": "hello"}` |
| Inference (rules, facts, chaining) | **Untagged** `TermInputDto` | Raw `"hello"` / `42` / `null` / `{name: "?X"}` |
Use the right builder for the endpoint:
- Term CRUD: build `BTreeMap<String, ValueDto>` via `Value::string(...)`, `Value::integer(...)`,
or `.into()` conversions from plain Rust values.
- Inference: use `psi(sort_name, [(feature, value), ...])`, `var("?X")`, `constrained("?X", guard(...))`,
`term_ref(uuid)`.
## Errors
All fallible operations return `Result<T, reasoninglayer::Error>`. HTTP errors are `Error::Api`
wrapping an `ApiError` with a `kind` discriminant:
```rust
use reasoninglayer::{Error, ApiErrorKind};
match client.terms().create_term(request, None).await {
Ok(resp) => println!("created {}", resp.term.id),
Err(Error::Api(e)) if e.kind == ApiErrorKind::ConstraintViolation => {
if let Some(details) = e.constraint_violation() {
eprintln!("feature {:?} failed constraint {:?}",
details.feature, details.constraint);
}
}
Err(Error::Api(e)) if e.kind == ApiErrorKind::RateLimit => {
if let Some(rl) = e.rate_limit() {
eprintln!("rate limited, retry after {:?}s", rl.retry_after);
}
}
Err(Error::Timeout { timeout }) => eprintln!("timed out after {timeout:?}"),
Err(e) => eprintln!("other: {e}"),
}
```
Automatic retry: HTTP 429 is retried with exponential backoff (honouring `Retry-After`).
503 is retried only when `retry_on_503` is set. Transient network errors retry up to `max_retries`
times.
## Resource surface
Core (fully typed DTOs):
| Accessor | Alias | Highlights |
| --- | --- | --- |
| `client.sorts()` | `client.types()` | 28 methods — CRUD, GLB/LUB, hierarchy queries, similarity learning |
| `client.terms()` | `client.records()` | 9 methods — CRUD, bulk, exists, clear |
| `client.inference()` | `client.rules()` | 20 methods — rules/facts, backward/forward/tagged, fuzzy, Bayesian, NAF, goals |
| `client.query()` | — | `find_unifiable`, `find_by_sort`, `osf_search`, `validate_term`, `validated_unify`, `nl_query` |
Phase 2a (fully typed DTOs): `health`, `admin`, `osfql`, `context`, `rl_training`, `rag`,
`generation`, `image_extraction`, `extract`, `preferences`, `discovery`, `functions`, `ontology`,
`analysis`, `scenarios`, `spaces`, `row`, `sources`, `ui`, `optimize`.
Phase 2b/c (typed DTOs landing in patch releases — `serde_json::Value` today):
`fuzzy`, `constraints`, `namespaces`, `collections`, `causal`, `statistical`, `communities`,
`visualization`, `reasoning`, `control`, `reviews`, `action_reviews`, `webhook_actions`, `ilp`,
`cdl`, `synthetic`, `utilities`, `ingestion`, `execution`, `proof_engine`, `conversation`,
`research`, `cognitive` / `agents`, `oversight`, `neuro_symbolic`.
Streams:
```rust
// WebSocket (auto-reconnect):
use reasoninglayer::ws::WebSocketConnection;
let url = client.cognitive().agent_events_url("agent-uuid")?;
let mut ws = WebSocketConnection::connect(&url).await?;
while let Some(event) = ws.next_message().await? { /* ... */ }
// SSE:
use futures_util::StreamExt;
use reasoninglayer::sse::stream;
let body = serde_json::json!({"max_iterations": 10});
let mut events = Box::pin(
stream(&client, reqwest::Method::POST,
"/inference/forward-chain/stream", Some(&body), None).await?
);
while let Some(event) = events.next().await { /* ... */ }
```
## Development
```bash
cargo test # unit + integration (wiremock) + doc tests
cargo doc --open # local API docs
cargo clippy -- -D warnings
```
### Releasing
Releases are driven by [`cargo-release`](https://github.com/crate-ci/cargo-release) per the configuration in `release.toml`. The actual publish to crates.io is done by GitLab CI on tag push, not from your laptop, so you never need a local crates.io token.
```bash
cargo install cargo-release # one-time
cargo release patch # dry-run: shows what would change
cargo release patch --execute # actually bump, tag, push
```
Substitute `minor`, `major`, or an explicit `X.Y.Z` for `patch` as needed. The script bumps `Cargo.toml`, refreshes `Cargo.lock`, rewrites `CHANGELOG.md` (moves `[Unreleased]` content under a dated `[X.Y.Z]` heading and inserts a fresh empty `[Unreleased]`), runs `cargo test --all-features` and `cargo publish --dry-run`, then commits, tags `vX.Y.Z`, and pushes. Pushing the tag triggers the GitLab `publish` stage which runs the actual `cargo publish` against crates.io using the masked `CARGO_REGISTRY_TOKEN` CI/CD variable.
## License
MIT.