reasoninglayer 1.0.3

Rust client SDK for the Reasoning Layer API
Documentation
//! Homoiconic (untagged) value types used by inference endpoints.
//!
//! These types are structurally discriminated — the backend uses `#[serde(untagged)]` so the same
//! JSON value (e.g. a bare string `"hello"`) is a valid [`FeatureInputValueDto::String`]. Use with:
//! inference (`/api/v1/inference/...`). Do **not** use with term CRUD or query — those use the
//! tagged [`ValueDto`](super::values::ValueDto) format.
//!
//! # Variant resolution order
//!
//! Each struct-shaped variant is wrapped in a newtype struct with
//! `#[serde(deny_unknown_fields)]` so that, for example,
//! `{"name": "?X", "constraint": ...}` can only match
//! [`FeatureInputValueDto::ConstrainedVariable`] — not [`Variable`](FeatureInputValueDto::Variable)
//! (which would be a silent drop of the constraint). Declaration order matters: the most
//! specific shapes come first so untagged deserialization tries them before falling back to
//! looser variants — TermRef → ConstrainedVariable → Variable → InlineTerm → InlineTermByName
//! → List → String → Integer → Real → Boolean → Null.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// A Psi-term in the homoiconic representation, returned by inference endpoints.
///
/// Features use the untagged [`FeatureValueDto`] format. Distinct from
/// [`TermDto`](super::terms::TermDto) which uses the tagged `ValueDto` format.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PsiTermDto {
    /// Term UUID.
    pub term_id: String,
    /// Sort (type) UUID.
    pub sort_id: String,
    /// Sort name (resolved by the server).
    pub sort_name: String,
    /// Named features with untagged values.
    pub features: BTreeMap<String, FeatureValueDto>,
    /// Human-readable display string.
    pub display: String,
}

/// Untagged value type used in inference responses. Produces raw JSON primitives
/// or a term reference object `{"term_id": "..."}`.
///
/// Variant resolution order matters here for untagged deserialization. The most
/// specific shape (term reference object) comes first, so `{"term_id": "..."}`
/// can't accidentally collapse into another variant. After that, list before
/// the scalars, then the primitives in JSON-spec order.
///
/// Ambiguity note: UUID strings and regular strings are both `String` in JSON.
/// The backend distinguishes them by UUID format; if you need to route on whether
/// a returned string is a UUID, parse it with `uuid::Uuid::parse_str`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FeatureValueDto {
    /// A reference to another term by UUID — `{"term_id": "..."}`.
    TermRef(FeatureValueTermRef),
    /// A list of feature values.
    List(Vec<FeatureValueDto>),
    /// A string (may be a UUID reference, a sort name, or a plain string).
    String(String),
    /// An integer value.
    Integer(i64),
    /// A real (floating-point) value.
    Real(f64),
    /// A boolean value.
    Boolean(bool),
    /// JSON null — represents uninstantiated.
    Null,
}

/// Term-reference variant payload for [`FeatureValueDto::TermRef`]. Wrapped in a
/// dedicated struct with `deny_unknown_fields` so the untagged-enum dispatcher
/// can't silently match a non-term-ref object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureValueTermRef {
    /// Term UUID being referenced.
    pub term_id: String,
}

// ─── TermInputDto ─────────────────────────────────────────────────────────────

/// Reference to an existing term by UUID.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TermInputRef {
    /// Term UUID.
    pub term_id: String,
}

/// Inline term definition by sort UUID.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TermInputInline {
    /// Sort UUID.
    pub sort_id: String,
    /// Named features.
    pub features: BTreeMap<String, FeatureInputValueDto>,
}

/// Inline term definition by sort name.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TermInputInlineByName {
    /// Sort name.
    pub sort_name: String,
    /// Named features (optional).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub features: Option<BTreeMap<String, FeatureInputValueDto>>,
}

/// Input type for specifying a term in inference requests.
///
/// Three variants:
/// 1. [`Reference`](TermInputDto::Reference) — by UUID
/// 2. [`Inline`](TermInputDto::Inline) — by sort UUID with features
/// 3. [`InlineByName`](TermInputDto::InlineByName) — by sort name with optional features
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TermInputDto {
    /// Reference an existing term by UUID.
    Reference(TermInputRef),
    /// Define a term inline using a sort UUID.
    Inline(TermInputInline),
    /// Define a term inline using a sort name.
    InlineByName(TermInputInlineByName),
}

impl TermInputDto {
    /// Shorthand: reference a saved term by UUID.
    pub fn reference(term_id: impl Into<String>) -> Self {
        Self::Reference(TermInputRef {
            term_id: term_id.into(),
        })
    }

    /// Shorthand: inline term by sort name with no features.
    pub fn by_name(sort_name: impl Into<String>) -> Self {
        Self::InlineByName(TermInputInlineByName {
            sort_name: sort_name.into(),
            features: None,
        })
    }

    /// Shorthand: inline term by sort name with the given features.
    pub fn by_name_with(
        sort_name: impl Into<String>,
        features: BTreeMap<String, FeatureInputValueDto>,
    ) -> Self {
        Self::InlineByName(TermInputInlineByName {
            sort_name: sort_name.into(),
            features: Some(features),
        })
    }

    /// Shorthand: inline term by sort UUID with features.
    pub fn by_sort_id(
        sort_id: impl Into<String>,
        features: BTreeMap<String, FeatureInputValueDto>,
    ) -> Self {
        Self::Inline(TermInputInline {
            sort_id: sort_id.into(),
            features,
        })
    }
}

