Skip to main content

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}