Skip to main content

memstead_schema/
schema.rs

1//! In-memory representation of a loaded schema.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::manifest::{
7    Cardinality, CrossMemRelationshipEntry, RelationshipDef, RelationshipMode, SchemaManifest,
8};
9use crate::types::TypeDefinition;
10
11/// A validated, in-memory schema — the product of the loader.
12///
13/// Holds the parsed manifest, the resolved semver version, and the set of
14/// fully-validated type definitions with `edge_weights` precomputed.
15#[derive(Debug)]
16pub struct Schema {
17    pub manifest: SchemaManifest,
18    pub version: semver::Version,
19    pub types: HashMap<String, Arc<TypeDefinition>>,
20}
21
22impl Schema {
23    pub fn get_type(&self, name: &str) -> Option<Arc<TypeDefinition>> {
24        self.types.get(name).cloned()
25    }
26
27    pub fn relationship_known(&self, name: &str) -> bool {
28        self.manifest
29            .relationships
30            .definitions
31            .iter()
32            .any(|d| d.name == name)
33    }
34
35    /// Returns `true` iff `name` is declared with `acyclic: true` in this
36    /// schema. Undeclared names resolve to `false` (permissive): the write
37    /// path already rejects undeclared names in strict mode via
38    /// `relationship_known`, and open mode is explicitly opt-in to cycles.
39    pub fn relationship_acyclic(&self, name: &str) -> bool {
40        self.manifest
41            .relationships
42            .definitions
43            .iter()
44            .any(|d| d.name == name && d.acyclic)
45    }
46
47    /// Returns `true` iff entities of `source_type` propagate `rel_type`
48    /// outward — i.e. the type's `propagating_relationships` list
49    /// declares this rel-type. A propagating-from-source self-loop is a
50    /// weight-bomb (the propagation accumulates into the entity that
51    /// originated it), so the engine
52    /// refuses self-loops on any `(source_type,
53    /// rel_type)` pair where this returns `true`, independent of the
54    /// rel-type's `acyclic` flag (which governs longer cycles, a
55    /// different concern). Unknown `source_type` returns `false`
56    /// (permissive — the type's existence is checked elsewhere).
57    pub fn type_propagates(&self, source_type: &str, rel_type: &str) -> bool {
58        self.types
59            .get(source_type)
60            .map(|td| td.propagating_relationships.iter().any(|r| r == rel_type))
61            .unwrap_or(false)
62    }
63
64    /// Returns the schema-declared manual-authoring posture for a
65    /// rel-type. Unknown names resolve to `Allow` (permissive — the
66    /// validator path already rejects unknown rel-types in strict
67    /// mode). The
68    /// explicit-author boundary (`memstead_relate`, `memstead_create`'s
69    /// `relations:` inline list, `memstead_update`'s `declare_relations`)
70    /// gates on this; the body-link → relation alias machinery does
71    /// NOT — the alias path is the *intended* way schema-emitted
72    /// rel-types (e.g. REFERENCES) appear on entities.
73    pub fn relationship_manual_authoring(&self, name: &str) -> crate::ManualAuthoring {
74        self.manifest
75            .relationships
76            .definitions
77            .iter()
78            .find(|d| d.name == name)
79            .map(|d| d.manual_authoring)
80            .unwrap_or_default()
81    }
82
83    /// `when_to_use` description for a rel-type, returned as the
84    /// recovery hint on `RELATION_MANUAL_AUTHORING_FORBIDDEN`
85    /// envelopes. `None` when the rel-type is unknown or the schema
86    /// author didn't author the field.
87    pub fn relationship_when_to_use(&self, name: &str) -> Option<String> {
88        self.manifest
89            .relationships
90            .definitions
91            .iter()
92            .find(|d| d.name == name)
93            .and_then(|d| d.when_to_use.clone())
94    }
95
96    pub fn mode(&self) -> RelationshipMode {
97        self.manifest.relationships.mode
98    }
99
100    pub fn id(&self) -> (String, semver::Version) {
101        (self.manifest.name.clone(), self.version.clone())
102    }
103
104    pub fn suggest_type(&self, name: &str) -> Option<String> {
105        closest_match(name, self.types.keys().map(String::as_str))
106    }
107
108    /// Look up a relationship definition by name. `None` for unknown
109    /// names; the `_default` sentinel is reachable but never a real
110    /// edge's rel_type.
111    pub fn relationship_def(&self, name: &str) -> Option<&RelationshipDef> {
112        self.manifest
113            .relationships
114            .definitions
115            .iter()
116            .find(|d| d.name == name)
117    }
118
119    /// Cardinality hint declared on `name`. `None` for unknown names or
120    /// for relationships with no declared cardinality (the default —
121    /// shape-free).
122    pub fn relationship_cardinality(&self, name: &str) -> Option<Cardinality> {
123        self.relationship_def(name)
124            .and_then(|d| d.cardinality_per_source)
125    }
126
127    pub fn suggest_relationship(&self, name: &str) -> Option<String> {
128        closest_match(
129            name,
130            self.manifest
131                .relationships
132                .definitions
133                .iter()
134                .map(|d| d.name.as_str()),
135        )
136    }
137
138    /// Schema-level `alias_target_rel_type` pointer — names the rel-type
139    /// that body wiki-links `[[target]]` should auto-emit as
140    /// engine-synthesised relations. `None` means the schema is opt-out
141    /// of alias synthesis (unbacked body wiki-links continue to refuse
142    /// with `WIKILINK_WITHOUT_RELATION`). The loader has already
143    /// validated that the named rel-type is declared.
144    pub fn alias_target_rel_type(&self) -> Option<&str> {
145        self.manifest.alias_target_rel_type.as_deref()
146    }
147
148    /// Look up the cross-mem entry whose `to_schema` matches the
149    /// target schema's *name*. Returns `None` when this schema declares
150    /// no outbound entry for that domain.
151    ///
152    /// Eligibility is name-based: a schema names a domain, and a
153    /// version is one iteration of describing it. The target mem's
154    /// pinned version never participates in the match, so a version
155    /// bump on the target side cannot invalidate the declaration. The
156    /// loader guarantees `to_schema` is a validated bare schema name,
157    /// so plain string equality is exact here.
158    pub fn cross_mem_entry(&self, target_name: &str) -> Option<&CrossMemRelationshipEntry> {
159        self.manifest
160            .cross_mem_relationships
161            .iter()
162            .find(|entry| entry.to_schema == target_name)
163    }
164
165    /// Load the embedded `default` builtin schema.
166    ///
167    /// Backed by the embedded YAML bundle under `builtins/schemas/default/`
168    /// that ships with every binary. Cached via `OnceLock` so repeated
169    /// calls are cheap.
170    pub fn builtin_default() -> Arc<Schema> {
171        use std::sync::OnceLock;
172        static CACHE: OnceLock<Arc<Schema>> = OnceLock::new();
173        CACHE
174            .get_or_init(|| {
175                crate::builtins::load_builtin_schemas()
176                    .expect("embedded default schema must load")
177                    .into_iter()
178                    .find(|s| s.manifest.name == "default")
179                    .expect("default schema must be embedded")
180            })
181            .clone()
182    }
183}
184
185pub(crate) fn closest_match<'a>(
186    needle: &str,
187    candidates: impl IntoIterator<Item = &'a str>,
188) -> Option<String> {
189    // Noise floor of `chars/2`: beyond that the input shares almost
190    // nothing with the vocabulary, so a "did you mean" suggestion is
191    // noise dressed as a hint (MCP F1 — a confidently-wrong suggestion
192    // misleads). Mirrors `nearest_str_match` in memstead-base verbatim, so
193    // `closest_match`-backed codes (`UNKNOWN_SECTION`, `INVALID_REL_TYPE`)
194    // gate consistently with the already-floored `INVALID_ENUM_VALUE`.
195    // Returns `None` when nothing is close — the caller omits `suggestion`
196    // while still shipping the full declared-list recovery payload.
197    let noise_floor = (needle.chars().count() / 2).max(1);
198    let mut best: Option<(usize, String)> = None;
199    for cand in candidates {
200        let d = strsim::levenshtein(needle, cand);
201        if d == 0 || d > noise_floor {
202            continue;
203        }
204        match &best {
205            Some((bd, _)) if *bd <= d => {}
206            _ => best = Some((d, cand.to_string())),
207        }
208    }
209    best.map(|(_, c)| c)
210}
211
212#[cfg(test)]
213mod closest_match_tests {
214    use super::closest_match;
215
216    /// MCP F1: a token with no close declared candidate yields no
217    /// suggestion (the `chars/2` noise floor rejects it) — a confidently-
218    /// wrong "did you mean" is noise dressed as a hint.
219    #[test]
220    fn far_token_yields_no_suggestion() {
221        let candidates = ["identity", "purpose", "context"];
222        // distance to every candidate (14/17/14) far exceeds the
223        // chars/2 floor (9) — the egregious wrong-suggestion case.
224        assert_eq!(
225            closest_match("nonexistent_section", candidates.into_iter()),
226            None,
227            "a semantically-unrelated token must not get a suggestion",
228        );
229        // A rel-type token sharing nothing with the vocabulary (distance
230        // well past floor) is suppressed too.
231        assert_eq!(
232            closest_match(
233                "TOTALLY_UNRELATED",
234                ["MOTIVATES", "REFERENCES", "PART_OF"].into_iter()
235            ),
236            None,
237            "a far rel-type token must not get a suggestion",
238        );
239    }
240
241    /// MCP F1 complement: a genuine near-typo (within `chars/2`) still
242    /// gets its suggestion.
243    #[test]
244    fn near_typo_still_suggests() {
245        let candidates = ["identity", "purpose", "context"];
246        assert_eq!(
247            closest_match("identty", candidates.into_iter()),
248            Some("identity".to_string()),
249            "a one-edit typo must still suggest the intended candidate",
250        );
251    }
252
253    /// An exact match is not a "did you mean" — `closest_match` is for
254    /// unknown tokens, so a zero-distance hit returns None (matches
255    /// `nearest_str_match`).
256    #[test]
257    fn exact_match_returns_none() {
258        let candidates = ["identity", "purpose"];
259        assert_eq!(closest_match("identity", candidates.into_iter()), None);
260    }
261}