eidos-kernel 0.1.0

Eidos kernel — the pure-logic brain engine (schema, retrieval, ranking, eval). No IO.
Documentation
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! Graph schema — nodes, edges, kinds. Defines the Aun graph-artifact contract.
//!
//! The Rust engine emits NATIVE kinds (the 9 base kinds) from day one; legacy kind
//! strings are mapped on ingest. Unknown kinds degrade to `Unknown` rather than crash.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// The 9 native base kinds, plus a tolerant `Unknown` sink.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
    Doc,
    Skill,
    Agent,
    /// A heading-delimited block within a Doc — its own groundable unit with a `file:line` span,
    /// `contains`-edged from its parent Doc. Lets grounding return the right *section* of a long
    /// doc, not the whole file. (Sprint 3.5.)
    Section,
    // Glyph (code) kinds — symbols extracted from source, alongside the doc kinds above.
    Function,
    Type,
    Trait,
    Module,
    /// Any unrecognized kind string deserializes here instead of failing the parse.
    #[serde(other)]
    Unknown,
}

impl Kind {
    /// Map a raw kind string — including legacy names — to a native kind.
    /// `specialist -> agent`, `workflow|archetype -> doc` (Sprint 7: Flow collapsed to Doc);
    /// unknown -> `Unknown`.
    pub fn normalize(raw: &str) -> Kind {
        let raw = raw.trim().to_ascii_lowercase();
        match raw.as_str() {
            "specialist" => Kind::Agent,
            "workflow" | "archetype" => Kind::Doc,
            // Collapsed kinds (Sprint 6): these legacy domain types fold into Doc, their
            // distinction preserved in `subkind`. "check" has no successor → Unknown.
            "idea" | "mcp" | "contract" | "memory" | "claim" => Kind::Doc,
            other => serde_json::from_value(serde_json::Value::String(other.to_string()))
                .unwrap_or(Kind::Unknown),
        }
    }

    /// Resolve a frontmatter `type`/`kind` string into a native kind, consulting an optional
    /// project-profile vocabulary first. Resolution order: a case-insensitive hit in `overrides`
    /// (whose mapped value is then parsed by [`Kind::normalize`]) wins; otherwise fall back to
    /// [`Kind::normalize`] directly. With `overrides == None` this is byte-for-byte `normalize`.
    pub fn resolve(
        raw: &str,
        overrides: Option<&std::collections::BTreeMap<String, String>>,
    ) -> Kind {
        if let Some(map) = overrides {
            let key = raw.trim().to_ascii_lowercase();
            if let Some(mapped) = map.get(&key) {
                return Kind::normalize(mapped);
            }
        }
        Kind::normalize(raw)
    }

    /// True if this raw string is a legacy alias (caller emits a `legacy_kind_renamed` note).
    pub fn is_legacy(raw: &str) -> bool {
        matches!(
            raw.trim().to_ascii_lowercase().as_str(),
            "specialist"
                | "workflow"
                | "archetype"
                | "idea"
                | "mcp"
                | "contract"
                | "memory"
                | "check"
        )
    }
}

/// A code node's location: the byte/line span it occupies in a source file. Doc nodes (whole-file
/// units) carry `None`; Glyph symbol nodes carry `Some` so `read_doc` can return just the symbol
/// body — every code node traceable to an exact `file:line` provenance span.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Span {
    pub path: String,
    pub start_line: usize,
    pub end_line: usize,
}

/// A graph node. Optional fields default so partial/external JSON never panics.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Node {
    pub id: String,
    pub kind: Kind,
    /// Generated provenance: the EKF partition that produced this node. Authored files do not set
    /// this; the source registry stamps it from the package manifest.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub partition: Option<String>,
    /// Open domain refinement of `kind` — the frontmatter `type`/`kind` string (lowercased), e.g.
    /// "skill", "agent", "mcp", or a user's own ("forecast"). `None` for a plain doc / code symbol.
    /// Drives domain filtering (subkind scope) + display; never structural. See the type-system spec.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subkind: Option<String>,
    pub title: String,
    #[serde(default)]
    pub summary: String,
    /// NAMES — strong BM25F identity boost. These are alternative identifiers (synonyms,
    /// acronyms, common misspellings). They feed exact/partial alias matching (+70/+25) and
    /// the identity anchor (confidence-carrying). Do NOT put categories here — use `tags`.
    #[serde(default)]
    pub aliases: Vec<String>,
    /// CATEGORIES — weak boost. These are topical tags (e.g. "baking", "rust", "devops").
    /// They contribute to the haystack (term coverage for matching) but NOT to identity
    /// anchor or alias exact/partial matching. A tag match is [weak], not [strong].
    #[serde(default)]
    pub tags: Vec<String>,
    /// Query-side phrases that should route here: author-declared examples plus generated Aun
    /// body/heading search surfaces. Their vocabulary enriches retrieval matching (the
    /// deterministic synonym/intent bridge) but they are NOT identifiers — they never feed alias
    /// exact/partial matching or the mentions index.
    #[serde(default)]
    pub query_examples: Vec<String>,
    #[serde(default)]
    pub source_files: Vec<String>,
    /// Glyph only: the source span this node occupies. `None` for doc nodes (whole-file units).
    #[serde(default)]
    pub span: Option<Span>,
}

