All checks were successful
release-tag / release-image (push) Successful in 2m32s
408 lines
14 KiB
Go
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
|
|
}
|