package capacity import ( "context" "errors" "sync" "testing" "time" ) // stubClassifier is a deterministic IntentClassifier: it records every text it // was asked about so a test can pin whether L3 ran at all (裁决 14.4.4 的 // CAPACITY_L3_ENABLED 默认关). type stubClassifier struct { mu sync.Mutex seen []string verdict Judgment err error calls int } func (s *stubClassifier) Classify(_ context.Context, text string) (Judgment, error) { s.mu.Lock() defer s.mu.Unlock() s.calls++ s.seen = append(s.seen, text) if s.err != nil { return Judgment{}, s.err } return s.verdict, nil } func (s *stubClassifier) callCount() int { s.mu.Lock() defer s.mu.Unlock() return s.calls } func l3Config(c IntentClassifier, ttl time.Duration) Config { cfg := activeConfig() cfg.L3Enabled = true cfg.L3Classifier = c cfg.L3CacheTTL = ttl return cfg } // TestDecide_L3Disabled_ClassifierNeverRuns pins the default-off red line: with // CAPACITY_L3_ENABLED false the classifier must never be consulted, even when one // is configured, so L1/L2/L4 behavior is unchanged. func TestDecide_L3Disabled_ClassifierNeverRuns(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} cfg := activeConfig() cfg.L3Classifier = stub // configured but not enabled cfg.L3CacheTTL = time.Minute // A turn no heuristic can decide: L1 needs a task verb + object, L2 needs a // leaning window. "那件事后来如何" carries no signal at all. dec := New(cfg).Decide(context.Background(), in("那件事后来如何")) if stub.callCount() != 0 { t.Fatalf("classifier calls = %d, want 0 while L3 is disabled", stub.callCount()) } if dec.State != StateQuiet { t.Fatalf("state = %s, want quiet (conservative default)", dec.State) } } // TestDecide_L3AwakensUndecidedTurn pins that an enabled L3 layer turns an // otherwise undecided turn into a wake with the classifier's own confidence, and // advertises the awake tool set. func TestDecide_L3AwakensUndecidedTurn(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: "query", Confidence: 0.82}} dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) if stub.callCount() != 1 { t.Fatalf("classifier calls = %d, want 1", stub.callCount()) } if dec.State != StateAwake { t.Fatalf("state = %s, want awake (L3 verdict)", dec.State) } if dec.Confidence != 0.82 { t.Fatalf("confidence = %v, want the classifier's 0.82", dec.Confidence) } if dec.Judgment.IsTask != true { t.Errorf("judgment = %+v, want the classifier's isTask=true written back for L4", dec.Judgment) } if !contains(dec.AllowedTools, "web-search") { t.Errorf("allowed = %v, want the awake set including web-search", dec.AllowedTools) } } // TestDecide_L3ComplexKindWakesFull pins the quantified gate's upper branch: a // "complex" task kind maps to the full state rather than awake. func TestDecide_L3ComplexKindWakesFull(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: taskKindComplex, Confidence: 0.9}} dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) if dec.State != StateFull { t.Fatalf("state = %s, want full for a complex task kind", dec.State) } } // TestDecide_L3LowConfidenceIsUndecided pins the conservative gate: a verdict the // model is unsure about must not wake anything, and must not be written back as a // task either (the L4 write-back feeds the learning loop, so a hesitant verdict // recorded as a task would teach the wrong habit). func TestDecide_L3LowConfidenceIsUndecided(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.3}} dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) if stub.callCount() != 1 { t.Fatalf("classifier calls = %d, want 1", stub.callCount()) } if dec.State != StateQuiet { t.Fatalf("state = %s, want quiet (below-threshold verdict is undecided)", dec.State) } if dec.Judgment.IsTask { t.Fatalf("judgment = %+v, want isTask=false (a below-threshold verdict is not a task)", dec.Judgment) } } // TestDecide_L3ClassifierErrorKeepsConservativeDefault pins that a model failure // is reported as "no verdict" rather than crashing or waking tools. func TestDecide_L3ClassifierErrorKeepsConservativeDefault(t *testing.T) { stub := &stubClassifier{err: errors.New("upstream down")} dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何")) if dec.State != StateQuiet { t.Fatalf("state = %s, want quiet after a classifier error", dec.State) } } // TestDecide_L3CacheReusesVerdict pins the L3 output cache (裁决 14.4.4): the same // turn text costs one model call inside the TTL, and a different turn still costs // its own call. func TestDecide_L3CacheReusesVerdict(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} d := New(l3Config(stub, time.Minute)) ctx := context.Background() for i := 0; i < 3; i++ { if dec := d.Decide(ctx, in("那件事后来如何")); dec.State != StateAwake { t.Fatalf("call %d state = %s, want awake", i, dec.State) } } if got := stub.callCount(); got != 1 { t.Fatalf("classifier calls = %d, want 1 (cached verdict reused)", got) } d.Decide(ctx, in("那件事后来如何?")) if got := stub.callCount(); got != 2 { t.Fatalf("classifier calls = %d, want 2 (different text is not cached)", got) } } // TestL3Cache_ExpiredEntryIsReclassified pins the TTL bound itself. func TestL3Cache_ExpiredEntryIsReclassified(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} c := newL3Cache(stub, time.Minute) ctx := context.Background() if _, err := c.classify(ctx, "那件事后来如何"); err != nil { t.Fatalf("classify: %v", err) } // Backdate the stored entry past the TTL. c.mu.Lock() for k, e := range c.entries { e.at = time.Now().Add(-2 * time.Minute) c.entries[k] = e } c.mu.Unlock() if _, err := c.classify(ctx, "那件事后来如何"); err != nil { t.Fatalf("classify: %v", err) } if got := stub.callCount(); got != 2 { t.Fatalf("classifier calls = %d, want 2 (expired entry reclassified)", got) } } // TestL3Cache_EmptyTextIsNotClassified pins that an empty turn is a sentinel // error rather than a model call. func TestL3Cache_EmptyTextIsNotClassified(t *testing.T) { stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}} c := newL3Cache(stub, time.Minute) if _, err := c.classify(context.Background(), " "); !errors.Is(err, ErrEmptyTurn) { t.Fatalf("err = %v, want ErrEmptyTurn", err) } if stub.callCount() != 0 { t.Fatalf("classifier calls = %d, want 0", stub.callCount()) } } // TestParseL3Judgment pins the wire parsing: a fenced JSON reply is accepted, a // prose reply is an error, and a hallucinated confidence is clamped. func TestParseL3Judgment(t *testing.T) { ok, err := parseL3Judgment("```json\n{\"isTask\":true,\"taskKind\":\"query\",\"confidence\":0.7}\n```") if err != nil { t.Fatalf("parseL3Judgment: %v", err) } if !ok.IsTask || ok.TaskKind != "query" || ok.Confidence != 0.7 { t.Fatalf("parsed = %+v, want isTask=true taskKind=query confidence=0.7", ok) } if _, err := parseL3Judgment("我觉得这是一个任务"); err == nil { t.Fatal("prose reply parsed without error, want failure") } if got := clampUnit(7.5); got != 1 { t.Errorf("clampUnit(7.5) = %v, want 1", got) } }