/// The structural edge-relation vocabulary, defined ONCE so the producer (compile) and the
/// consumers (retrieval) can't drift on a typo'd string literal. (Typed frontmatter relations —
/// `depends_on`, `produces`, … — live in `compile::REL_FIELDS`.)
pub mod relation {
    /// A `SKILL.md` owns the docs under its directory (structural ownership).
    pub const HAS_KNOWLEDGE: &str = "has_knowledge";
    /// An explicit `[[wikilink]]` the author wrote.
    pub const LINKS_TO: &str = "links_to";
    /// An unlinked lexical mention — the candidate/noisy class (not traversed in expansion).
    pub const MENTIONS: &str = "mentions";

    // Glyph (code) relations. Structural ones are CERTAIN from syntax (trusted, walked in
    // expansion); `references` is the lexical call/use signal — the code analogue of `mentions`
    // (untrusted, not walked). The trusted/untrusted split is enforced by the read side.
    /// A container holds a declared item: module→item, type→method (from syntax).
    pub const CONTAINS: &str = "contains";
    /// A type implements a trait (`impl Trait for Type`) — from syntax.
    pub const IMPLEMENTS: &str = "implements";
    /// A symbol's body lexically names another known symbol — the candidate/noisy class.
    pub const REFERENCES: &str = "references";
}

/// Evidence marker for a reference edge that exists PURELY as a call-graph relationship — a
/// `self.attr.method()` call resolved by inferred receiver type. Such edges power `callers`/`impact`
/// traversal but are excluded from the BM25F grounding relation-surface: a callee's "used by X" is
/// call structure, not "aboutness", and including it would let a caller bleed into the callee's
/// grounding (and vice-versa). Single source of truth so the extractor and the scorer agree.
pub const TRAVERSAL_ONLY_EVIDENCE: &str = "receiver-typed method call (self.attr inferred type)";

/// The provenance basis of an edge — where the evidence comes from. This is the synaptic
/// strength signal: `Resolved` edges (from syntax/authoring) are trusted and strong;
/// `Lexical` edges (from co-occurrence heuristics) are candidates, weak until verified.
///
/// The activation ranker uses this as the base weight: Resolved=1.0, Lexical=0.4.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EdgeBasis {
    /// From syntax or explicit authoring (wikilinks, `impl`, `contains`, frontmatter relations).
    /// Trusted — walked during context expansion. Synaptic weight: 1.0. Must be claimed
    /// explicitly at the edge builder; it is NEVER the default (trust is earned, not inherited).
    Resolved,
    /// From lexical co-occurrence heuristics (`mentions`, `references`). Untrusted — not walked
    /// in expansion, but available as a candidate signal. Synaptic weight: 0.4. This is the
    /// DEFAULT: an edge builder that forgets to set basis gets the safe, untrusted band.
    #[default]
    Lexical,
}

