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    /// The declared acyclicity set containing `name`, if any. The
48    /// loader guarantees a rel-type appears in at most one set, so
49    /// the first hit is the only hit.
50    pub fn acyclic_set_containing(&self, name: &str) -> Option<&[String]> {
51        self.manifest
52            .relationships
53            .acyclic_sets
54            .iter()
55            .find(|set| set.iter().any(|n| n == name))
56            .map(|set| set.as_slice())
57    }
58
59    /// Whether this schema declares exactly this acyclicity set
60    /// (order-sensitive, as declared). Used by the boot sweep to
61    /// scope a set's union subgraph to mems whose schema declares it.
62    pub fn has_acyclic_set(&self, set: &[String]) -> bool {
63        self.manifest
64            .relationships
65            .acyclic_sets
66            .iter()
67            .any(|s| s.as_slice() == set)
68    }
69
70    /// Returns `true` iff `source_type`'s `no_self_loop_relationships`
71    /// list declares `rel_type` — the engine then refuses a SELF-LOOP
72    /// (from == to) on that pair, independent of the rel-type's
73    /// `acyclic` flag (which governs longer cycles, a different
74    /// concern). That refusal is the declaration's only effect —
75    /// nothing propagates (the historical "weight-bomb" rationale was
76    /// backed by no weight code anywhere; agent-trust plan 06 renamed
77    /// the field and this predicate to match reality). Unknown
78    /// `source_type` returns `false` (permissive — the type's
79    /// existence is checked elsewhere).
80    pub fn type_refuses_self_loop(&self, source_type: &str, rel_type: &str) -> bool {
81        self.types
82            .get(source_type)
83            .map(|td| td.no_self_loop_relationships.iter().any(|r| r == rel_type))
84            .unwrap_or(false)
85    }
86
87    /// Returns the schema-declared manual-authoring posture for a
88    /// rel-type. Unknown names resolve to `Allow` (permissive — the
89    /// validator path already rejects unknown rel-types in strict
90    /// mode). The
91    /// explicit-author boundary (`memstead_relate`, `memstead_create`'s
92    /// `relations:` inline list, `memstead_update`'s `declare_relations`)
93    /// gates on this; the body-link → relation alias machinery does
94    /// NOT — the alias path is the *intended* way schema-emitted
95    /// rel-types (e.g. REFERENCES) appear on entities.
96    pub fn relationship_manual_authoring(&self, name: &str) -> crate::ManualAuthoring {
97        self.manifest
98            .relationships
99            .definitions
100            .iter()
101            .find(|d| d.name == name)
102            .map(|d| d.manual_authoring)
103            .unwrap_or_default()
104    }
105
106    /// `when_to_use` description for a rel-type, returned as the
107    /// recovery hint on `RELATION_MANUAL_AUTHORING_FORBIDDEN`
108    /// envelopes. `None` when the rel-type is unknown or the schema
109    /// author didn't author the field.
110    pub fn relationship_when_to_use(&self, name: &str) -> Option<String> {
111        self.manifest
112            .relationships
113            .definitions
114            .iter()
115            .find(|d| d.name == name)
116            .and_then(|d| d.when_to_use.clone())
117    }
118
119    pub fn mode(&self) -> RelationshipMode {
120        self.manifest.relationships.mode
121    }
122
123    pub fn id(&self) -> (String, semver::Version) {
124        (self.manifest.name.clone(), self.version.clone())
125    }
126
127    pub fn suggest_type(&self, name: &str) -> Option<String> {
128        closest_match(name, self.types.keys().map(String::as_str))
129    }
130
131    /// Look up a relationship definition by name. `None` for unknown
132    /// names; the `_default` sentinel is reachable but never a real
133    /// edge's rel_type.
134    pub fn relationship_def(&self, name: &str) -> Option<&RelationshipDef> {
135        self.manifest
136            .relationships
137            .definitions
138            .iter()
139            .find(|d| d.name == name)
140    }
141
142    /// Cardinality hint declared on `name`. `None` for unknown names or
143    /// for relationships with no declared cardinality (the default —
144    /// shape-free).
145    pub fn relationship_cardinality(&self, name: &str) -> Option<Cardinality> {
146        self.relationship_def(name)
147            .and_then(|d| d.cardinality_per_source)
148    }
149
150    pub fn suggest_relationship(&self, name: &str) -> Option<String> {
151        closest_match(
152            name,
153            self.manifest
154                .relationships
155                .definitions
156                .iter()
157                .map(|d| d.name.as_str()),
158        )
159    }
160
161    /// Schema-level `alias_target_rel_type` pointer — names the rel-type
162    /// that body wiki-links `[[target]]` should auto-emit as
163    /// engine-synthesised relations. `None` means the schema is opt-out
164    /// of alias synthesis (unbacked body wiki-links continue to refuse
165    /// with `WIKILINK_WITHOUT_RELATION`). The loader has already
166    /// validated that the named rel-type is declared.
167    pub fn alias_target_rel_type(&self) -> Option<&str> {
168        self.manifest.alias_target_rel_type.as_deref()
169    }
170
171    /// Look up the cross-mem entry whose `to_schema` matches the
172    /// target schema's *name*. Returns `None` when this schema declares
173    /// no outbound entry for that domain.
174    ///
175    /// Eligibility is name-based: a schema names a domain, and a
176    /// version is one iteration of describing it. The target mem's
177    /// pinned version never participates in the match, so a version
178    /// bump on the target side cannot invalidate the declaration. The
179    /// loader guarantees `to_schema` is a validated bare schema name,
180    /// so plain string equality is exact here.
181    pub fn cross_mem_entry(&self, target_name: &str) -> Option<&CrossMemRelationshipEntry> {
182        self.manifest
183            .cross_mem_relationships
184            .iter()
185            .find(|entry| entry.to_schema == target_name)
186    }
187
188    /// Every cross-mem entry applicable to `target_name`, in priority
189    /// order: the exact-name entry first, then the `to_schema: "*"`
190    /// wildcard entry (loader-bound to this schema's
191    /// `alias_target_rel_type`). Consumers resolve a rel-type by
192    /// first hit across the returned entries, so an exact declaration
193    /// for a destination schema never SHADOWS the wildcard for the
194    /// alias rel-type — a schema carrying structural declarations for
195    /// one destination keeps its wildcarded alias links to that same
196    /// destination. Empty when neither entry exists. This is the ONE
197    /// matcher behind edge validation, the load-path edge filter, and
198    /// the per-edge-description posture lookup — a wildcard honoured
199    /// in one place is honoured in all three.
200    pub fn cross_mem_entries(&self, target_name: &str) -> Vec<&CrossMemRelationshipEntry> {
201        let mut out = Vec::with_capacity(2);
202        if target_name != "*"
203            && let Some(exact) = self
204                .manifest
205                .cross_mem_relationships
206                .iter()
207                .find(|entry| entry.to_schema == target_name)
208        {
209            out.push(exact);
210        }
211        if let Some(wildcard) = self
212            .manifest
213            .cross_mem_relationships
214            .iter()
215            .find(|entry| entry.to_schema == "*")
216        {
217            out.push(wildcard);
218        }
219        out
220    }
221
222    /// Load the embedded `default` builtin schema.
223    ///
224    /// Backed by the embedded YAML bundle under `builtins/schemas/default/`
225    /// that ships with every binary. Cached via `OnceLock` so repeated
226    /// calls are cheap.
227    pub fn builtin_default() -> Arc<Schema> {
228        use std::sync::OnceLock;
229        static CACHE: OnceLock<Arc<Schema>> = OnceLock::new();
230        CACHE
231            .get_or_init(|| {
232                crate::builtins::load_builtin_schemas()
233                    .expect("embedded default schema must load")
234                    .into_iter()
235                    .find(|s| s.manifest.name == "default")
236                    .expect("default schema must be embedded")
237            })
238            .clone()
239    }
240}
241
242pub(crate) fn closest_match<'a>(
243    needle: &str,
244    candidates: impl IntoIterator<Item = &'a str>,
245) -> Option<String> {
246    // Noise floor of `chars/2`: beyond that the input shares almost
247    // nothing with the vocabulary, so a "did you mean" suggestion is
248    // noise dressed as a hint (MCP F1 — a confidently-wrong suggestion
249    // misleads). Mirrors `nearest_str_match` in memstead-base verbatim, so
250    // `closest_match`-backed codes (`UNKNOWN_SECTION`, `INVALID_REL_TYPE`)
251    // gate consistently with the already-floored `INVALID_ENUM_VALUE`.
252    // Returns `None` when nothing is close — the caller omits `suggestion`
253    // while still shipping the full declared-list recovery payload.
254    let noise_floor = (needle.chars().count() / 2).max(1);
255    let mut best: Option<(usize, String)> = None;
256    for cand in candidates {
257        let d = strsim::levenshtein(needle, cand);
258        if d == 0 || d > noise_floor {
259            continue;
260        }
261        match &best {
262            Some((bd, _)) if *bd <= d => {}
263            _ => best = Some((d, cand.to_string())),
264        }
265    }
266    best.map(|(_, c)| c)
267}
268
269#[cfg(test)]
270mod closest_match_tests {
271    use super::closest_match;
272
273    /// MCP F1: a token with no close declared candidate yields no
274    /// suggestion (the `chars/2` noise floor rejects it) — a confidently-
275    /// wrong "did you mean" is noise dressed as a hint.
276    #[test]
277    fn far_token_yields_no_suggestion() {
278        let candidates = ["identity", "purpose", "context"];
279        // distance to every candidate (14/17/14) far exceeds the
280        // chars/2 floor (9) — the egregious wrong-suggestion case.
281        assert_eq!(
282            closest_match("nonexistent_section", candidates.into_iter()),
283            None,
284            "a semantically-unrelated token must not get a suggestion",
285        );
286        // A rel-type token sharing nothing with the vocabulary (distance
287        // well past floor) is suppressed too.
288        assert_eq!(
289            closest_match(
290                "TOTALLY_UNRELATED",
291                ["MOTIVATES", "REFERENCES", "PART_OF"].into_iter()
292            ),
293            None,
294            "a far rel-type token must not get a suggestion",
295        );
296    }
297
298    /// MCP F1 complement: a genuine near-typo (within `chars/2`) still
299    /// gets its suggestion.
300    #[test]
301    fn near_typo_still_suggests() {
302        let candidates = ["identity", "purpose", "context"];
303        assert_eq!(
304            closest_match("identty", candidates.into_iter()),
305            Some("identity".to_string()),
306            "a one-edit typo must still suggest the intended candidate",
307        );
308    }
309
310    /// An exact match is not a "did you mean" — `closest_match` is for
311    /// unknown tokens, so a zero-distance hit returns None (matches
312    /// `nearest_str_match`).
313    #[test]
314    fn exact_match_returns_none() {
315        let candidates = ["identity", "purpose"];
316        assert_eq!(closest_match("identity", candidates.into_iter()), None);
317    }
318}