eidos_kernel/schema.rs
1//! Graph schema — nodes, edges, kinds. Defines the Aun graph-artifact contract.
2//!
3//! The Rust engine emits NATIVE kinds (the 9 base kinds) from day one; legacy kind
4//! strings are mapped on ingest. Unknown kinds degrade to `Unknown` rather than crash.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// The 9 native base kinds, plus a tolerant `Unknown` sink.
10#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
11#[serde(rename_all = "lowercase")]
12pub enum Kind {
13 Doc,
14 Skill,
15 Agent,
16 /// A heading-delimited block within a Doc — its own groundable unit with a `file:line` span,
17 /// `contains`-edged from its parent Doc. Lets grounding return the right *section* of a long
18 /// doc, not the whole file. (Sprint 3.5.)
19 Section,
20 // Glyph (code) kinds — symbols extracted from source, alongside the doc kinds above.
21 Function,
22 Type,
23 Trait,
24 Module,
25 /// Any unrecognized kind string deserializes here instead of failing the parse.
26 #[serde(other)]
27 Unknown,
28}
29
30impl Kind {
31 /// Map a raw kind string — including legacy names — to a native kind.
32 /// `specialist -> agent`, `workflow|archetype -> doc` (Sprint 7: Flow collapsed to Doc);
33 /// unknown -> `Unknown`.
34 pub fn normalize(raw: &str) -> Kind {
35 let raw = raw.trim().to_ascii_lowercase();
36 match raw.as_str() {
37 "specialist" => Kind::Agent,
38 "workflow" | "archetype" => Kind::Doc,
39 // Collapsed kinds (Sprint 6): these legacy domain types fold into Doc, their
40 // distinction preserved in `subkind`. "check" has no successor → Unknown.
41 "idea" | "mcp" | "contract" | "memory" | "claim" => Kind::Doc,
42 other => serde_json::from_value(serde_json::Value::String(other.to_string()))
43 .unwrap_or(Kind::Unknown),
44 }
45 }
46
47 /// Resolve a frontmatter `type`/`kind` string into a native kind, consulting an optional
48 /// project-profile vocabulary first. Resolution order: a case-insensitive hit in `overrides`
49 /// (whose mapped value is then parsed by [`Kind::normalize`]) wins; otherwise fall back to
50 /// [`Kind::normalize`] directly. With `overrides == None` this is byte-for-byte `normalize`.
51 pub fn resolve(
52 raw: &str,
53 overrides: Option<&std::collections::BTreeMap<String, String>>,
54 ) -> Kind {
55 if let Some(map) = overrides {
56 let key = raw.trim().to_ascii_lowercase();
57 if let Some(mapped) = map.get(&key) {
58 return Kind::normalize(mapped);
59 }
60 }
61 Kind::normalize(raw)
62 }
63
64 /// True if this raw string is a legacy alias (caller emits a `legacy_kind_renamed` note).
65 pub fn is_legacy(raw: &str) -> bool {
66 matches!(
67 raw.trim().to_ascii_lowercase().as_str(),
68 "specialist"
69 | "workflow"
70 | "archetype"
71 | "idea"
72 | "mcp"
73 | "contract"
74 | "memory"
75 | "check"
76 )
77 }
78}
79
80/// A code node's location: the byte/line span it occupies in a source file. Doc nodes (whole-file
81/// units) carry `None`; Glyph symbol nodes carry `Some` so `read_doc` can return just the symbol
82/// body — every code node traceable to an exact `file:line` provenance span.
83#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
84pub struct Span {
85 pub path: String,
86 pub start_line: usize,
87 pub end_line: usize,
88}
89
90/// A graph node. Optional fields default so partial/external JSON never panics.
91#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
92pub struct Node {
93 pub id: String,
94 pub kind: Kind,
95 /// Generated provenance: the EKF partition that produced this node. Authored files do not set
96 /// this; the source registry stamps it from the package manifest.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub partition: Option<String>,
99 /// Open domain refinement of `kind` — the frontmatter `type`/`kind` string (lowercased), e.g.
100 /// "skill", "agent", "mcp", or a user's own ("forecast"). `None` for a plain doc / code symbol.
101 /// Drives domain filtering (subkind scope) + display; never structural. See the type-system spec.
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub subkind: Option<String>,
104 pub title: String,
105 #[serde(default)]
106 pub summary: String,
107 /// NAMES — strong BM25F identity boost. These are alternative identifiers (synonyms,
108 /// acronyms, common misspellings). They feed exact/partial alias matching (+70/+25) and
109 /// the identity anchor (confidence-carrying). Do NOT put categories here — use `tags`.
110 #[serde(default)]
111 pub aliases: Vec<String>,
112 /// CATEGORIES — weak boost. These are topical tags (e.g. "baking", "rust", "devops").
113 /// They contribute to the haystack (term coverage for matching) but NOT to identity
114 /// anchor or alias exact/partial matching. A tag match is [weak], not [strong].
115 #[serde(default)]
116 pub tags: Vec<String>,
117 /// Query-side phrases that should route here: author-declared examples plus generated Aun
118 /// body/heading search surfaces. Their vocabulary enriches retrieval matching (the
119 /// deterministic synonym/intent bridge) but they are NOT identifiers — they never feed alias
120 /// exact/partial matching or the mentions index.
121 #[serde(default)]
122 pub query_examples: Vec<String>,
123 #[serde(default)]
124 pub source_files: Vec<String>,
125 /// Glyph only: the source span this node occupies. `None` for doc nodes (whole-file units).
126 #[serde(default)]
127 pub span: Option<Span>,
128}
129
130/// The structural edge-relation vocabulary, defined ONCE so the producer (compile) and the
131/// consumers (retrieval) can't drift on a typo'd string literal. (Typed frontmatter relations —
132/// `depends_on`, `produces`, … — live in `compile::REL_FIELDS`.)
133pub mod relation {
134 /// A `SKILL.md` owns the docs under its directory (structural ownership).
135 pub const HAS_KNOWLEDGE: &str = "has_knowledge";
136 /// An explicit `[[wikilink]]` the author wrote.
137 pub const LINKS_TO: &str = "links_to";
138 /// An unlinked lexical mention — the candidate/noisy class (not traversed in expansion).
139 pub const MENTIONS: &str = "mentions";
140
141 // Glyph (code) relations. Structural ones are CERTAIN from syntax (trusted, walked in
142 // expansion); `references` is the lexical call/use signal — the code analogue of `mentions`
143 // (untrusted, not walked). The trusted/untrusted split is enforced by the read side.
144 /// A container holds a declared item: module→item, type→method (from syntax).
145 pub const CONTAINS: &str = "contains";
146 /// A type implements a trait (`impl Trait for Type`) — from syntax.
147 pub const IMPLEMENTS: &str = "implements";
148 /// A symbol's body lexically names another known symbol — the candidate/noisy class.
149 pub const REFERENCES: &str = "references";
150}
151
152/// Evidence marker for a reference edge that exists PURELY as a call-graph relationship — a
153/// `self.attr.method()` call resolved by inferred receiver type. Such edges power `callers`/`impact`
154/// traversal but are excluded from the BM25F grounding relation-surface: a callee's "used by X" is
155/// call structure, not "aboutness", and including it would let a caller bleed into the callee's
156/// grounding (and vice-versa). Single source of truth so the extractor and the scorer agree.
157pub const TRAVERSAL_ONLY_EVIDENCE: &str = "receiver-typed method call (self.attr inferred type)";
158
159/// The provenance basis of an edge — where the evidence comes from. This is the synaptic
160/// strength signal: `Resolved` edges (from syntax/authoring) are trusted and strong;
161/// `Lexical` edges (from co-occurrence heuristics) are candidates, weak until verified.
162///
163/// The activation ranker uses this as the base weight: Resolved=1.0, Lexical=0.4.
164#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
165#[serde(rename_all = "snake_case")]
166pub enum EdgeBasis {
167 /// From syntax or explicit authoring (wikilinks, `impl`, `contains`, frontmatter relations).
168 /// Trusted — walked during context expansion. Synaptic weight: 1.0. Must be claimed
169 /// explicitly at the edge builder; it is NEVER the default (trust is earned, not inherited).
170 Resolved,
171 /// From lexical co-occurrence heuristics (`mentions`, `references`). Untrusted — not walked
172 /// in expansion, but available as a candidate signal. Synaptic weight: 0.4. This is the
173 /// DEFAULT: an edge builder that forgets to set basis gets the safe, untrusted band.
174 #[default]
175 Lexical,
176}
177
178/// A directed, evidence-bearing edge — a synapse in the knowledge graph.
179///
180/// ## Bi-temporal fields
181///
182/// `asserted_at` and `expires_at` are the temporal axis (the Graphiti idea, deterministic).
183/// Human-authored edges keep `expires_at = None` (stable forever). Sigil-accepted files can receive
184/// a half-life from EKF `trust.agent_accepted_ttl_days`: `expires_at = asserted_at + TTL`. The edge
185/// is not deleted — when expired, Engine policy can de-rank it and flag it for re-verification.
186///
187/// The runtime properties (`use_count`, `last_used`) are NOT stored here — they live in the
188/// Ryn metadata overlay (SQLite), keyed by edge identity. The graph stays deterministic; the
189/// overlay breathes.
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
191pub struct Edge {
192 pub from: String,
193 pub to: String,
194 pub relation: String,
195 /// Backward-compatible compact evidence summary. When several sources assert the same edge,
196 /// summaries are merged instead of dropping later evidence.
197 pub evidence: String,
198
199 /// Structured receipts for why this edge exists. The graph keeps the compact `evidence` string
200 /// for old consumers, while agents that need auditability can inspect these receipt records.
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub evidence_refs: Vec<EdgeEvidence>,
203
204 /// Provenance basis — the synaptic strength class. Defaults to `Resolved` (backward-compatible).
205 #[serde(default, skip_serializing_if = "is_default_basis")]
206 pub basis: EdgeBasis,
207
208 /// When this edge was asserted (compile time, unix seconds). 0 = unknown/stable.
209 /// Deterministic: the same source files always produce the same `asserted_at` per build.
210 #[serde(default, skip_serializing_if = "is_zero_u64")]
211 pub asserted_at: u64,
212
213 /// When this edge should be re-verified. `None` = stable (human-authored, no decay).
214 /// `Some(t)` = agent-accepted, decays at time `t`. The immune system trigger.
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub expires_at: Option<u64>,
217}
218
219#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
220pub struct EdgeEvidence {
221 /// Human-readable reason, e.g. `frontmatter`, `wikilink`, or `resolved reference`.
222 pub reason: String,
223 /// Source file that supports the assertion, when known.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub source: Option<String>,
226 /// Exact node/source span that supports the assertion, when known.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub span: Option<Span>,
229}
230
231/// Retrieval/traversal semantics for a relation name.
232///
233/// Edges keep their simple `relation: String`; this optional graph-level profile gives retrieval
234/// the contract for how a relation should be searched and walked without hardcoding every domain
235/// relation in the kernel.
236#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
237pub struct RelationProfile {
238 /// Phrase indexed on the source node, e.g. `produces`.
239 pub forward_phrase: String,
240 /// Phrase indexed on the target node, e.g. `produced by`.
241 pub reverse_phrase: String,
242 /// Relative priority when building focused context packs.
243 pub context_score: i64,
244 /// Whether this relation contributes relation text to BM25F.
245 pub searchable: bool,
246 /// Whether focus expansion may walk this relation when the edge basis is trusted.
247 pub traversable: bool,
248 /// Optional allowed source node kinds for this relation, using lowercase kind labels.
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub domain_kinds: Vec<String>,
251 /// Optional allowed target node kinds for this relation, using lowercase kind labels.
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub range_kinds: Vec<String>,
254}
255
256impl RelationProfile {
257 pub fn new(
258 forward_phrase: impl Into<String>,
259 reverse_phrase: impl Into<String>,
260 context_score: i64,
261 ) -> Self {
262 Self {
263 forward_phrase: forward_phrase.into(),
264 reverse_phrase: reverse_phrase.into(),
265 context_score,
266 searchable: true,
267 traversable: true,
268 domain_kinds: Vec::new(),
269 range_kinds: Vec::new(),
270 }
271 }
272}
273
274/// Built-in relation profiles for Eidos' native graph vocabulary.
275///
276/// Domain-specific EKF/package relations are layered on top by the engine; these core profiles keep
277/// native relation semantics visible and consistent for retrieval, focus, graph export, and MCP tools.
278pub fn core_relation_profiles() -> BTreeMap<String, RelationProfile> {
279 let mut profiles = BTreeMap::new();
280 for relation in [
281 relation::CONTAINS,
282 relation::HAS_KNOWLEDGE,
283 relation::LINKS_TO,
284 relation::MENTIONS,
285 relation::REFERENCES,
286 relation::IMPLEMENTS,
287 ] {
288 if let Some(profile) = core_relation_profile(relation) {
289 profiles.insert(relation.to_string(), profile);
290 }
291 }
292 profiles
293}
294
295/// Return the built-in profile for one native relation.
296pub fn core_relation_profile(relation: &str) -> Option<RelationProfile> {
297 match relation {
298 relation::CONTAINS => Some(relation_profile(
299 "contains",
300 "contained by",
301 14,
302 false,
303 true,
304 )),
305 relation::HAS_KNOWLEDGE => Some(relation_profile(
306 "has knowledge",
307 "knowledge of",
308 10,
309 false,
310 true,
311 )),
312 relation::LINKS_TO => Some(relation_profile("links to", "linked from", 22, true, true)),
313 relation::MENTIONS => Some(relation_profile(
314 "mentions",
315 "mentioned by",
316 4,
317 false,
318 false,
319 )),
320 relation::REFERENCES => Some(relation_profile(
321 "references",
322 "referenced by",
323 30,
324 true,
325 true,
326 )),
327 relation::IMPLEMENTS => Some(relation_profile(
328 "implements",
329 "implemented by",
330 26,
331 true,
332 true,
333 )),
334 _ => None,
335 }
336}
337
338fn relation_profile(
339 forward: &str,
340 reverse: &str,
341 context_score: i64,
342 searchable: bool,
343 traversable: bool,
344) -> RelationProfile {
345 RelationProfile {
346 forward_phrase: forward.to_string(),
347 reverse_phrase: reverse.to_string(),
348 context_score,
349 searchable,
350 traversable,
351 domain_kinds: Vec::new(),
352 range_kinds: Vec::new(),
353 }
354}
355
356/// Serde skip predicate for EdgeBasis (skip if it's the default = Resolved).
357fn is_default_basis(b: &EdgeBasis) -> bool {
358 *b == EdgeBasis::Lexical
359}
360
361/// Serde skip predicate for u64 (skip if zero — the default/unknown value).
362fn is_zero_u64(v: &u64) -> bool {
363 *v == 0
364}
365
366/// The compiled graph. (Extended graph-artifact fields — stats/aliases/built_at — are added
367/// when structure parity is wired.)
368#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
369pub struct Graph {
370 #[serde(default)]
371 pub nodes: Vec<Node>,
372 #[serde(default)]
373 pub edges: Vec<Edge>,
374 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
375 pub relation_profiles: BTreeMap<String, RelationProfile>,
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn normalizes_legacy_to_native() {
384 assert_eq!(Kind::normalize("specialist"), Kind::Agent);
385 // Sprint 7: Flow collapsed to Doc (workflow/archetype are niche doc subtypes).
386 assert_eq!(Kind::normalize("workflow"), Kind::Doc);
387 assert_eq!(Kind::normalize("archetype"), Kind::Doc);
388 assert_eq!(Kind::normalize("skill"), Kind::Skill);
389 // Sprint 6 collapse: idea/mcp/contract/memory fold into Doc; check has no successor.
390 assert_eq!(Kind::normalize("idea"), Kind::Doc);
391 assert_eq!(Kind::normalize("mcp"), Kind::Doc);
392 assert_eq!(Kind::normalize("contract"), Kind::Doc);
393 assert_eq!(Kind::normalize("memory"), Kind::Doc);
394 assert_eq!(Kind::normalize("claim"), Kind::Doc);
395 assert_eq!(Kind::normalize("check"), Kind::Unknown);
396 assert_eq!(Kind::normalize("bogus"), Kind::Unknown);
397 assert!(Kind::is_legacy("specialist"));
398 assert!(Kind::is_legacy("mcp"));
399 assert!(!Kind::is_legacy("agent"));
400 }
401
402 #[test]
403 fn node_round_trips_through_json() {
404 let n = Node {
405 id: "skill.mcp".into(),
406 kind: Kind::Skill,
407 subkind: Some("skill".into()),
408 title: "mcp".into(),
409 summary: "build mcp servers".into(),
410 aliases: vec!["mcp".into(), "fastmcp".into()],
411 tags: vec![],
412 query_examples: vec![],
413 source_files: vec!["/x/SKILL.md".into()],
414 span: None,
415 partition: None,
416 };
417 let json = serde_json::to_string(&n).unwrap();
418 let back: Node = serde_json::from_str(&json).unwrap();
419 assert_eq!(back, n);
420 assert!(json.contains("\"kind\":\"skill\""));
421 }
422
423 #[test]
424 fn core_relation_profiles_define_native_relation_semantics() {
425 let profiles = core_relation_profiles();
426 let contains = &profiles[relation::CONTAINS];
427 assert_eq!(contains.forward_phrase, "contains");
428 assert_eq!(contains.reverse_phrase, "contained by");
429 assert!(!contains.searchable);
430 assert!(contains.traversable);
431
432 let mentions = &profiles[relation::MENTIONS];
433 assert_eq!(mentions.context_score, 4);
434 assert!(!mentions.searchable);
435 assert!(!mentions.traversable);
436
437 let references = &profiles[relation::REFERENCES];
438 assert_eq!(references.reverse_phrase, "referenced by");
439 assert!(references.searchable);
440 assert!(references.traversable);
441 }
442
443 #[test]
444 fn unknown_kind_does_not_panic() {
445 let n: Node =
446 serde_json::from_str(r#"{"id":"x","kind":"specialistx","title":"t"}"#).unwrap();
447 assert_eq!(n.kind, Kind::Unknown);
448 assert!(n.aliases.is_empty()); // defaulted, not required
449 }
450
451 #[test]
452 fn edge_basis_defaults_to_lexical_so_trust_is_earned_not_inherited() {
453 // D1: the untrusted band is the safe default. An edge builder that forgets to set
454 // basis gets Lexical (a candidate, not walked in expansion) — Resolved must be claimed
455 // explicitly by syntax/authoring. This flips the old Resolved-default polarity that let
456 // lexical co-occurrence edges (cross.rs mentions) inherit trust they never earned.
457 assert_eq!(EdgeBasis::default(), EdgeBasis::Lexical);
458 assert_eq!(Edge::default().basis, EdgeBasis::Lexical);
459 }
460}