/// A directed, evidence-bearing edge — a synapse in the knowledge graph.
///
/// ## Bi-temporal fields
///
/// `asserted_at` and `expires_at` are the temporal axis (the Graphiti idea, deterministic).
/// Human-authored edges keep `expires_at = None` (stable forever). Sigil-accepted files can receive
/// a half-life from EKF `trust.agent_accepted_ttl_days`: `expires_at = asserted_at + TTL`. The edge
/// is not deleted — when expired, Engine policy can de-rank it and flag it for re-verification.
///
/// The runtime properties (`use_count`, `last_used`) are NOT stored here — they live in the
/// Ryn metadata overlay (SQLite), keyed by edge identity. The graph stays deterministic; the
/// overlay breathes.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct Edge {
    pub from: String,
    pub to: String,
    pub relation: String,
    /// Backward-compatible compact evidence summary. When several sources assert the same edge,
    /// summaries are merged instead of dropping later evidence.
    pub evidence: String,

    /// Structured receipts for why this edge exists. The graph keeps the compact `evidence` string
    /// for old consumers, while agents that need auditability can inspect these receipt records.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub evidence_refs: Vec<EdgeEvidence>,

    /// Provenance basis — the synaptic strength class. Defaults to `Resolved` (backward-compatible).
    #[serde(default, skip_serializing_if = "is_default_basis")]
    pub basis: EdgeBasis,

    /// When this edge was asserted (compile time, unix seconds). 0 = unknown/stable.
    /// Deterministic: the same source files always produce the same `asserted_at` per build.
    #[serde(default, skip_serializing_if = "is_zero_u64")]
    pub asserted_at: u64,

    /// When this edge should be re-verified. `None` = stable (human-authored, no decay).
    /// `Some(t)` = agent-accepted, decays at time `t`. The immune system trigger.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<u64>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct EdgeEvidence {
    /// Human-readable reason, e.g. `frontmatter`, `wikilink`, or `resolved reference`.
    pub reason: String,
    /// Source file that supports the assertion, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// Exact node/source span that supports the assertion, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub span: Option<Span>,
}

/// Retrieval/traversal semantics for a relation name.
///
/// Edges keep their simple `relation: String`; this optional graph-level profile gives retrieval
/// the contract for how a relation should be searched and walked without hardcoding every domain
/// relation in the kernel.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct RelationProfile {
    /// Phrase indexed on the source node, e.g. `produces`.
    pub forward_phrase: String,
    /// Phrase indexed on the target node, e.g. `produced by`.
    pub reverse_phrase: String,
    /// Relative priority when building focused context packs.
    pub context_score: i64,
    /// Whether this relation contributes relation text to BM25F.
    pub searchable: bool,
    /// Whether focus expansion may walk this relation when the edge basis is trusted.
    pub traversable: bool,
    /// Optional allowed source node kinds for this relation, using lowercase kind labels.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub domain_kinds: Vec<String>,
    /// Optional allowed target node kinds for this relation, using lowercase kind labels.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub range_kinds: Vec<String>,
}

impl RelationProfile {
    pub fn new(
        forward_phrase: impl Into<String>,
        reverse_phrase: impl Into<String>,
        context_score: i64,
    ) -> Self {
        Self {
            forward_phrase: forward_phrase.into(),
            reverse_phrase: reverse_phrase.into(),
            context_score,
            searchable: true,
            traversable: true,
            domain_kinds: Vec::new(),
            range_kinds: Vec::new(),
        }
    }
}

/// Built-in relation profiles for Eidos' native graph vocabulary.
///
/// Domain-specific EKF/package relations are layered on top by the engine; these core profiles keep
/// native relation semantics visible and consistent for retrieval, focus, graph export, and MCP tools.
pub fn core_relation_profiles() -> BTreeMap<String, RelationProfile> {
    let mut profiles = BTreeMap::new();
    for relation in [
        relation::CONTAINS,
        relation::HAS_KNOWLEDGE,
        relation::LINKS_TO,
        relation::MENTIONS,
        relation::REFERENCES,
        relation::IMPLEMENTS,
    ] {
        if let Some(profile) = core_relation_profile(relation) {
            profiles.insert(relation.to_string(), profile);
        }
    }
    profiles
}

/// Return the built-in profile for one native relation.
pub fn core_relation_profile(relation: &str) -> Option<RelationProfile> {
    match relation {
        relation::CONTAINS => Some(relation_profile(
            "contains",
            "contained by",
            14,
            false,
            true,
        )),
        relation::HAS_KNOWLEDGE => Some(relation_profile(
            "has knowledge",
            "knowledge of",
            10,
            false,
            true,
        )),
        relation::LINKS_TO => Some(relation_profile("links to", "linked from", 22, true, true)),
        relation::MENTIONS => Some(relation_profile(
            "mentions",
            "mentioned by",
            4,
            false,
            false,
        )),
        relation::REFERENCES => Some(relation_profile(
            "references",
            "referenced by",
            30,
            true,
            true,
        )),
        relation::IMPLEMENTS => Some(relation_profile(
            "implements",
            "implemented by",
            26,
            true,
            true,
        )),
        _ => None,
    }
}

