reasoninglayer 0.1.1

Rust client SDK for the Reasoning Layer API
Documentation

reasoninglayer

Rust client SDK for the Reasoning Layer 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

[dependencies]
reasoninglayer = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

TLS backend: rustls by default. Switch with features:

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 idnaicu_* 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

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

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:

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:

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

cargo test              # unit + integration (wiremock) + doc tests
cargo doc --open        # local API docs
cargo clippy -- -D warnings

Releasing

Releases are driven by 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.

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.