// ─── FeatureInputValueDto ─────────────────────────────────────────────────────

/// Reference an existing term by UUID (feature-position).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureInputTermRef {
    /// Term UUID.
    pub term_id: String,
}

/// A variable constrained by a guard term.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureInputConstrainedVariable {
    /// Variable name (conventionally prefixed with `?`, e.g. `"?X"`).
    pub name: String,
    /// Constraint as a full [`TermInputDto`] (typically a guard sort).
    pub constraint: Box<TermInputDto>,
}

/// An unconstrained variable in a feature position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureInputVariable {
    /// Variable name.
    pub name: String,
}

/// An inline term defined by sort UUID in a feature position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureInputInlineTerm {
    /// Sort UUID.
    pub sort_id: String,
    /// Named features.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub features: Option<BTreeMap<String, FeatureInputValueDto>>,
}

/// An inline term defined by sort name in a feature position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureInputInlineTermByName {
    /// Sort name.
    pub sort_name: String,
    /// Named features.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub features: Option<BTreeMap<String, FeatureInputValueDto>>,
}

/// Input value type for features in inference requests.
///
/// See the module docs for the variant resolution order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FeatureInputValueDto {
    /// Reference an existing term by UUID.
    TermRef(FeatureInputTermRef),
    /// A variable with a constraint term.
    ConstrainedVariable(FeatureInputConstrainedVariable),
    /// An unconstrained variable.
    Variable(FeatureInputVariable),
    /// An inline term defined by sort UUID.
    InlineTerm(FeatureInputInlineTerm),
    /// An inline term defined by sort name.
    InlineTermByName(FeatureInputInlineTermByName),
    /// A list of values.
    List(Vec<FeatureInputValueDto>),
    /// A string value.
    String(String),
    /// An integer value.
    Integer(i64),
    /// A real (floating-point) value.
    Real(f64),
    /// A boolean value.
    Boolean(bool),
    /// JSON null — represents an uninstantiated value.
    Null,
}

impl FeatureInputValueDto {
    /// Build a plain string value.
    pub fn string(s: impl Into<String>) -> Self {
        Self::String(s.into())
    }

    /// Build an unconstrained variable (prefix with `?` by convention).
    pub fn variable(name: impl Into<String>) -> Self {
        Self::Variable(FeatureInputVariable { name: name.into() })
    }

    /// Build a constrained variable.
    pub fn constrained(name: impl Into<String>, constraint: TermInputDto) -> Self {
        Self::ConstrainedVariable(FeatureInputConstrainedVariable {
            name: name.into(),
            constraint: Box::new(constraint),
        })
    }

    /// Build a term reference.
    pub fn term_ref(term_id: impl Into<String>) -> Self {
        Self::TermRef(FeatureInputTermRef {
            term_id: term_id.into(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn variable_roundtrip() {
        let v = FeatureInputValueDto::variable("?X");
        let j = serde_json::to_value(&v).unwrap();
        assert_eq!(j, json!({"name": "?X"}));
        let back: FeatureInputValueDto = serde_json::from_value(j).unwrap();
        assert_eq!(back, v);
    }

    #[test]
    fn constrained_variable_routed_correctly() {
        let v = FeatureInputValueDto::constrained("?S", TermInputDto::by_name("guard_constraint"));
        let j = serde_json::to_value(&v).unwrap();
        let back: FeatureInputValueDto = serde_json::from_value(j).unwrap();
        match back {
            FeatureInputValueDto::ConstrainedVariable(cv) => {
                assert_eq!(cv.name, "?S");
            }
            other => panic!("expected ConstrainedVariable, got {other:?}"),
        }
    }

    #[test]
    fn term_ref_roundtrip() {
        let v = FeatureInputValueDto::term_ref("term-uuid");
        let j = serde_json::to_value(&v).unwrap();
        assert_eq!(j, json!({"term_id": "term-uuid"}));
    }

    #[test]
    fn primitive_values_roundtrip() {
        for v in [
            FeatureInputValueDto::String("hello".into()),
            FeatureInputValueDto::Integer(42),
            FeatureInputValueDto::Real(2.5),
            FeatureInputValueDto::Boolean(true),
            FeatureInputValueDto::Null,
        ] {
            let s = serde_json::to_string(&v).unwrap();
            let back: FeatureInputValueDto = serde_json::from_str(&s).unwrap();
            assert_eq!(back, v);
        }
    }

    #[test]
    fn inline_term_by_name_optional_features() {
        let v = TermInputDto::by_name("person");
        let j = serde_json::to_value(&v).unwrap();
        assert_eq!(j, json!({"sort_name": "person"}));

        let mut features = BTreeMap::new();
        features.insert("name".into(), FeatureInputValueDto::String("Alice".into()));
        let v = TermInputDto::by_name_with("person", features);
        let j = serde_json::to_value(&v).unwrap();
        assert_eq!(
            j,
            json!({"sort_name": "person", "features": {"name": "Alice"}})
        );
    }

    #[test]
    fn term_input_reference_roundtrip() {
        let v = TermInputDto::reference("some-uuid");
        let j = serde_json::to_value(&v).unwrap();
        assert_eq!(j, json!({"term_id": "some-uuid"}));
    }
}