package graph import ( "fmt" "math" "sort" "time" "github.com/local/glpi-neural-brain/internal/model" "github.com/local/glpi-neural-brain/internal/vectorgraph" ) const VectorMathOrigin = "vector-math" type VectorSemanticLayerConfig struct { Workers int Neighbors int CandidateLimit int HashBits int HashTables int MinSimilarity float64 MinAffinity float64 Layout bool LayoutRelax bool LayoutBlend float64 LayoutMaxShift float64 OrphanPass bool OrphanNeighbors int OrphanCandidateLimit int OrphanMinSimilarity float64 OrphanMinAffinity float64 } type VectorSemanticLayerStats struct { vectorgraph.Stats OrphanStats vectorgraph.Stats `json:"orphan_stats"` OrphanFocus int `json:"orphan_focus"` OrphanLinks int `json:"orphan_links"` PositionUpdates uint64 `json:"position_updates"` } func (s *Store) HasEdgesByOrigin(origin string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, edge := range s.edges { if edge.Origin == origin && edge.Status != "rejected" { return true } } return false } // VectorSemanticEntries snapshots the production knowledge vectors used by the // deterministic semantic layer. Callers may safely ship this immutable copy to // a CPU worker/Agent while the live graph stays owned by the Brain process. func (s *Store) VectorSemanticEntries(filter NodeFilter) []vectorgraph.Entry { s.mu.RLock() defer s.mu.RUnlock() entries := make([]vectorgraph.Entry, 0, len(s.vectors)) for id, node := range s.nodes { if node.Kind != "knowledge" || node.Status != "production" || !filter.Matches(node) { continue } vector, ok := s.vectors[id] if !ok || len(vector) == 0 { continue } entries = append(entries, vectorgraph.Entry{ID: id, Vector: append([]float32(nil), vector...)}) } sort.Slice(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID }) return entries } // KnowledgeOrphanIDsIgnoringOrigin returns production knowledge nodes that have // no direct Knowledge/External evidence relationship when edges from the given // origin are ignored. The vector-math layer uses this before rebuilding itself // so an old mathematical edge does not hide a genuine orphan from pass two. func (s *Store) KnowledgeOrphanIDsIgnoringOrigin(filter NodeFilter, ignoredOrigin string) []string { s.mu.RLock() defer s.mu.RUnlock() linked := map[string]bool{} for _, edge := range s.edges { if edge.Status == "rejected" || edge.Origin == ignoredOrigin { continue } a, aok := s.nodes[edge.Source] b, bok := s.nodes[edge.Target] if !aok || !bok { continue } aKnowledge := a.Kind == "knowledge" && a.Status == "production" && filter.Matches(a) bKnowledge := b.Kind == "knowledge" && b.Status == "production" && filter.Matches(b) if aKnowledge && (b.Kind == "knowledge" || b.Kind == "ai-think" || b.Kind == "external") { linked[a.ID] = true } if bKnowledge && (a.Kind == "knowledge" || a.Kind == "ai-think" || a.Kind == "external") { linked[b.ID] = true } } out := make([]string, 0) for id, node := range s.nodes { if node.Kind == "knowledge" && node.Status == "production" && filter.Matches(node) && !linked[id] { if vector := s.vectors[id]; len(vector) > 0 { out = append(out, id) } } } sort.Strings(out) return out } func vectorBuildConfig(cfg VectorSemanticLayerConfig) vectorgraph.Config { return vectorgraph.Config{ Workers: cfg.Workers, Neighbors: cfg.Neighbors, CandidateLimit: cfg.CandidateLimit, HashBits: cfg.HashBits, HashTables: cfg.HashTables, BandBits: 8, MinSimilarity: cfg.MinSimilarity, MinAffinity: cfg.MinAffinity, Layout: cfg.Layout, Smoothing: .22, } } func orphanVectorBuildConfig(cfg VectorSemanticLayerConfig) vectorgraph.Config { return vectorgraph.Config{ Workers: cfg.Workers, Neighbors: cfg.OrphanNeighbors, CandidateLimit: cfg.OrphanCandidateLimit, HashBits: cfg.HashBits, HashTables: cfg.HashTables, BandBits: 8, MinSimilarity: cfg.OrphanMinSimilarity, MinAffinity: cfg.OrphanMinAffinity, Layout: false, } } // BuildVectorSemanticLayerLocal performs both mathematical passes locally. It // does not mutate the graph and can therefore be replaced transparently by an // Agent result using the same inputs/configuration. func (s *Store) BuildVectorSemanticLayerLocal(cfg VectorSemanticLayerConfig, filter NodeFilter) (vectorgraph.Result, vectorgraph.Result, []string) { entries := s.VectorSemanticEntries(filter) baseOrphans := s.KnowledgeOrphanIDsIgnoringOrigin(filter, VectorMathOrigin) primary := vectorgraph.Build(entries, vectorBuildConfig(cfg)) if !cfg.OrphanPass || len(baseOrphans) == 0 { return primary, vectorgraph.Result{}, nil } focus := make(map[string]bool, len(baseOrphans)) for _, id := range baseOrphans { focus[id] = true } for _, link := range primary.Links { delete(focus, link.Source) delete(focus, link.Target) } focusIDs := make([]string, 0, len(focus)) for id := range focus { focusIDs = append(focusIDs, id) } sort.Strings(focusIDs) orphan := vectorgraph.BuildFocused(entries, focus, orphanVectorBuildConfig(cfg)) return primary, orphan, focusIDs } // ApplyVectorSemanticLayer atomically replaces the mathematical edge layer // with a result computed either locally or on an Agent. The Brain remains the // sole graph owner and validates endpoint existence while applying the result. func (s *Store) ApplyVectorSemanticLayer(cfg VectorSemanticLayerConfig, primary, orphan vectorgraph.Result, orphanFocus []string) (VectorSemanticLayerStats, MutationStats) { type taggedLink struct { link vectorgraph.Link pass string } byPair := map[string]taggedLink{} for _, link := range primary.Links { byPair[pairKey(link.Source, link.Target)] = taggedLink{link: link, pass: "primary"} } for _, link := range orphan.Links { key := pairKey(link.Source, link.Target) if _, exists := byPair[key]; !exists { byPair[key] = taggedLink{link: link, pass: "orphan"} } } keys := make([]string, 0, len(byPair)) for key := range byPair { keys = append(keys, key) } sort.Strings(keys) edges := make([]model.Edge, 0, len(keys)) now := time.Now().UTC() for _, key := range keys { tagged := byPair[key] link := tagged.link algorithm := "mutual-knn-local-scaling-v1" if tagged.pass == "orphan" { algorithm = "orphan-knn-local-scaling-v1" } edges = append(edges, model.Edge{ Source: link.Source, Target: link.Target, Type: "semantic_neighbor", Origin: VectorMathOrigin, Status: "staging", Confidence: link.Confidence, Weight: math.Max(.2, link.Affinity), Explanation: fmt.Sprintf("Deterministische Vektornachbarschaft (%s): Cosine %.4f, lokal skalierte Affinität %.4f", tagged.pass, link.Similarity, link.Affinity), Metadata: map[string]any{ "algorithm": algorithm, "pass": tagged.pass, "semantic_similarity": link.Similarity, "local_affinity": link.Affinity, "reciprocal": link.Reciprocal, "source_rank": link.SourceRank, "target_rank": link.TargetRank, "no_model_call": true, }, CreatedAt: now, UpdatedAt: now, }) } mutations := s.ReplaceOriginsWithStats([]string{VectorMathOrigin}, nil, edges) stats := VectorSemanticLayerStats{Stats: primary.Stats, OrphanStats: orphan.Stats, OrphanFocus: len(orphanFocus), OrphanLinks: len(orphan.Links)} if cfg.Layout && len(primary.Positions) > 0 { var updated MutationStats if cfg.LayoutRelax { updated = s.applyVectorPositionsRelaxed(primary.Positions, cfg.LayoutBlend, cfg.LayoutMaxShift) } else { updated = s.applyVectorPositions(primary.Positions) } stats.PositionUpdates = updated.NodesUpdated mutations.Add(updated) } return stats, mutations } // RebuildVectorSemanticLayer creates a sparse Knowledge<->Knowledge semantic // layer from already stored embeddings. It performs no model/network request. // The generated relation is intentionally named semantic_neighbor rather than // same_topic: vector proximity is a mathematical neighbourhood signal, not a // factual relation decision. func (s *Store) RebuildVectorSemanticLayer(cfg VectorSemanticLayerConfig, filter NodeFilter) (VectorSemanticLayerStats, MutationStats) { primary, orphan, focus := s.BuildVectorSemanticLayerLocal(cfg, filter) return s.ApplyVectorSemanticLayer(cfg, primary, orphan, focus) } func (s *Store) applyVectorPositionsRelaxed(positions []vectorgraph.Position, blend, maxShift float64) MutationStats { if blend <= 0 { blend = .08 } if blend > .5 { blend = .5 } if maxShift <= 0 { maxShift = .035 } wanted := make(map[string]vectorgraph.Position, len(positions)) for _, p := range positions { wanted[p.ID] = p } s.mu.Lock() defer s.mu.Unlock() var stats MutationStats for id, p := range wanted { node, ok := s.nodes[id] if !ok || node.Kind != "knowledge" || node.Status != "production" { continue } tx, ty, tz := fitVectorPosition(node.ID, p.X, p.Y, p.Z) dx, dy, dz := (tx-node.X)*blend, (ty-node.Y)*blend, (tz-node.Z)*blend distance := math.Sqrt(dx*dx + dy*dy + dz*dz) // Do not dirty thousands of rows for sub-pixel relaxation noise. Periodic // reevaluation will revisit the target later if the semantic geometry moves. if distance < .00075 { continue } if distance > maxShift && distance > 0 { scale := maxShift / distance dx, dy, dz = dx*scale, dy*scale, dz*scale } x, y, z := node.X+dx, node.Y+dy, node.Z+dz if math.Abs(node.X-x) < 1e-7 && math.Abs(node.Y-y) < 1e-7 && math.Abs(node.Z-z) < 1e-7 { continue } old := node node.X, node.Y, node.Z = x, y, z s.nodes[id] = node s.countNodeUpdatedLocked() stats.NodesUpdated++ s.version++ s.recordChangeLocked(nodeUpdateChange(old, node)) s.markNodeDirtyLocked(id) } return stats } func (s *Store) applyVectorPositions(positions []vectorgraph.Position) MutationStats { wanted := make(map[string]vectorgraph.Position, len(positions)) for _, p := range positions { wanted[p.ID] = p } s.mu.Lock() defer s.mu.Unlock() var stats MutationStats for id, p := range wanted { node, ok := s.nodes[id] if !ok || node.Kind != "knowledge" || node.Status != "production" { continue } x, y, z := fitVectorPosition(node.ID, p.X, p.Y, p.Z) if math.Abs(node.X-x) < 1e-7 && math.Abs(node.Y-y) < 1e-7 && math.Abs(node.Z-z) < 1e-7 { continue } old := node node.X, node.Y, node.Z = x, y, z // Position is a derived visualization property; preserve source freshness. s.nodes[id] = node s.countNodeUpdatedLocked() stats.NodesUpdated++ s.version++ s.recordChangeLocked(nodeUpdateChange(old, node)) s.markNodeDirtyLocked(id) } return stats } func fitVectorPosition(id string, x, y, z float64) (float64, float64, float64) { x = math.Max(-.82, math.Min(.82, x)) y = math.Max(-.78, math.Min(.82, y)) z = math.Max(-.62, math.Min(.62, z)) if math.Abs(x) < .055 && y > -.58 && y < .42 { if ID("vector-layout-side", id)[0]%2 == 0 { x = .06 } else { x = -.06 } } for i := 0; i < 20 && !insideBrainShape(x, y, z); i++ { x *= .94 y *= .94 z *= .94 if math.Abs(x) < .055 && y > -.58 && y < .42 { if x >= 0 { x = .06 } else { x = -.06 } } } if !insideBrainShape(x, y, z) { return position(id, nil) } return x, y, z } type VectorNeighborCandidateStats struct { Candidates int `json:"candidates"` AlreadyReviewed int `json:"already_reviewed"` } // NextVectorNeighborPairScoped returns the strongest mathematical neighbourhood // that has not yet been reviewed by AI-THINK. This turns the vector layer into // a cheap candidate generator: the LLM evaluates relations instead of spending // model time searching the full embedding space again. func (s *Store) NextVectorNeighborPairScoped(filter NodeFilter, maxAIDepth int) (model.Node, model.Node, float64, bool, VectorNeighborCandidateStats) { s.mu.RLock() defer s.mu.RUnlock() stats := VectorNeighborCandidateStats{} reviewed := map[string]bool{} for _, edge := range s.edges { if edge.Origin == "ai-inference" { reviewed[pairKey(edge.Source, edge.Target)] = true } } type candidate struct { edge model.Edge similarity float64 reciprocal bool } candidates := make([]candidate, 0) for _, edge := range s.edges { if edge.Origin != VectorMathOrigin || edge.Type != "semantic_neighbor" || edge.Status == "rejected" { continue } a, aok := s.nodes[edge.Source] b, bok := s.nodes[edge.Target] if !aok || !bok || !filter.Matches(a) || !filter.Matches(b) { continue } if a.Kind != "knowledge" || b.Kind != "knowledge" { continue } if (a.Kind == "ai-think" && maxAIDepth > 0 && graphNodeGenerationDepth(a) >= maxAIDepth) || (b.Kind == "ai-think" && maxAIDepth > 0 && graphNodeGenerationDepth(b) >= maxAIDepth) { continue } stats.Candidates++ if reviewed[pairKey(a.ID, b.ID)] { stats.AlreadyReviewed++ continue } similarity := edge.Confidence if value, ok := edge.Metadata["semantic_similarity"].(float64); ok { similarity = value } else if value, ok := edge.Metadata["semantic_similarity"].(float32); ok { similarity = float64(value) } reciprocal, _ := edge.Metadata["reciprocal"].(bool) candidates = append(candidates, candidate{edge: edge, similarity: similarity, reciprocal: reciprocal}) } if len(candidates) == 0 { return model.Node{}, model.Node{}, 0, false, stats } sort.Slice(candidates, func(i, j int) bool { if candidates[i].reciprocal != candidates[j].reciprocal { return candidates[i].reciprocal } if candidates[i].edge.Confidence != candidates[j].edge.Confidence { return candidates[i].edge.Confidence > candidates[j].edge.Confidence } if candidates[i].similarity != candidates[j].similarity { return candidates[i].similarity > candidates[j].similarity } return candidates[i].edge.ID < candidates[j].edge.ID }) best := candidates[0] return s.nodes[best.edge.Source], s.nodes[best.edge.Target], best.similarity, true, stats }