Files
glpi-neural-brain/internal/engine/vector_graph.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

330 lines
14 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"math"
"sort"
"time"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/sourceagent"
"github.com/local/glpi-neural-brain/internal/vectorgraph"
)
type vectorLayerExecution struct {
Stats graph.VectorSemanticLayerStats
Mutations graph.MutationStats
Offloaded bool
AgentID string
ComputeMS int64
FallbackReason string
LayoutMode string
LayoutDue bool
}
func (e *Engine) vectorLayerConfig() graph.VectorSemanticLayerConfig {
workers := 1
if settings := e.RuntimeSettings(); settings.SpeedMode {
workers = e.effectiveSpeedCPUWorkers(settings)
}
return graph.VectorSemanticLayerConfig{
Workers: workers, Neighbors: e.Cfg.VectorGraphNeighbors, CandidateLimit: e.Cfg.VectorGraphCandidates,
HashBits: e.Cfg.ClusterHashBits, HashTables: e.Cfg.ClusterHashTables,
MinSimilarity: e.Cfg.VectorGraphMinSimilarity, MinAffinity: e.Cfg.VectorGraphMinAffinity,
Layout: e.Cfg.VectorGraphLayout, LayoutRelax: false, LayoutBlend: e.Cfg.VectorGraphLayoutBlend, LayoutMaxShift: e.Cfg.VectorGraphLayoutMaxShift,
OrphanPass: e.Cfg.VectorGraphOrphanPass, OrphanNeighbors: e.Cfg.VectorGraphOrphanNeighbors,
OrphanCandidateLimit: e.Cfg.VectorGraphOrphanCandidates,
OrphanMinSimilarity: e.Cfg.VectorGraphOrphanMinSimilarity, OrphanMinAffinity: e.Cfg.VectorGraphOrphanMinAffinity,
}
}
func (e *Engine) vectorPrimaryConfig(layout bool) vectorgraph.Config {
workers := 1
if settings := e.RuntimeSettings(); settings.SpeedMode {
workers = e.effectiveSpeedCPUWorkers(settings)
}
return vectorgraph.Config{
Workers: workers, Neighbors: e.Cfg.VectorGraphNeighbors, CandidateLimit: e.Cfg.VectorGraphCandidates,
HashBits: e.Cfg.ClusterHashBits, HashTables: e.Cfg.ClusterHashTables, BandBits: 8,
MinSimilarity: e.Cfg.VectorGraphMinSimilarity, MinAffinity: e.Cfg.VectorGraphMinAffinity,
Layout: layout, Smoothing: .22,
}
}
func (e *Engine) vectorOrphanConfig() vectorgraph.Config {
workers := 1
if settings := e.RuntimeSettings(); settings.SpeedMode {
workers = e.effectiveSpeedCPUWorkers(settings)
}
return vectorgraph.Config{
Workers: workers, Neighbors: e.Cfg.VectorGraphOrphanNeighbors, CandidateLimit: e.Cfg.VectorGraphOrphanCandidates,
HashBits: e.Cfg.ClusterHashBits, HashTables: e.Cfg.ClusterHashTables, BandBits: 8,
MinSimilarity: e.Cfg.VectorGraphOrphanMinSimilarity, MinAffinity: e.Cfg.VectorGraphOrphanMinAffinity,
Layout: false,
}
}
func vectorAgentStartupGrace(initialBootstrap bool, configuredWait time.Duration) time.Duration {
if !initialBootstrap || configuredWait <= 0 {
return 0
}
return configuredWait
}
func (e *Engine) checkOnlineVectorComputeAgent(ctx context.Context) (bool, error) {
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return e.SourceInbox.HasOnlineComputeAgent(checkCtx, sourceagent.ComputeKindVectorGraph, 3*time.Minute)
}
func (e *Engine) waitForOnlineVectorComputeAgent(ctx context.Context, wait time.Duration) (bool, error) {
if wait <= 0 {
return e.checkOnlineVectorComputeAgent(ctx)
}
deadline := time.Now().Add(wait)
for {
hasAgent, err := e.checkOnlineVectorComputeAgent(ctx)
if err != nil || hasAgent {
return hasAgent, err
}
remaining := time.Until(deadline)
if remaining <= 0 {
return false, nil
}
delay := time.Second
if remaining < delay {
delay = remaining
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return false, ctx.Err()
case <-timer.C:
}
}
}
func (e *Engine) rebuildVectorSemanticLayer(ctx context.Context, runID string, filter graph.NodeFilter) (vectorLayerExecution, error) {
cfg := e.vectorLayerConfig()
e.stateMu.RLock()
lastVectorLayout := e.lastVectorLayout
e.stateMu.RUnlock()
layoutDue := e.Cfg.VectorGraphLayout || (e.Cfg.VectorGraphRelaxLayout && (lastVectorLayout.IsZero() || time.Since(lastVectorLayout) >= e.Cfg.VectorGraphLayoutRelaxInterval))
cfg.Layout = layoutDue
cfg.LayoutRelax = !e.Cfg.VectorGraphLayout && layoutDue
layoutMode := "off"
if e.Cfg.VectorGraphLayout && layoutDue {
layoutMode = "full"
} else if cfg.LayoutRelax {
layoutMode = "relax"
}
if !e.Cfg.VectorGraphAgentOffload || e.SourceInbox == nil {
primary, orphan, focus := e.Graph.BuildVectorSemanticLayerLocal(cfg, filter)
stats, mutations := e.Graph.ApplyVectorSemanticLayer(cfg, primary, orphan, focus)
e.noteVectorLayoutApplied(layoutDue, stats.PositionUpdates)
return vectorLayerExecution{Stats: stats, Mutations: mutations, LayoutMode: layoutMode, LayoutDue: layoutDue}, nil
}
hasAgent, availabilityErr := e.checkOnlineVectorComputeAgent(ctx)
startupGrace := vectorAgentStartupGrace(!e.bootstrapIsComplete(), e.Cfg.VectorGraphAgentWait)
if e.SpeedModeEnabled() && !e.Cfg.VectorGraphAgentRequired {
startupGrace = 0
}
if availabilityErr == nil && !hasAgent && startupGrace > 0 {
_ = e.requestControllerComputeCapacity(ctx, sourceagent.ComputeKindVectorGraph)
e.Broker.Publish(model.Activity{Type: "vector.graph.agent.waiting", Source: "brain", Phase: "semantic-linking", Message: fmt.Sprintf("Initialer Vector-Graph wartet bis zu %s auf die Compute-Agent-Registrierung", startupGrace), Strength: .34, Metadata: map[string]any{"run_id": runID, "kind": sourceagent.ComputeKindVectorGraph, "startup_grace": startupGrace.String()}})
hasAgent, availabilityErr = e.waitForOnlineVectorComputeAgent(ctx, startupGrace)
}
if availabilityErr != nil || !hasAgent {
reason := "no_compute_agent"
if startupGrace > 0 && availabilityErr == nil {
reason = "no_compute_agent_after_startup_grace"
}
if availabilityErr != nil {
reason = "compute_agent_check_failed: " + availabilityErr.Error()
}
if e.Cfg.VectorGraphAgentRequired {
return vectorLayerExecution{FallbackReason: reason, LayoutMode: layoutMode, LayoutDue: layoutDue}, fmt.Errorf("vector graph agent offload required but unavailable: %s", reason)
}
primary, orphan, focus := e.Graph.BuildVectorSemanticLayerLocal(cfg, filter)
stats, mutations := e.Graph.ApplyVectorSemanticLayer(cfg, primary, orphan, focus)
e.noteVectorLayoutApplied(layoutDue, stats.PositionUpdates)
return vectorLayerExecution{Stats: stats, Mutations: mutations, FallbackReason: reason, LayoutMode: layoutMode, LayoutDue: layoutDue}, nil
}
entries := e.Graph.VectorSemanticEntries(filter)
baseOrphans := e.Graph.KnowledgeOrphanIDsIgnoringOrigin(filter, graph.VectorMathOrigin)
inputVersion := e.Graph.Version()
request := sourceagent.VectorGraphComputeRequest{
Header: sourceagent.VectorGraphComputeHeader{
GraphVersion: inputVersion, Primary: e.vectorPrimaryConfig(layoutDue),
OrphanPass: e.Cfg.VectorGraphOrphanPass, Orphan: e.vectorOrphanConfig(), OrphanFocusIDs: baseOrphans,
},
Entries: entries,
}
e.Broker.Publish(model.Activity{Type: "vector.graph.agent.queued", Source: "brain", Phase: "semantic-linking", Message: fmt.Sprintf("Vector-Graph-CPU-Job für Agent bereitgestellt · %d Embeddings", len(entries)), Strength: .46, Metadata: map[string]any{
"run_id": runID, "kind": sourceagent.ComputeKindVectorGraph, "indexed": len(entries), "orphan_focus_candidates": len(baseOrphans), "graph_version": inputVersion, "cpu_workers": request.Header.Primary.Workers, "speed_mode": e.SpeedModeEnabled(),
}})
jobCtx, cancelJob := context.WithTimeout(ctx, e.Cfg.VectorGraphAgentWait)
result, err := e.SourceInbox.SubmitVectorGraphJob(jobCtx, request)
cancelJob()
if err == nil {
err = validateAgentVectorGraphResult(request, result)
}
if err == nil && e.Graph.Version() != inputVersion {
err = errors.New("graph changed while vector compute job was running")
}
if err != nil {
reason := err.Error()
e.Broker.Publish(model.Activity{Type: "vector.graph.agent.fallback", Source: "brain", Phase: "semantic-linking", Message: "Agent-Vectorjob konnte nicht sicher übernommen werden; lokale CPU-Berechnung wird verwendet", Strength: .38, Metadata: map[string]any{"run_id": runID, "reason": reason, "graph_version": inputVersion}})
if e.Cfg.VectorGraphAgentRequired {
return vectorLayerExecution{FallbackReason: reason, LayoutMode: layoutMode, LayoutDue: layoutDue}, err
}
primary, orphan, focus := e.Graph.BuildVectorSemanticLayerLocal(cfg, filter)
stats, mutations := e.Graph.ApplyVectorSemanticLayer(cfg, primary, orphan, focus)
e.noteVectorLayoutApplied(layoutDue, stats.PositionUpdates)
return vectorLayerExecution{Stats: stats, Mutations: mutations, FallbackReason: reason, LayoutMode: layoutMode, LayoutDue: layoutDue}, nil
}
focus := remainingOrphanFocus(baseOrphans, result.Primary.Links)
stats, mutations := e.Graph.ApplyVectorSemanticLayer(cfg, result.Primary, result.Orphan, focus)
e.noteVectorLayoutApplied(layoutDue, stats.PositionUpdates)
e.Broker.Publish(model.Activity{Type: "vector.graph.agent.completed", Source: "agent", Phase: "semantic-linking", Message: fmt.Sprintf("Agent hat Vector-Graph-CPU-Job abgeschlossen · %d + %d Kanten", len(result.Primary.Links), len(result.Orphan.Links)), Strength: .68, Metadata: withRunMutations(map[string]any{
"run_id": runID, "agent_id": result.AgentID, "compute_duration_ms": result.DurationMS, "indexed": len(entries),
"primary_links": len(result.Primary.Links), "orphan_links": len(result.Orphan.Links), "orphan_focus": len(focus), "no_model_call": true, "cpu_workers": request.Header.Primary.Workers, "speed_mode": e.SpeedModeEnabled(),
}, mutations)})
return vectorLayerExecution{Stats: stats, Mutations: mutations, Offloaded: true, AgentID: result.AgentID, ComputeMS: result.DurationMS, LayoutMode: layoutMode, LayoutDue: layoutDue}, nil
}
func (e *Engine) noteVectorLayoutApplied(layoutDue bool, updates uint64) {
if !layoutDue || updates == 0 {
return
}
e.stateMu.Lock()
e.lastVectorLayout = time.Now().UTC()
e.stateMu.Unlock()
}
func remainingOrphanFocus(base []string, primary []vectorgraph.Link) []string {
focus := make(map[string]bool, len(base))
for _, id := range base {
focus[id] = true
}
for _, link := range primary {
delete(focus, link.Source)
delete(focus, link.Target)
}
out := make([]string, 0, len(focus))
for id := range focus {
out = append(out, id)
}
sort.Strings(out)
return out
}
func validateAgentVectorGraphResult(request sourceagent.VectorGraphComputeRequest, result sourceagent.VectorGraphComputeResult) error {
entries := request.Entries
graphVersion := request.Header.GraphVersion
if result.Kind != sourceagent.ComputeKindVectorGraph {
return fmt.Errorf("unexpected agent compute kind %q", result.Kind)
}
if result.GraphVersion != graphVersion {
return fmt.Errorf("stale agent graph version %d, expected %d", result.GraphVersion, graphVersion)
}
known := make(map[string]bool, len(entries))
for _, entry := range entries {
known[entry.ID] = true
}
validUnit := func(number float64) bool {
return !math.IsNaN(number) && !math.IsInf(number, 0) && number >= 0 && number <= 1.000001
}
validatePositions := func(name string, positions []vectorgraph.Position, allowed bool) error {
if !allowed && len(positions) > 0 {
return fmt.Errorf("%s result contains unexpected positions", name)
}
seen := make(map[string]bool, len(positions))
for _, position := range positions {
if !known[position.ID] || seen[position.ID] {
return fmt.Errorf("%s result contains unknown/duplicate position id", name)
}
seen[position.ID] = true
for _, number := range []float64{position.X, position.Y, position.Z} {
if math.IsNaN(number) || math.IsInf(number, 0) {
return fmt.Errorf("%s result contains invalid position", name)
}
}
}
return nil
}
validateLinks := func(name string, value vectorgraph.Result, maxLinks int, focus map[string]bool) error {
if value.Stats.Indexed != 0 && value.Stats.Indexed != len(entries) {
return fmt.Errorf("%s result indexed %d vectors, expected %d", name, value.Stats.Indexed, len(entries))
}
if maxLinks >= 0 && len(value.Links) > maxLinks {
return fmt.Errorf("%s result contains %d links, maximum expected %d", name, len(value.Links), maxLinks)
}
seen := make(map[string]bool, len(value.Links))
for _, link := range value.Links {
if !known[link.Source] || !known[link.Target] || link.Source == link.Target {
return fmt.Errorf("%s result contains unknown/invalid endpoint", name)
}
key := link.Source + "\x00" + link.Target
if link.Source > link.Target {
key = link.Target + "\x00" + link.Source
}
if seen[key] {
return fmt.Errorf("%s result contains duplicate link", name)
}
seen[key] = true
if focus != nil && !focus[link.Source] && !focus[link.Target] {
return fmt.Errorf("%s result contains link outside orphan focus", name)
}
for _, number := range []float64{link.Similarity, link.Affinity, link.Confidence} {
if !validUnit(number) {
return fmt.Errorf("%s result contains invalid numeric value", name)
}
}
}
return nil
}
primaryK := request.Header.Primary.Neighbors
if primaryK < 1 {
primaryK = 4
}
primaryMax := len(entries) * primaryK
if err := validateLinks("primary", result.Primary, primaryMax, nil); err != nil {
return err
}
if err := validatePositions("primary", result.Primary.Positions, request.Header.Primary.Layout); err != nil {
return err
}
if !request.Header.OrphanPass {
if len(result.Orphan.Links) > 0 || len(result.Orphan.Positions) > 0 {
return errors.New("orphan result returned although orphan pass is disabled")
}
return nil
}
focusIDs := remainingOrphanFocus(request.Header.OrphanFocusIDs, result.Primary.Links)
focus := make(map[string]bool, len(focusIDs))
for _, id := range focusIDs {
focus[id] = true
}
orphanK := request.Header.Orphan.Neighbors
if orphanK < 1 {
orphanK = 4
}
orphanMax := len(focus) * orphanK
if err := validateLinks("orphan", result.Orphan, orphanMax, focus); err != nil {
return err
}
return validatePositions("orphan", result.Orphan.Positions, false)
}