Skip to main content

memstead_schema/
types.rs

1//! Schema-as-artifact type format.
2//!
3//! Serde-based, YAML-authorable type definitions.
4
5use indexmap::IndexMap;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Complete type definition — serde/schemars-based, authored in YAML.
10#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct TypeDefinition {
13    pub name: String,
14    pub description: String,
15    pub when_to_use: String,
16    #[serde(default)]
17    pub boundaries: Vec<String>,
18    #[serde(default)]
19    pub examples: Vec<TypeExample>,
20    #[serde(default)]
21    pub system_message: Option<String>,
22    pub sections: Vec<SectionDef>,
23    pub metadata_fields: Vec<MetadataFieldDef>,
24    pub title_weight: f32,
25    pub text_fields: Vec<String>,
26    pub hierarchy_relationship: String,
27    #[serde(default)]
28    pub edge_weight_overrides: IndexMap<String, f32>,
29    pub propagating_relationships: Vec<String>,
30    pub updatable_fields: Vec<String>,
31    pub health_required_fields: Vec<String>,
32    pub staleness_threshold_days: u32,
33    pub write_rules: Vec<String>,
34    /// Outgoing-edge invariants the schema asserts for this type.
35    /// Each entry names a list of relationship names plus a cardinality
36    /// constraint. The engine evaluates these on every `memstead_create` /
37    /// `memstead_update` (post-application of inline `relations:` / patches)
38    /// and surfaces unsatisfied blocks as a single
39    /// `MISSING_REQUIRED_OUTGOING` warning per entity. Tier-2 (warn,
40    /// never block). Empty default — types without `required_outgoing`
41    /// keep current behaviour.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub required_outgoing: Vec<RequiredOutgoing>,
44    // Populated by loader from schema-level defaults merged with
45    // edge_weight_overrides. Skipped during serialization so the on-disk
46    // form round-trips.
47    #[serde(skip)]
48    pub edge_weights: IndexMap<String, f32>,
49}
50
51/// One outgoing-edge requirement block on a type definition. Lists one
52/// or more relationship names and a cardinality constraint they must
53/// jointly satisfy. The schema author groups multiple alternative
54/// relationships into a single block when "any of these" satisfies the
55/// rule (e.g. `[CHOSEN, REJECTED]` together with `at_least_one` would
56/// require at least one outgoing edge across both names — but the
57/// planning schema lists each as its own block instead, so each block
58/// gets its own warning entry).
59#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct RequiredOutgoing {
62    /// Edge names the rule applies to. Loader validates each against
63    /// the schema's declared relationship vocabulary; unknown names
64    /// raise `SchemaLoadError::UndeclaredRelationship`.
65    pub relationships: Vec<String>,
66    pub cardinality: RequiredCardinality,
67}
68
69/// Required-cardinality variants. `AtLeastOne` is the only variant
70/// shipped initially; `ExactlyOne` is the obvious next variant but is
71/// not yet wired.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
73#[serde(rename_all = "snake_case")]
74pub enum RequiredCardinality {
75    AtLeastOne,
76}
77
78impl std::fmt::Display for RequiredCardinality {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(match self {
81            RequiredCardinality::AtLeastOne => "at_least_one",
82        })
83    }
84}
85
86impl RequiredOutgoing {
87    /// Returns `true` iff `outgoing_count` of edges across the block's
88    /// `relationships` list satisfies the declared cardinality.
89    pub fn admits(&self, outgoing_count: usize) -> bool {
90        match self.cardinality {
91            RequiredCardinality::AtLeastOne => outgoing_count >= 1,
92        }
93    }
94}
95
96impl TypeDefinition {
97    /// Look up an edge weight by relationship name. Falls back to `_default`
98    /// when the relationship is unknown; returns `1.0` only if `_default`
99    /// itself is missing (the loader normally guarantees it exists).
100    pub fn edge_weight(&self, rel: &str) -> f32 {
101        if let Some(&w) = self.edge_weights.get(rel) {
102            return w;
103        }
104        if let Some(&w) = self.edge_weights.get("_default") {
105            return w;
106        }
107        1.0
108    }
109
110    pub fn section(&self, key: &str) -> Option<&SectionDef> {
111        self.sections.iter().find(|s| s.key == key)
112    }
113
114    pub fn catch_all_section(&self) -> Option<&SectionDef> {
115        self.sections.iter().find(|s| s.catch_all)
116    }
117
118    pub fn metadata_field(&self, key: &str) -> Option<&MetadataFieldDef> {
119        self.metadata_fields.iter().find(|f| f.key == key)
120    }
121
122    /// Closest declared metadata-field key for a typo, used by the CRUD layer
123    /// to build a "did you mean ..." hint when rejecting an unknown key.
124    pub fn suggest_metadata_field(&self, key: &str) -> Option<String> {
125        crate::schema::closest_match(key, self.metadata_fields.iter().map(|f| f.key.as_str()))
126    }
127
128    /// Closest declared section key for a typo. Used by the CRUD layer to
129    /// build the `UNKNOWN_SECTION` envelope's `suggestion` field on inbound
130    /// create/update writes.
131    pub fn suggest_section(&self, key: &str) -> Option<String> {
132        crate::schema::closest_match(key, self.sections.iter().map(|s| s.key.as_str()))
133    }
134
135    /// Required sections in declaration order.
136    pub fn required_sections(&self) -> impl Iterator<Item = &SectionDef> {
137        self.sections.iter().filter(|s| s.required)
138    }
139
140    /// Optional sections in declaration order.
141    pub fn optional_sections(&self) -> impl Iterator<Item = &SectionDef> {
142        self.sections.iter().filter(|s| !s.required)
143    }
144
145    /// System message as a string — empty if unset.
146    pub fn system_message_str(&self) -> &str {
147        self.system_message.as_deref().unwrap_or("")
148    }
149}
150
151/// Inline few-shot example — concrete entity content matching this type.
152#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct TypeExample {
155    pub title: String,
156    pub sections: IndexMap<String, String>,
157}
158
159/// A section within an entity (e.g. "Claim", "Evidence").
160#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct SectionDef {
163    pub key: String,
164    pub heading: String,
165    pub required: bool,
166    pub search_weight: f32,
167    #[serde(default)]
168    pub catch_all: bool,
169    #[serde(default)]
170    pub write_rules: Vec<String>,
171    #[serde(default)]
172    pub description: Option<String>,
173}
174
175/// A metadata (frontmatter) field.
176#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
177#[serde(deny_unknown_fields)]
178pub struct MetadataFieldDef {
179    pub key: String,
180    pub description: String,
181    pub field_type: FieldType,
182    #[serde(default)]
183    pub default_value: Option<String>,
184    #[serde(default)]
185    pub enum_values: Option<Vec<String>>,
186    #[serde(default)]
187    pub optional: bool,
188    #[serde(default)]
189    pub init_timestamp: bool,
190    #[serde(default)]
191    pub auto_timestamp: bool,
192    #[serde(default)]
193    pub serialization: Serialization,
194    #[serde(default)]
195    pub filterable: Filterable,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
199#[serde(rename_all = "snake_case")]
200pub enum FieldType {
201    String,
202    Number,
203    Date,
204    Boolean,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
208#[serde(rename_all = "snake_case")]
209pub enum Serialization {
210    #[default]
211    Default,
212    CsvArray,
213    OmitWhenFalsy,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
217#[serde(rename_all = "snake_case")]
218pub enum Filterable {
219    #[default]
220    None,
221    Equality,
222    Range,
223}
224
225impl Filterable {
226    /// Agent-facing wire token for this posture, or `None` when the field
227    /// is not filterable. Single source of truth for the string both MCP
228    /// schema projections (`memstead_schema`) emit so an agent reads a field's
229    /// `filters` / `range_filters` eligibility straight from the schema
230    /// body instead of trial-and-error against filter warnings.
231    pub fn as_wire_str(self) -> Option<&'static str> {
232        match self {
233            Filterable::None => None,
234            Filterable::Equality => Some("equality"),
235            Filterable::Range => Some("range"),
236        }
237    }
238}