use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Doc,
Skill,
Agent,
Section,
Function,
Type,
Trait,
Module,
#[serde(other)]
Unknown,
}
impl Kind {
pub fn normalize(raw: &str) -> Kind {
let raw = raw.trim().to_ascii_lowercase();
match raw.as_str() {
"specialist" => Kind::Agent,
"workflow" | "archetype" => Kind::Doc,
"idea" | "mcp" | "contract" | "memory" | "claim" => Kind::Doc,
other => serde_json::from_value(serde_json::Value::String(other.to_string()))
.unwrap_or(Kind::Unknown),
}
}
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)
}
pub fn is_legacy(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"specialist"
| "workflow"
| "archetype"
| "idea"
| "mcp"
| "contract"
| "memory"
| "check"
)
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Span {
pub path: String,
pub start_line: usize,
pub end_line: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Node {
pub id: String,
pub kind: Kind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subkind: Option<String>,
pub title: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub query_examples: Vec<String>,
#[serde(default)]
pub source_files: Vec<String>,
#[serde(default)]
pub span: Option<Span>,
}
pub mod relation {
pub const HAS_KNOWLEDGE: &str = "has_knowledge";
pub const LINKS_TO: &str = "links_to";
pub const MENTIONS: &str = "mentions";
pub const CONTAINS: &str = "contains";
pub const IMPLEMENTS: &str = "implements";
pub const REFERENCES: &str = "references";
}
pub const TRAVERSAL_ONLY_EVIDENCE: &str = "receiver-typed method call (self.attr inferred type)";
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EdgeBasis {
Resolved,
#[default]
Lexical,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct Edge {
pub from: String,
pub to: String,
pub relation: String,
pub evidence: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_refs: Vec<EdgeEvidence>,
#[serde(default, skip_serializing_if = "is_default_basis")]
pub basis: EdgeBasis,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub asserted_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct EdgeEvidence {
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct RelationProfile {
pub forward_phrase: String,
pub reverse_phrase: String,
pub context_score: i64,
pub searchable: bool,
pub traversable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domain_kinds: Vec<String>,
#[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(),
}
}
}
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
}
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(),
}
}
fn is_default_basis(b: &EdgeBasis) -> bool {
*b == EdgeBasis::Lexical
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
#[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);
assert_eq!(Kind::normalize("workflow"), Kind::Doc);
assert_eq!(Kind::normalize("archetype"), Kind::Doc);
assert_eq!(Kind::normalize("skill"), Kind::Skill);
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()); }
#[test]
fn edge_basis_defaults_to_lexical_so_trust_is_earned_not_inherited() {
assert_eq!(EdgeBasis::default(), EdgeBasis::Lexical);
assert_eq!(Edge::default().basis, EdgeBasis::Lexical);
}
}