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