All checks were successful
release-tag / release-image (push) Successful in 2m43s
267 lines
12 KiB
Go
267 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
func TestBuildAutonomousCandidatesPrioritizesContradictionWithoutEvidence(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
snapshot := model.Snapshot{
|
|
Nodes: []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "ZFS Restore", Status: "production", UpdatedAt: now.Add(-400 * 24 * time.Hour), Metadata: map[string]any{"source": "internal-category"}},
|
|
{ID: "b", Kind: "knowledge", Label: "ZFS Key Import", Status: "production", UpdatedAt: now, Metadata: map[string]any{"source": "internal-category"}},
|
|
},
|
|
Edges: []model.Edge{{ID: "e", Source: "a", Target: "b", Type: "contradicts", Status: "accepted"}},
|
|
}
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"internal-category"}}, 8)
|
|
if len(candidates) == 0 {
|
|
t.Fatal("expected a research candidate")
|
|
}
|
|
candidate := candidates[0]
|
|
if candidate.Priority < .75 {
|
|
t.Fatalf("expected contradiction/no-evidence candidate to be high priority, got %.3f", candidate.Priority)
|
|
}
|
|
if len(candidate.SeedNodeIDs) < 2 {
|
|
t.Fatalf("expected related production nodes as seeds, got %#v", candidate.SeedNodeIDs)
|
|
}
|
|
if got := candidate.Signals["contradictions"]; got != 1 {
|
|
t.Fatalf("expected contradiction signal, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousCandidatesHonorsExactThinkingSource(t *testing.T) {
|
|
snapshot := model.Snapshot{Nodes: []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "Allowed", Status: "production", Metadata: map[string]any{"source": "internal-category"}},
|
|
{ID: "b", Kind: "knowledge", Label: "Wrong case", Status: "production", Metadata: map[string]any{"source": "Internal-Category"}},
|
|
}}
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"internal-category"}}, 8)
|
|
for _, candidate := range candidates {
|
|
if candidate.Topic == "Wrong case" {
|
|
t.Fatal("candidate source matching must stay exact and case-sensitive")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousQueryQueueUsesBothLanguagesAndRounds(t *testing.T) {
|
|
queue := buildAutonomousQueryQueue(
|
|
[]string{"Wie wird ein Restore validiert?", "Welche Schlüssel werden benötigt?"},
|
|
[]string{"ZFS Restore validieren", "ZFS Schlüssel importieren", "ZFS Ersatzsystem"},
|
|
[]string{"ZFS restore validation", "ZFS key import"},
|
|
3,
|
|
)
|
|
if len(queue) != 5 {
|
|
t.Fatalf("expected five planned queries, got %d", len(queue))
|
|
}
|
|
languages := map[string]bool{}
|
|
maxRound := 0
|
|
for _, item := range queue {
|
|
languages[item.Language] = true
|
|
if item.Round > maxRound {
|
|
maxRound = item.Round
|
|
}
|
|
}
|
|
if !languages["de-DE"] || !languages["en-US"] {
|
|
t.Fatalf("expected German and English queries, got %#v", languages)
|
|
}
|
|
if maxRound < 2 || maxRound > 3 {
|
|
t.Fatalf("unexpected round assignment: %d", maxRound)
|
|
}
|
|
}
|
|
|
|
func TestAutonomousDedupeKeyIsStableAcrossSeedOrder(t *testing.T) {
|
|
a := autonomousDedupeKey(" ZFS Restore ", []string{"b", "a"})
|
|
b := autonomousDedupeKey("zfs restore", []string{"a", "b"})
|
|
if a != b {
|
|
t.Fatalf("dedupe key should normalize topic spacing/case and seed order: %q != %q", a, b)
|
|
}
|
|
}
|
|
|
|
func TestAutonomousResearchRuntimeAllowedDoesNotDependOnThinking(t *testing.T) {
|
|
settings := RuntimeSettings{AutonomousResearchEnabled: true, ThinkingEnabled: false}
|
|
if !autonomousResearchRuntimeAllowed(settings, true) {
|
|
t.Fatal("autonomous research must remain available when Thinking is disabled")
|
|
}
|
|
if autonomousResearchRuntimeAllowed(settings, false) {
|
|
t.Fatal("autonomous research must still require the research backend")
|
|
}
|
|
settings.AutonomousResearchEnabled = false
|
|
if autonomousResearchRuntimeAllowed(settings, true) {
|
|
t.Fatal("disabled autonomous research must stay disabled")
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousCandidatesPromotesSpecificOrphanCluster(t *testing.T) {
|
|
snapshot := model.Snapshot{Nodes: []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "ZFS Snapshot Restore", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "b", Kind: "knowledge", Label: "ZFS Snapshot Validation", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "c", Kind: "knowledge", Label: "ZFS Snapshot Rollback", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "d", Kind: "knowledge", Label: "Unrelated ZFS Item", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "zfs", Kind: "concept", Label: "ZFS"},
|
|
{ID: "snapshot", Kind: "concept", Label: "Snapshot"},
|
|
}}
|
|
for _, id := range []string{"a", "b", "c", "d"} {
|
|
snapshot.Edges = append(snapshot.Edges, model.Edge{ID: "zfs-" + id, Source: id, Target: "zfs", Type: "mentions", Status: "verified"})
|
|
}
|
|
for _, id := range []string{"a", "b", "c"} {
|
|
snapshot.Edges = append(snapshot.Edges, model.Edge{ID: "snapshot-" + id, Source: id, Target: "snapshot", Type: "mentions", Status: "verified"})
|
|
}
|
|
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"kb"}}, 8)
|
|
if len(candidates) == 0 {
|
|
t.Fatal("expected autonomous candidates")
|
|
}
|
|
var cluster *autonomousCandidate
|
|
for i := range candidates {
|
|
if candidates[i].Signals["signal_type"] == "orphan_cluster" {
|
|
cluster = &candidates[i]
|
|
break
|
|
}
|
|
}
|
|
if cluster == nil {
|
|
t.Fatalf("expected a specific orphan-cluster signal, got %#v", candidates)
|
|
}
|
|
if got := cluster.Signals["cluster_size"]; got != 3 {
|
|
t.Fatalf("expected three-node orphan cluster, got %#v", got)
|
|
}
|
|
if cluster.Priority <= .65 {
|
|
t.Fatalf("expected orphan cluster to be stronger than a single weak orphan, got %.3f", cluster.Priority)
|
|
}
|
|
if len(cluster.SeedNodeIDs) != 3 {
|
|
t.Fatalf("expected only the three nodes sharing both features, got %#v", cluster.SeedNodeIDs)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousCandidatesDoesNotClusterOnSingleBroadFeature(t *testing.T) {
|
|
snapshot := model.Snapshot{Nodes: []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "A", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "b", Kind: "knowledge", Label: "B", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "c", Kind: "knowledge", Label: "C", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "security", Kind: "category", Label: "IT-Security"},
|
|
}}
|
|
for _, id := range []string{"a", "b", "c"} {
|
|
snapshot.Edges = append(snapshot.Edges, model.Edge{ID: "category-" + id, Source: id, Target: "security", Type: "categorized_as", Status: "verified"})
|
|
}
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"kb"}}, 8)
|
|
for _, candidate := range candidates {
|
|
if candidate.Signals["signal_type"] == "orphan_cluster" {
|
|
t.Fatalf("a single shared category must not create an orphan cluster: %#v", candidate)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAutonomousDecisionRejectionCountsExplainsEveryCandidate(t *testing.T) {
|
|
decisions := []autonomousOpportunityDecision{
|
|
{Accepted: true},
|
|
{RejectionReason: "model_not_worthy"},
|
|
{RejectionReason: "priority_below_threshold"},
|
|
{},
|
|
}
|
|
counts := autonomousDecisionRejectionCounts(decisions)
|
|
if counts["accepted"] != 1 || counts["model_not_worthy"] != 1 || counts["priority_below_threshold"] != 1 || counts["unknown"] != 1 {
|
|
t.Fatalf("unexpected rejection summary: %#v", counts)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousCandidatesRejectsTaxonomyTokenNoise(t *testing.T) {
|
|
snapshot := model.Snapshot{Nodes: []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "Noise A", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "b", Kind: "knowledge", Label: "Noise B", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "c", Kind: "knowledge", Label: "Noise C", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "found", Kind: "concept", Label: "Found"},
|
|
{ID: "not", Kind: "concept", Label: "Not"},
|
|
{ID: "permission", Kind: "concept", Label: "Permission"},
|
|
}}
|
|
for _, id := range []string{"a", "b", "c"} {
|
|
for _, feature := range []string{"found", "not", "permission"} {
|
|
snapshot.Edges = append(snapshot.Edges, model.Edge{ID: id + "-" + feature, Source: id, Target: feature, Type: "mentions", Status: "verified"})
|
|
}
|
|
}
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"kb"}}, 8)
|
|
for _, candidate := range candidates {
|
|
if candidate.Signals["signal_type"] == "orphan_cluster" {
|
|
t.Fatalf("token noise must not create an orphan cluster: %#v", candidate)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildAutonomousCandidatesDoesNotChainOrphanComponentsTransitively(t *testing.T) {
|
|
nodes := []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "A", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "b", Kind: "knowledge", Label: "B", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "c", Kind: "knowledge", Label: "C", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "d", Kind: "knowledge", Label: "D", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "e", Kind: "knowledge", Label: "E", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "f", Kind: "knowledge", Label: "F", Status: "production", Metadata: map[string]any{"source": "kb"}},
|
|
{ID: "alpha", Kind: "concept", Label: "AlphaFeature"},
|
|
{ID: "beta", Kind: "concept", Label: "BetaFeature"},
|
|
{ID: "gamma", Kind: "concept", Label: "GammaFeature"},
|
|
{ID: "delta", Kind: "concept", Label: "DeltaFeature"},
|
|
}
|
|
snapshot := model.Snapshot{Nodes: nodes}
|
|
attach := func(ids []string, features ...string) {
|
|
for _, id := range ids {
|
|
for _, feature := range features {
|
|
snapshot.Edges = append(snapshot.Edges, model.Edge{ID: id + "-" + feature, Source: id, Target: feature, Type: "mentions", Status: "verified"})
|
|
}
|
|
}
|
|
}
|
|
// Two legitimate three-node groups share node c/d through different feature
|
|
// pairs. The old connected-component implementation could chain them into one
|
|
// six-node topic; v6 must keep exact shared-feature cores separate.
|
|
attach([]string{"a", "b", "c"}, "alpha", "beta")
|
|
attach([]string{"c", "d", "e"}, "gamma", "delta")
|
|
attach([]string{"f"}, "alpha", "gamma")
|
|
candidates := buildAutonomousCandidates(snapshot, graph.NodeFilter{Sources: []string{"kb"}}, 16)
|
|
maxCluster := 0
|
|
clusterCount := 0
|
|
for _, candidate := range candidates {
|
|
if candidate.Signals["signal_type"] != "orphan_cluster" {
|
|
continue
|
|
}
|
|
clusterCount++
|
|
if size, _ := candidate.Signals["cluster_size"].(int); size > maxCluster {
|
|
maxCluster = size
|
|
}
|
|
if candidate.Signals["cluster_density"] != 1.0 || candidate.Signals["core_feature_coverage"] != 1.0 {
|
|
t.Fatalf("expected cohesive exact-core cluster, got %#v", candidate.Signals)
|
|
}
|
|
}
|
|
if clusterCount < 2 {
|
|
t.Fatalf("expected two separate cohesive clusters, got %d: %#v", clusterCount, candidates)
|
|
}
|
|
if maxCluster > 3 {
|
|
t.Fatalf("transitive chaining created an oversized cluster of %d nodes", maxCluster)
|
|
}
|
|
}
|
|
|
|
func TestAutonomousArticleSynthesisFocusNarrowsMultiErrorClusterToBestEvidence(t *testing.T) {
|
|
task := model.ResearchTask{
|
|
Topic: "CBS / Servicing / Windows Update",
|
|
Questions: []string{
|
|
"Wie wird 0x80242014 diagnostiziert?",
|
|
"Wie wird 0x80D02002 DELIVERY_OPTIMIZATION_TIMEOUT diagnostiziert?",
|
|
},
|
|
}
|
|
seeds := []model.Node{
|
|
{ID: "a", Kind: "knowledge", Label: "Windows Update 0x80242014", Summary: "Post reboot still pending"},
|
|
{ID: "b", Kind: "knowledge", Label: "Delivery Optimization 0x80D02002", Summary: "Download timeout"},
|
|
{ID: "c", Kind: "knowledge", Label: "CBS Servicing", Summary: "Allgemeine CBS Diagnose"},
|
|
}
|
|
evidence := []model.ResearchResult{{Title: "Troubleshoot Windows Update download errors", Content: "The error 0x80D02002 is DELIVERY_OPTIMIZATION_TIMEOUT. Check Delivery Optimization and retry the download.", Relevant: true, Relevance: .97, SourceQuality: "primary", SourceQualityScore: .9}}
|
|
topic, focusedSeeds, focusedEvidence, focused := autonomousArticleSynthesisFocus(task, seeds, evidence)
|
|
if !focused || !strings.Contains(strings.ToLower(topic), "0x80d02002") {
|
|
t.Fatalf("expected evidence-backed focus on 0x80D02002, got focused=%v topic=%q", focused, topic)
|
|
}
|
|
if len(focusedEvidence) != 1 {
|
|
t.Fatalf("expected matching evidence to remain attached, got %#v", focusedEvidence)
|
|
}
|
|
if len(focusedSeeds) == 0 || focusedSeeds[0].ID != "b" {
|
|
t.Fatalf("expected matching error-code seed first, got %#v", focusedSeeds)
|
|
}
|
|
}
|