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 `source_type`'s `no_self_loop_relationships`
48 /// list declares `rel_type` — the engine then refuses a SELF-LOOP
49 /// (from == to) on that pair, independent of the rel-type's
50 /// `acyclic` flag (which governs longer cycles, a different
51 /// concern). That refusal is the declaration's only effect —
52 /// nothing propagates (the historical "weight-bomb" rationale was
53 /// backed by no weight code anywhere; agent-trust plan 06 renamed
54 /// the field and this predicate to match reality). Unknown
55 /// `source_type` returns `false` (permissive — the type's
56 /// existence is checked elsewhere).
57 pub fn type_refuses_self_loop(&self, source_type: &str, rel_type: &str) -> bool {
58 self.types
59 .get(source_type)
60 .map(|td| td.no_self_loop_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 /// Every cross-mem entry applicable to `target_name`, in priority
166 /// order: the exact-name entry first, then the `to_schema: "*"`
167 /// wildcard entry (loader-bound to this schema's
168 /// `alias_target_rel_type`). Consumers resolve a rel-type by
169 /// first hit across the returned entries, so an exact declaration
170 /// for a destination schema never SHADOWS the wildcard for the
171 /// alias rel-type — a schema carrying structural declarations for
172 /// one destination keeps its wildcarded alias links to that same
173 /// destination. Empty when neither entry exists. This is the ONE
174 /// matcher behind edge validation, the load-path edge filter, and
175 /// the per-edge-description posture lookup — a wildcard honoured
176 /// in one place is honoured in all three.
177 pub fn cross_mem_entries(&self, target_name: &str) -> Vec<&CrossMemRelationshipEntry> {
178 let mut out = Vec::with_capacity(2);
179 if target_name != "*"
180 && let Some(exact) = self
181 .manifest
182 .cross_mem_relationships
183 .iter()
184 .find(|entry| entry.to_schema == target_name)
185 {
186 out.push(exact);
187 }
188 if let Some(wildcard) = self
189 .manifest
190 .cross_mem_relationships
191 .iter()
192 .find(|entry| entry.to_schema == "*")
193 {
194 out.push(wildcard);
195 }
196 out
197 }
198
199 /// Load the embedded `default` builtin schema.
200 ///
201 /// Backed by the embedded YAML bundle under `builtins/schemas/default/`
202 /// that ships with every binary. Cached via `OnceLock` so repeated
203 /// calls are cheap.
204 pub fn builtin_default() -> Arc<Schema> {
205 use std::sync::OnceLock;
206 static CACHE: OnceLock<Arc<Schema>> = OnceLock::new();
207 CACHE
208 .get_or_init(|| {
209 crate::builtins::load_builtin_schemas()
210 .expect("embedded default schema must load")
211 .into_iter()
212 .find(|s| s.manifest.name == "default")
213 .expect("default schema must be embedded")
214 })
215 .clone()
216 }
217}
218
219pub(crate) fn closest_match<'a>(
220 needle: &str,
221 candidates: impl IntoIterator<Item = &'a str>,
222) -> Option<String> {
223 // Noise floor of `chars/2`: beyond that the input shares almost
224 // nothing with the vocabulary, so a "did you mean" suggestion is
225 // noise dressed as a hint (MCP F1 — a confidently-wrong suggestion
226 // misleads). Mirrors `nearest_str_match` in memstead-base verbatim, so
227 // `closest_match`-backed codes (`UNKNOWN_SECTION`, `INVALID_REL_TYPE`)
228 // gate consistently with the already-floored `INVALID_ENUM_VALUE`.
229 // Returns `None` when nothing is close — the caller omits `suggestion`
230 // while still shipping the full declared-list recovery payload.
231 let noise_floor = (needle.chars().count() / 2).max(1);
232 let mut best: Option<(usize, String)> = None;
233 for cand in candidates {
234 let d = strsim::levenshtein(needle, cand);
235 if d == 0 || d > noise_floor {
236 continue;
237 }
238 match &best {
239 Some((bd, _)) if *bd <= d => {}
240 _ => best = Some((d, cand.to_string())),
241 }
242 }
243 best.map(|(_, c)| c)
244}
245
246#[cfg(test)]
247mod closest_match_tests {
248 use super::closest_match;
249
250 /// MCP F1: a token with no close declared candidate yields no
251 /// suggestion (the `chars/2` noise floor rejects it) — a confidently-
252 /// wrong "did you mean" is noise dressed as a hint.
253 #[test]
254 fn far_token_yields_no_suggestion() {
255 let candidates = ["identity", "purpose", "context"];
256 // distance to every candidate (14/17/14) far exceeds the
257 // chars/2 floor (9) — the egregious wrong-suggestion case.
258 assert_eq!(
259 closest_match("nonexistent_section", candidates.into_iter()),
260 None,
261 "a semantically-unrelated token must not get a suggestion",
262 );
263 // A rel-type token sharing nothing with the vocabulary (distance
264 // well past floor) is suppressed too.
265 assert_eq!(
266 closest_match(
267 "TOTALLY_UNRELATED",
268 ["MOTIVATES", "REFERENCES", "PART_OF"].into_iter()
269 ),
270 None,
271 "a far rel-type token must not get a suggestion",
272 );
273 }
274
275 /// MCP F1 complement: a genuine near-typo (within `chars/2`) still
276 /// gets its suggestion.
277 #[test]
278 fn near_typo_still_suggests() {
279 let candidates = ["identity", "purpose", "context"];
280 assert_eq!(
281 closest_match("identty", candidates.into_iter()),
282 Some("identity".to_string()),
283 "a one-edit typo must still suggest the intended candidate",
284 );
285 }
286
287 /// An exact match is not a "did you mean" — `closest_match` is for
288 /// unknown tokens, so a zero-distance hit returns None (matches
289 /// `nearest_str_match`).
290 #[test]
291 fn exact_match_returns_none() {
292 let candidates = ["identity", "purpose"];
293 assert_eq!(closest_match("identity", candidates.into_iter()), None);
294 }
295}