package graph import ( "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "math" "sort" "strings" "sync" "time" "github.com/local/glpi-neural-brain/internal/model" ) type Store struct { mu sync.RWMutex nodes map[string]model.Node edges map[string]model.Edge vectors map[string][]float32 version uint64 persistedVersion uint64 pairCursor int embeddingModel string embeddingDigest string embeddingMetaGeneration uint64 mutations MutationStats db *sql.DB dbPath string journalMode string dirtyNodes map[string]uint64 dirtyEdges map[string]uint64 dirtyVectors map[string]uint64 deletedNodes map[string]uint64 deletedEdges map[string]uint64 deletedVectors map[string]uint64 analysisMu sync.Mutex analysisQueue chan analysisRecord analysisWG sync.WaitGroup analysisLastMutations MutationStats analysisPendingChanges []GraphChange analysisPendingTruncated int analysisChangesDropped uint64 analysisDropped uint64 analysisLastError string analysisLastPersisted time.Time processID string analysisDetailMu sync.Mutex analysisDetailVersion uint64 analysisDetailCache DetailedGraphAnalysis researchTaskMu sync.Mutex } func ID(parts ...string) string { h := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) return hex.EncodeToString(h[:12]) } func EdgeID(source, target, typ, origin string) string { return ID("edge", source, target, typ, origin) } func (s *Store) markNodeDirtyLocked(id string) { delete(s.deletedNodes, id) s.dirtyNodes[id] = s.version } func (s *Store) markEdgeDirtyLocked(id string) { delete(s.deletedEdges, id) s.dirtyEdges[id] = s.version } func (s *Store) markVectorDirtyLocked(id string) { delete(s.deletedVectors, id) s.dirtyVectors[id] = s.version } func (s *Store) markNodeDeletedLocked(id string) { delete(s.dirtyNodes, id) s.deletedNodes[id] = s.version } func (s *Store) markEdgeDeletedLocked(id string) { delete(s.dirtyEdges, id) s.deletedEdges[id] = s.version } func (s *Store) markVectorDeletedLocked(id string) { delete(s.dirtyVectors, id) s.deletedVectors[id] = s.version } func (s *Store) Dirty() bool { s.mu.RLock() defer s.mu.RUnlock() return len(s.dirtyNodes)+len(s.dirtyEdges)+len(s.dirtyVectors)+len(s.deletedNodes)+len(s.deletedEdges)+len(s.deletedVectors) > 0 || s.embeddingMetaGeneration > 0 || s.version != s.persistedVersion } // ConfigureEmbeddingModel records the model name before Ollama health data is // available. A stored digest remains intact when the model name is unchanged. func (s *Store) ConfigureEmbeddingModel(modelName string) int { return s.ConfigureEmbeddingIdentity(modelName, "") } // ConfigureEmbeddingIdentity records the model and, once known, its Ollama // digest. Importing a database built with another model identity automatically // invalidates all vectors while retaining nodes and edges for selective // relearning. func (s *Store) ConfigureEmbeddingIdentity(modelName, digest string) int { modelName = strings.TrimSpace(modelName) digest = strings.TrimSpace(digest) if modelName == "" { return 0 } s.mu.Lock() defer s.mu.Unlock() modelChanged := s.embeddingModel != "" && s.embeddingModel != modelName digestChanged := digest != "" && s.embeddingDigest != "" && s.embeddingDigest != digest metadataChanged := s.embeddingModel != modelName || (digest != "" && s.embeddingDigest != digest) if !metadataChanged { return 0 } s.version++ removed := 0 if modelChanged || digestChanged { for id, vector := range s.vectors { label := "" if node, ok := s.nodes[id]; ok { label = node.Label } delete(s.vectors, id) s.countVectorDeletedLocked() s.recordChangeLocked(vectorChange(id, "deleted", len(vector), label)) s.deletedVectors[id] = s.version delete(s.dirtyVectors, id) removed++ } } s.embeddingModel = modelName if modelChanged { s.embeddingDigest = digest } else if digest != "" { s.embeddingDigest = digest } s.embeddingMetaGeneration = s.version return removed } func (s *Store) UpsertNode(n model.Node) { s.mu.Lock() defer s.mu.Unlock() old, existed := s.nodes[n.ID] if n.UpdatedAt.IsZero() { n.UpdatedAt = time.Now().UTC() } if n.Weight == 0 { n.Weight = 1 } if n.X == 0 && n.Y == 0 && n.Z == 0 { n.X, n.Y, n.Z = position(n.ID, n.Categories) } s.nodes[n.ID] = n if existed { s.countNodeUpdatedLocked() } else { s.countNodeCreatedLocked() } s.version++ if existed { s.recordChangeLocked(nodeUpdateChange(old, n)) } else { s.recordChangeLocked(nodeChange(n, "created")) } s.markNodeDirtyLocked(n.ID) } func (s *Store) UpsertEdge(e model.Edge) { s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() if e.ID == "" { e.ID = EdgeID(e.Source, e.Target, e.Type, e.Origin) } old, existed := s.edges[e.ID] if e.CreatedAt.IsZero() { if existed { e.CreatedAt = old.CreatedAt } else { e.CreatedAt = now } } e.UpdatedAt = now if e.Weight == 0 { e.Weight = 1 } s.edges[e.ID] = e if existed { s.countEdgeUpdatedLocked() } else { s.countEdgeCreatedLocked() } s.version++ if existed { s.recordChangeLocked(edgeUpdateChange(old, e)) } else { s.recordChangeLocked(edgeChange(e, "created")) } s.markEdgeDirtyLocked(e.ID) } func (s *Store) HasEdgeBetween(a, b string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, e := range s.edges { if (e.Source == a && e.Target == b) || (e.Source == b && e.Target == a) { return true } } return false } func (s *Store) GetNode(id string) (model.Node, bool) { s.mu.RLock() defer s.mu.RUnlock() n, ok := s.nodes[id] return n, ok } func (s *Store) LookupExternal(id string) (model.Node, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, n := range s.nodes { if strings.EqualFold(n.ExternalID, id) { return n, true } } return model.Node{}, false } func (s *Store) SetVector(id string, v []float64) { s.mu.Lock() defer s.mu.Unlock() converted := make([]float32, len(v)) for i, value := range v { converted[i] = float32(value) } old, ok := s.vectors[id] if ok && float32SlicesEqual(old, converted) { return } s.vectors[id] = converted if ok { s.countVectorUpdatedLocked() } else { s.countVectorCreatedLocked() } s.version++ label := "" if node, exists := s.nodes[id]; exists { label = node.Label } if ok { s.recordChangeLocked(vectorRecalculatedChange(id, len(old), len(converted), label)) } else { s.recordChangeLocked(vectorChange(id, "created", len(converted), label)) } s.markVectorDirtyLocked(id) } func (s *Store) Vector(id string) ([]float64, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.vectors[id] if !ok { return nil, false } out := make([]float64, len(v)) for i, value := range v { out[i] = float64(value) } return out, true } func (s *Store) ClearVectorsByDimension(dim int) int { s.mu.Lock() defer s.mu.Unlock() removed := 0 for id, v := range s.vectors { if len(v) == dim { label := "" if node, ok := s.nodes[id]; ok { label = node.Label } delete(s.vectors, id) s.countVectorDeletedLocked() removed++ s.version++ s.recordChangeLocked(vectorChange(id, "deleted", len(v), label)) s.markVectorDeletedLocked(id) } } return removed } func (s *Store) NodesForEmbedding() []model.Node { return s.NodesForEmbeddingScoped(NodeFilter{}) } func (s *Store) NodesForEmbeddingFiltered(sources []string) []model.Node { return s.NodesForEmbeddingScoped(NodeFilter{Sources: sources}) } func (s *Store) NodesForEmbeddingScoped(filter NodeFilter) []model.Node { s.mu.RLock() defer s.mu.RUnlock() out := []model.Node{} for _, n := range s.nodes { if n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external" { continue } if !filter.Matches(n) { continue } if _, ok := s.vectors[n.ID]; !ok { out = append(out, n) } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func (s *Store) KnowledgeNodes() []model.Node { s.mu.RLock() defer s.mu.RUnlock() out := []model.Node{} for _, n := range s.nodes { if n.Kind == "knowledge" || n.Kind == "ai-think" { out = append(out, n) } } return out } func (s *Store) ReplaceOrigins(origins []string, nodes []model.Node, edges []model.Edge) { originSet := make(map[string]struct{}, len(origins)) for _, origin := range origins { originSet[origin] = struct{}{} } now := time.Now().UTC() incomingNodes := make(map[string]model.Node, len(nodes)) for _, node := range nodes { if node.Weight == 0 { node.Weight = 1 } normalizeNodeCollections(&node) incomingNodes[node.ID] = node } incomingEdges := make(map[string]model.Edge, len(edges)) for _, edge := range edges { if edge.ID == "" { edge.ID = EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin) } if edge.Weight == 0 { edge.Weight = 1 } normalizeEdgeCollections(&edge) incomingEdges[edge.ID] = edge } s.mu.Lock() defer s.mu.Unlock() // Remove records from managed origins that disappeared from the source. for id, old := range s.nodes { if _, managed := originSet[old.Origin]; !managed { continue } if _, present := incomingNodes[id]; present { continue } delete(s.nodes, id) s.countNodeDeletedLocked() if _, hadVector := s.vectors[id]; hadVector { vector := s.vectors[id] delete(s.vectors, id) s.countVectorDeletedLocked() s.version++ s.recordChangeLocked(vectorChange(id, "deleted", len(vector), old.Label)) s.markVectorDeletedLocked(id) } s.version++ s.recordChangeLocked(nodeChange(old, "deleted")) s.markNodeDeletedLocked(id) } for id, old := range s.edges { if _, managed := originSet[old.Origin]; !managed { continue } if _, present := incomingEdges[id]; present { continue } delete(s.edges, id) s.countEdgeDeletedLocked() s.version++ s.recordChangeLocked(edgeChange(old, "deleted")) s.markEdgeDeletedLocked(id) } // Reconcile nodes instead of deleting and recreating every row on each scan. // This is what makes the scheduled SQLite flush truly incremental for an // unchanged knowledge base. for id, incoming := range incomingNodes { old, existed := s.nodes[id] if incoming.UpdatedAt.IsZero() { if existed && !old.UpdatedAt.IsZero() { incoming.UpdatedAt = old.UpdatedAt } else { incoming.UpdatedAt = now } } if incoming.X == 0 && incoming.Y == 0 && incoming.Z == 0 { if existed && (old.X != 0 || old.Y != 0 || old.Z != 0) { incoming.X, incoming.Y, incoming.Z = old.X, old.Y, old.Z } else { incoming.X, incoming.Y, incoming.Z = position(incoming.ID, incoming.Categories) } } oldEmbeddingFingerprint := "" if existed { oldEmbeddingFingerprint = embeddingFingerprint(old) } if existed && nodesEquivalent(old, incoming) { continue } s.nodes[id] = incoming if existed { s.countNodeUpdatedLocked() } else { s.countNodeCreatedLocked() } s.version++ if existed { s.recordChangeLocked(nodeUpdateChange(old, incoming)) } else { s.recordChangeLocked(nodeChange(incoming, "created")) } s.markNodeDirtyLocked(id) // Embeddings depend on text/categories/keywords, not on display // coordinates or unrelated metadata. Only invalidate a vector when its // actual embedding input changed. if existed && oldEmbeddingFingerprint != embeddingFingerprint(incoming) { if vector, hadVector := s.vectors[id]; hadVector { delete(s.vectors, id) s.countVectorDeletedLocked() s.version++ s.recordChangeLocked(vectorChange(id, "deleted", len(vector), incoming.Label)) s.markVectorDeletedLocked(id) } } } // Reconcile deterministic source edges. Timestamps are retained for an // unchanged edge so periodic scans do not produce needless writes. for id, incoming := range incomingEdges { old, existed := s.edges[id] if existed && edgesEquivalentIgnoringTimestamps(old, incoming) { continue } if incoming.CreatedAt.IsZero() { if existed && !old.CreatedAt.IsZero() { incoming.CreatedAt = old.CreatedAt } else { incoming.CreatedAt = now } } incoming.UpdatedAt = now s.edges[id] = incoming if existed { s.countEdgeUpdatedLocked() } else { s.countEdgeCreatedLocked() } s.version++ if existed { s.recordChangeLocked(edgeUpdateChange(old, incoming)) } else { s.recordChangeLocked(edgeChange(incoming, "created")) } s.markEdgeDirtyLocked(id) } // Remove any remaining edge whose endpoint no longer exists. This includes // AI-derived edges that referred to a source note removed from the KB. for id, edge := range s.edges { if _, ok := s.nodes[edge.Source]; !ok { delete(s.edges, id) s.countEdgeDeletedLocked() s.version++ s.recordChangeLocked(edgeChange(edge, "deleted")) s.markEdgeDeletedLocked(id) continue } if _, ok := s.nodes[edge.Target]; !ok { delete(s.edges, id) s.countEdgeDeletedLocked() s.version++ s.recordChangeLocked(edgeChange(edge, "deleted")) s.markEdgeDeletedLocked(id) } } } func embeddingFingerprint(node model.Node) string { return node.Label + "\x00" + node.Summary + "\x00" + strings.Join(node.Categories, "\x00") + "\x00" + strings.Join(node.Keywords, "\x00") } func normalizeNodeCollections(node *model.Node) { if node.Categories == nil { node.Categories = []string{} } if node.Keywords == nil { node.Keywords = []string{} } if node.Metadata == nil { node.Metadata = map[string]any{} } } func normalizeEdgeCollections(edge *model.Edge) { if edge.Evidence == nil { edge.Evidence = []model.Evidence{} } if edge.Metadata == nil { edge.Metadata = map[string]any{} } } func nodesEquivalent(a, b model.Node) bool { if a.ID != b.ID || a.Kind != b.Kind || a.Label != b.Label || a.Summary != b.Summary || a.Status != b.Status || a.Origin != b.Origin || a.ExternalID != b.ExternalID || a.URI != b.URI || a.Weight != b.Weight || a.X != b.X || a.Y != b.Y || a.Z != b.Z { return false } return JSONEquivalent(a.Categories, b.Categories) && JSONEquivalent(a.Keywords, b.Keywords) && JSONEquivalent(a.Metadata, b.Metadata) } func edgesEquivalentIgnoringTimestamps(a, b model.Edge) bool { if a.ID != b.ID || a.Source != b.Source || a.Target != b.Target || a.Type != b.Type || a.Origin != b.Origin || a.Status != b.Status || a.Confidence != b.Confidence || a.Weight != b.Weight || a.Explanation != b.Explanation { return false } return JSONEquivalent(a.Evidence, b.Evidence) && JSONEquivalent(a.Metadata, b.Metadata) } func JSONEquivalent(a, b any) bool { left, leftErr := json.Marshal(a) right, rightErr := json.Marshal(b) return leftErr == nil && rightErr == nil && string(left) == string(right) } func (s *Store) Version() uint64 { s.mu.RLock() defer s.mu.RUnlock() return s.version } func (s *Store) Counts() (nodes, edges int, version uint64) { s.mu.RLock() defer s.mu.RUnlock() nodes = len(s.nodes) for _, edge := range s.edges { if edge.Status != "rejected" { edges++ } } return nodes, edges, s.version } func (s *Store) IdleNode(seed int64) (model.Node, bool) { s.mu.RLock() defer s.mu.RUnlock() if len(s.nodes) == 0 { return model.Node{}, false } index := int(seed % int64(len(s.nodes))) if index < 0 { index = -index } for _, node := range s.nodes { if index == 0 { return node, true } index-- } return model.Node{}, false } func (s *Store) Snapshot() model.Snapshot { s.mu.RLock() defer s.mu.RUnlock() n := make([]model.Node, 0, len(s.nodes)) e := make([]model.Edge, 0, len(s.edges)) for _, x := range s.nodes { n = append(n, x) } for _, x := range s.edges { if x.Status == "rejected" { continue } e = append(e, x) } sort.Slice(n, func(i, j int) bool { return n[i].ID < n[j].ID }) sort.Slice(e, func(i, j int) bool { return e[i].ID < e[j].ID }) return model.Snapshot{Version: s.version, Nodes: n, Edges: e, UpdatedAt: time.Now().UTC()} } func (s *Store) Similar(query []float64, limit int) []model.Hit { return s.SimilarFiltered(query, limit, NodeFilter{}) } func (s *Store) SimilarFiltered(query []float64, limit int, filter NodeFilter) []model.Hit { s.mu.RLock() defer s.mu.RUnlock() hits := []model.Hit{} for id, v := range s.vectors { n, ok := s.nodes[id] if !ok || (n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external") || !filter.Matches(n) { continue } score := cosineMixed(query, v) hits = append(hits, model.Hit{NodeID: id, Label: n.Label, Score: score, Kind: n.Kind, Status: n.Status}) } sort.Slice(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score }) if limit > 0 && len(hits) > limit { hits = hits[:limit] } return hits } // NextPair searches a bounded rotating window of anchor nodes instead of // comparing the complete graph on every AI-THINK cycle. This keeps candidate // selection responsive even for tens of thousands of knowledge nodes while the // rotating cursor eventually visits the complete corpus. func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node, float64, bool, int) { return s.NextPairFiltered(min, anchorLimit, nil) } func (s *Store) NextPairFiltered(min float64, anchorLimit int, sources []string) (model.Node, model.Node, float64, bool, int) { return s.NextPairFilteredDepth(min, anchorLimit, sources, 0) } func (s *Store) NextPairFilteredDepth(min float64, anchorLimit int, sources []string, maxAIDepth int) (model.Node, model.Node, float64, bool, int) { return s.NextPairScopedDepth(min, anchorLimit, NodeFilter{Sources: sources}, maxAIDepth) } func (s *Store) NextPairScopedDepth(min float64, anchorLimit int, filter NodeFilter, maxAIDepth int) (model.Node, model.Node, float64, bool, int) { s.mu.Lock() defer s.mu.Unlock() nodes := make([]model.Node, 0, len(s.nodes)) for _, n := range s.nodes { if n.Kind != "knowledge" && n.Kind != "ai-think" { continue } if !filter.Matches(n) { continue } if n.Kind == "ai-think" && maxAIDepth > 0 && graphNodeGenerationDepth(n) >= maxAIDepth { continue } if v, ok := s.vectors[n.ID]; ok && len(v) > 0 { nodes = append(nodes, n) } } if len(nodes) < 2 { return model.Node{}, model.Node{}, 0, false, 0 } sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) if anchorLimit <= 0 || anchorLimit > len(nodes) { anchorLimit = len(nodes) } blocked := make(map[string]struct{}, len(s.edges)) for _, e := range s.edges { blocked[pairKey(e.Source, e.Target)] = struct{}{} } start := s.pairCursor % len(nodes) best := -1.0 var a, b model.Node comparisons := 0 for step := 0; step < anchorLimit; step++ { i := (start + step) % len(nodes) left := nodes[i] lv := s.vectors[left.ID] for j := 0; j < len(nodes); j++ { if i == j { continue } right := nodes[j] if left.Kind == "ai-think" && right.Kind == "ai-think" { continue } if _, exists := blocked[pairKey(left.ID, right.ID)]; exists { continue } rv := s.vectors[right.ID] if len(lv) != len(rv) { continue } comparisons++ score := cosine32(lv, rv) if score >= min && score > best { best = score a, b = left, right } } } s.pairCursor = (start + anchorLimit) % len(nodes) return a, b, best, best >= 0, comparisons } func pairKey(a, b string) string { if a > b { a, b = b, a } return a + "\x00" + b } func (s *Store) BestPair(min float64) (model.Node, model.Node, float64, bool) { s.mu.RLock() defer s.mu.RUnlock() nodes := []model.Node{} for _, n := range s.nodes { if n.Kind == "knowledge" || n.Kind == "ai-think" { if _, ok := s.vectors[n.ID]; ok { nodes = append(nodes, n) } } } best := -1.0 var a, b model.Node for i := 0; i < len(nodes); i++ { for j := i + 1; j < len(nodes); j++ { if edgeBetweenLocked(s.edges, nodes[i].ID, nodes[j].ID) { continue } score := cosine32(s.vectors[nodes[i].ID], s.vectors[nodes[j].ID]) if score >= min && score > best { best = score a = nodes[i] b = nodes[j] } } } return a, b, best, best >= 0 } func (s *Store) ConnectingEdges(ids []string) []string { set := map[string]bool{} for _, id := range ids { set[id] = true } s.mu.RLock() defer s.mu.RUnlock() var out []string for id, e := range s.edges { if set[e.Source] && set[e.Target] { out = append(out, id) } } return out } func graphNodeGenerationDepth(n model.Node) int { if n.Kind != "ai-think" { return 0 } value, ok := n.Metadata["generation_depth"] if !ok { return 1 } switch typed := value.(type) { case int: return typed case int64: return int(typed) case float64: return int(typed) case json.Number: value, _ := typed.Int64() return int(value) default: return 1 } } func edgeBetweenLocked(edges map[string]model.Edge, a, b string) bool { for _, e := range edges { if (e.Source == a && e.Target == b) || (e.Source == b && e.Target == a) { return true } } return false } func float32SlicesEqual(a, b []float32) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func cosine32(a, b []float32) float64 { if len(a) == 0 || len(a) != len(b) { return 0 } var dot, aa, bb float64 for i := range a { av, bv := float64(a[i]), float64(b[i]) dot += av * bv aa += av * av bb += bv * bv } if aa == 0 || bb == 0 { return 0 } return dot / (math.Sqrt(aa) * math.Sqrt(bb)) } func cosineMixed(a []float64, b []float32) float64 { if len(a) == 0 || len(a) != len(b) { return 0 } var dot, aa, bb float64 for i := range a { bv := float64(b[i]) dot += a[i] * bv aa += a[i] * a[i] bb += bv * bv } if aa == 0 || bb == 0 { return 0 } return dot / (math.Sqrt(aa) * math.Sqrt(bb)) } func position(id string, cats []string) (float64, float64, float64) { seed := sha256.Sum256([]byte(id + "\x00" + strings.Join(cats, "|"))) u := func(i int) float64 { return float64(int(seed[i%len(seed)])) / 255 } side := -1.0 if seed[0]%2 == 0 { side = 1 } biasY, biasZ := 0.0, 0.0 if len(cats) > 0 { h := sha256.Sum256([]byte(cats[0])) biasY = (float64(h[0])/255 - .5) * .9 biasZ = (float64(h[1])/255 - .5) * .65 } for i := 0; i < 16; i++ { x := side * (0.08 + u(1+i)*0.72) y := biasY*.32 + (u(2+i)-.5)*1.18 z := biasZ*.28 + (u(3+i)-.5)*.94 if insideBrainShape(x, y, z) { return x, y, z } } return side * .34, biasY * .22, biasZ * .2 } func insideBrainShape(x, y, z float64) bool { if math.Abs(x) < .045 && y > -.58 && y < .42 { return false } if y < -.76 || y > .82 { return false } taperY := y + math.Abs(z)*.10 - math.Max(0, math.Abs(x)-.58)*.18 lx := (x + .35) / .58 rx := (x - .35) / .58 ny := taperY / .76 nz := z / .58 left := lx*lx+ny*ny+nz*nz <= 1 right := rx*rx+ny*ny+nz*nz <= 1 return left || right } func (s *Store) Analyze() model.GraphAnalysis { s.mu.RLock() defer s.mu.RUnlock() analysis := model.GraphAnalysis{NodeCount: len(s.nodes)} degree := make(map[string]int, len(s.nodes)) knowledgeLinked := make(map[string]bool) parent := make(map[string]string, len(s.nodes)) for id, n := range s.nodes { parent[id] = id if n.Status == "staging" { analysis.StagingNodes++ } if n.Kind == "ai-think" { analysis.AIThinkNodes++ } if n.Kind == "external" { analysis.ExternalNodes++ } } var find func(string) string find = func(x string) string { p := parent[x] if p != x { parent[x] = find(p) } return parent[x] } union := func(a, b string) { ra, rb := find(a), find(b) if ra != rb { parent[rb] = ra } } for _, e := range s.edges { if e.Status == "rejected" { continue } if _, ok := s.nodes[e.Source]; !ok { continue } if _, ok := s.nodes[e.Target]; !ok { continue } analysis.EdgeCount++ degree[e.Source]++ degree[e.Target]++ union(e.Source, e.Target) if e.Origin == "ai-inference" { analysis.AIEdges++ } if e.Type == "contradicts" { analysis.Contradictions++ } a, b := s.nodes[e.Source], s.nodes[e.Target] if (a.Kind == "knowledge" || a.Kind == "ai-think") && (b.Kind == "knowledge" || b.Kind == "ai-think" || b.Kind == "external") { knowledgeLinked[a.ID] = true if b.Kind != "external" { knowledgeLinked[b.ID] = true } } } roots := map[string]bool{} for id, n := range s.nodes { roots[find(id)] = true if (n.Kind == "knowledge" || n.Kind == "ai-think") && !knowledgeLinked[id] { analysis.KnowledgeOrphans++ } } analysis.Components = len(roots) hubs := make([]model.Hub, 0, len(degree)) for id, d := range degree { n := s.nodes[id] hubs = append(hubs, model.Hub{NodeID: id, Label: n.Label, Kind: n.Kind, Degree: d}) } sort.Slice(hubs, func(i, j int) bool { if hubs[i].Degree == hubs[j].Degree { return hubs[i].Label < hubs[j].Label } return hubs[i].Degree > hubs[j].Degree }) if len(hubs) > 8 { hubs = hubs[:8] } analysis.TopHubs = hubs return analysis }