Files
glpi-neural-brain/internal/graph/store.go
2026-08-07 17:05:03 +02:00

976 lines
24 KiB
Go

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
}