fn relation_profile(
    forward: &str,
    reverse: &str,
    context_score: i64,
    searchable: bool,
    traversable: bool,
) -> RelationProfile {
    RelationProfile {
        forward_phrase: forward.to_string(),
        reverse_phrase: reverse.to_string(),
        context_score,
        searchable,
        traversable,
        domain_kinds: Vec::new(),
        range_kinds: Vec::new(),
    }
}

/// Serde skip predicate for EdgeBasis (skip if it's the default = Resolved).
fn is_default_basis(b: &EdgeBasis) -> bool {
    *b == EdgeBasis::Lexical
}

/// Serde skip predicate for u64 (skip if zero — the default/unknown value).
fn is_zero_u64(v: &u64) -> bool {
    *v == 0
}

/// The compiled graph. (Extended graph-artifact fields — stats/aliases/built_at — are added
/// when structure parity is wired.)
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Graph {
    #[serde(default)]
    pub nodes: Vec<Node>,
    #[serde(default)]
    pub edges: Vec<Edge>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub relation_profiles: BTreeMap<String, RelationProfile>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_legacy_to_native() {
        assert_eq!(Kind::normalize("specialist"), Kind::Agent);
        // Sprint 7: Flow collapsed to Doc (workflow/archetype are niche doc subtypes).
        assert_eq!(Kind::normalize("workflow"), Kind::Doc);
        assert_eq!(Kind::normalize("archetype"), Kind::Doc);
        assert_eq!(Kind::normalize("skill"), Kind::Skill);
        // Sprint 6 collapse: idea/mcp/contract/memory fold into Doc; check has no successor.
        assert_eq!(Kind::normalize("idea"), Kind::Doc);
        assert_eq!(Kind::normalize("mcp"), Kind::Doc);
        assert_eq!(Kind::normalize("contract"), Kind::Doc);
        assert_eq!(Kind::normalize("memory"), Kind::Doc);
        assert_eq!(Kind::normalize("claim"), Kind::Doc);
        assert_eq!(Kind::normalize("check"), Kind::Unknown);
        assert_eq!(Kind::normalize("bogus"), Kind::Unknown);
        assert!(Kind::is_legacy("specialist"));
        assert!(Kind::is_legacy("mcp"));
        assert!(!Kind::is_legacy("agent"));
    }

    #[test]
    fn node_round_trips_through_json() {
        let n = Node {
            id: "skill.mcp".into(),
            kind: Kind::Skill,
            subkind: Some("skill".into()),
            title: "mcp".into(),
            summary: "build mcp servers".into(),
            aliases: vec!["mcp".into(), "fastmcp".into()],
            tags: vec![],
            query_examples: vec![],
            source_files: vec!["/x/SKILL.md".into()],
            span: None,
            partition: None,
        };
        let json = serde_json::to_string(&n).unwrap();
        let back: Node = serde_json::from_str(&json).unwrap();
        assert_eq!(back, n);
        assert!(json.contains("\"kind\":\"skill\""));
    }

    #[test]
    fn core_relation_profiles_define_native_relation_semantics() {
        let profiles = core_relation_profiles();
        let contains = &profiles[relation::CONTAINS];
        assert_eq!(contains.forward_phrase, "contains");
        assert_eq!(contains.reverse_phrase, "contained by");
        assert!(!contains.searchable);
        assert!(contains.traversable);

        let mentions = &profiles[relation::MENTIONS];
        assert_eq!(mentions.context_score, 4);
        assert!(!mentions.searchable);
        assert!(!mentions.traversable);

        let references = &profiles[relation::REFERENCES];
        assert_eq!(references.reverse_phrase, "referenced by");
        assert!(references.searchable);
        assert!(references.traversable);
    }

    #[test]
    fn unknown_kind_does_not_panic() {
        let n: Node =
            serde_json::from_str(r#"{"id":"x","kind":"specialistx","title":"t"}"#).unwrap();
        assert_eq!(n.kind, Kind::Unknown);
        assert!(n.aliases.is_empty()); // defaulted, not required
    }

    #[test]
    fn edge_basis_defaults_to_lexical_so_trust_is_earned_not_inherited() {
        // D1: the untrusted band is the safe default. An edge builder that forgets to set
        // basis gets Lexical (a candidate, not walked in expansion) — Resolved must be claimed
        // explicitly by syntax/authoring. This flips the old Resolved-default polarity that let
        // lexical co-occurrence edges (cross.rs mentions) inherit trust they never earned.
        assert_eq!(EdgeBasis::default(), EdgeBasis::Lexical);
        assert_eq!(Edge::default().basis, EdgeBasis::Lexical);
    }
}