Files
glpi-neural-brain/internal/graph/vector_layer.go
jbergner 94dbd4ccab
All checks were successful
release-tag / release-image (push) Successful in 2m32s
RC-4
2026-08-09 18:41:47 +02:00

408 lines
14 KiB
Go

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
}