610 lines
27 KiB
Go
610 lines
27 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// content_routing_test.go drives the content-label mechanism through the REAL wave driver (rule 11:
|
||
// sandbox reproductions through the driver, not diff reading). Every label VALUE here is synthetic —
|
||
// if the engine knew "adult", these tests would not be able to prove genericity.
|
||
//
|
||
// The scenarios are the ones the pack owes (D39.26 point 14): a load refusal for a model that may not
|
||
// receive the label, the runtime assert firing BEFORE money moves, refusal→hop→OK→re-gate, refusal with
|
||
// a refusing hop → flag+skip, the read path staying alive on a book whose routing is not runnable, and
|
||
// the rate guard reaching the label-resolved model.
|
||
|
||
const synthLabel = "restricted-synth"
|
||
|
||
// labelled is the fixture knob set for a routable labelled project: the endpoint accepts the synthetic
|
||
// label, the book declares it, the registry routes it, and the draft stage's label model + the chain
|
||
// head are both the fixture's fallback slug (a distinct model from the unlabelled primary).
|
||
func labelledRouting() *labelRouting {
|
||
return &labelRouting{
|
||
acceptsLabels: []string{synthLabel},
|
||
bookLabels: []string{synthLabel},
|
||
policy: synthLabel,
|
||
// BOTH differ from the configured pair (model: fake-model, escalate_to: fake-fallback) on purpose:
|
||
// with the label routing as an identity map, reverting the resolved-model wiring in stagerun /
|
||
// escalation / snapshot would leave every test green — the mechanism's load-bearing half unguarded.
|
||
labelModel: "fake-label",
|
||
chainModel: "fake-labelhop",
|
||
}
|
||
}
|
||
|
||
// TestLabelledLoadRefusesIncapableModel is rubezh 1: a book carrying a label the endpoint does not
|
||
// accept must not load for the money path, and the message must name the model, its provider and the
|
||
// MISSING label (the operator fixes a data row, so the row has to be identified).
|
||
func TestLabelledLoadRefusesIncapableModel(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
|
||
// The book declares the label; the provider accepts NOTHING → every reachable model is incapable.
|
||
lbl := labelledRouting()
|
||
lbl.acceptsLabels = nil
|
||
bookPath := setupEscalationProject(t, srv.URL, 1.0, lbl)
|
||
|
||
_, err := NewRunner(bookPath, obs.NewLogger())
|
||
if err == nil {
|
||
t.Fatal("a labelled book whose models may not receive the label must NOT load for translate/redrive")
|
||
}
|
||
for _, want := range []string{"may not receive content label", synthLabel, "fake", "accepts_labels"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Errorf("load error must mention %q, got: %v", want, err)
|
||
}
|
||
}
|
||
if rec.count() != 0 {
|
||
t.Errorf("a refused load must make zero provider calls, got %d", rec.count())
|
||
}
|
||
}
|
||
|
||
// TestLabelledReadPathStaysAlive is D39.26 point 9: the SAME unroutable book must remain readable
|
||
// through the $0 projections. Otherwise the only way to inspect a book labelled after it was paid for
|
||
// is to strip the label — the silent bypass the mechanism exists to prevent.
|
||
func TestLabelledReadPathStaysAlive(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
|
||
lbl := labelledRouting()
|
||
lbl.acceptsLabels = nil
|
||
bookPath := setupEscalationProject(t, srv.URL, 1.0, lbl)
|
||
|
||
r, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatalf("read-only projections must survive an unroutable label set (D20.4): %v", err)
|
||
}
|
||
defer r.Close()
|
||
rep, err := r.Status(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("status on an unroutable labelled book must work: %v", err)
|
||
}
|
||
// The reason must travel WITH the projection, not only in a log line the operator may never see.
|
||
if len(rep.ContentLabels) != 1 || rep.ContentLabels[0] != synthLabel {
|
||
t.Errorf("status must report the book's labels, got %v", rep.ContentLabels)
|
||
}
|
||
if len(rep.Routing) != 2 {
|
||
t.Errorf("status must report one routing row per stage, got %v", rep.Routing)
|
||
}
|
||
if len(rep.ContentRoutingProblems) == 0 {
|
||
t.Error("status must carry the routing problems of an unroutable labelled book")
|
||
}
|
||
if rec.count() != 0 {
|
||
t.Errorf("read-only path must make zero provider calls, got %d", rec.count())
|
||
}
|
||
}
|
||
|
||
// TestLabelledRuntimeAssertPrecedesMoney is rubezh 2. The load-time gate is BYPASSED on purpose (the
|
||
// runner is built unlabelled, then the book's labels are set post-construction, exactly the trick the
|
||
// repair tests use to isolate a gate) so the assert inside runAttempt is the only thing left. It must
|
||
// fire on a FRESH call, be identifiable as an error, and leave the ledger untouched.
|
||
func TestLabelledRuntimeAssertPrecedesMoney(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupEscalationProject(t, srv.URL, 1.0, nil) // loads clean: no labels declared anywhere
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
// A label nobody declared as accepted, injected AFTER validation.
|
||
r.Book.ContentLabels = []string{synthLabel}
|
||
|
||
_, err := r.TranslateBook(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), "refusing to send content labelled") {
|
||
t.Fatalf("the runtime assert must refuse a model that may not receive the label, got: %v", err)
|
||
}
|
||
if rec.count() != 0 {
|
||
t.Errorf("the assert must fire BEFORE the provider call, got %d call(s)", rec.count())
|
||
}
|
||
committed, reserved, serr := r.Store.SpentUSD(r.Book.BookID)
|
||
if serr != nil {
|
||
t.Fatal(serr)
|
||
}
|
||
if committed != 0 || reserved != 0 {
|
||
t.Errorf("the assert must fire BEFORE Reserve: committed=%v reserved=%v, want 0/0", committed, reserved)
|
||
}
|
||
}
|
||
|
||
// TestLabelledRefusalEscalatesThroughChainAndRegates is the refusal cascade the repo never had (every
|
||
// prior "hop cures it" test used cjk_artifact): the label-routed primary REFUSES, the hop comes from the
|
||
// LABEL'S CHAIN, its answer is re-gated and ships. Money is attributed to the hop.
|
||
func TestLabelledRefusalEscalatesThroughChainAndRegates(t *testing.T) {
|
||
rec := &reqRec{}
|
||
// The primary refuses in the provider's own voice (finish_reason=refusal → hard_refusal, a
|
||
// deterministic content failure); the chain head answers cleanly. No 18+ text anywhere — the
|
||
// marker-proxy is the model slug in the request body.
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isEditBody(body) {
|
||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||
}
|
||
if strings.Contains(body, "fake-labelhop") { // the LABEL's chain head, not the configured escalate_to
|
||
return "Тихое утро в библиотеке.", "stop"
|
||
}
|
||
return "Не могу помочь с этим фрагментом.", "refusal"
|
||
})
|
||
defer srv.Close()
|
||
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, labelledRouting()))
|
||
defer r.Close()
|
||
res, err := r.TranslateBook(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("translate: %v", err)
|
||
}
|
||
if len(res.Chunks) != 1 {
|
||
t.Fatalf("want 1 unit, got %d", len(res.Chunks))
|
||
}
|
||
oc := res.Chunks[0]
|
||
if oc.Disposition != DispOK {
|
||
t.Fatalf("the re-gated hop answer must ship: disposition=%s flag=%s", oc.Disposition, oc.FlagReason)
|
||
}
|
||
draft := oc.Stages[0]
|
||
if !draft.Escalated || draft.EscalationModel != "fake-labelhop" {
|
||
t.Errorf("the hop must come from the LABEL's chain head (fake-labelhop), NOT the configured escalate_to (fake-fallback): escalated=%v model=%q", draft.Escalated, draft.EscalationModel)
|
||
}
|
||
// The WIRE is the only witness that the resolved model actually reached the provider (the mock
|
||
// canonicalises every response's model field, so modelActual cannot serve): the first draft body must
|
||
// carry the label's model and the second the label's chain head, and the configured pair must appear
|
||
// nowhere. This is what makes a revert of the ResolvedModel/ResolvedHop wiring RED.
|
||
bodies := rec.all()
|
||
if len(bodies) < 2 {
|
||
t.Fatalf("want at least 2 request bodies, got %d", len(bodies))
|
||
}
|
||
if !strings.Contains(bodies[0], `"model":"fake-label"`) {
|
||
t.Errorf("the first draft call must go to the LABEL's model: %s", bodies[0])
|
||
}
|
||
if !strings.Contains(bodies[1], `"model":"fake-labelhop"`) {
|
||
t.Errorf("the hop must go to the LABEL's chain head: %s", bodies[1])
|
||
}
|
||
for i, b := range bodies[:2] {
|
||
if strings.Contains(b, `"model":"fake-model"`) || strings.Contains(b, `"model":"fake-fallback"`) {
|
||
t.Errorf("draft call %d went to a CONFIGURED model although the label re-routes both legs: %s", i, b)
|
||
}
|
||
}
|
||
// And the converse, which is the whole point of the per-stage substitution (D39.26 point 1): the EDIT
|
||
// stage carries no label_models entry here, so it stays on its CONFIGURED model — a label re-routes
|
||
// exactly what it claims, never a leg it was not pointed at.
|
||
if len(bodies) > 2 && !strings.Contains(bodies[2], `"model":"fake-model"`) {
|
||
t.Errorf("the edit stage has no label route, so it must stay on its configured model: %s", bodies[2])
|
||
}
|
||
// primary refusal + hop + edit = 3 calls (regenerate_before_escalate: 0, so no same-model retry of a
|
||
// deterministic refusal — the ladder semantics are unchanged).
|
||
if rec.count() != 3 {
|
||
t.Errorf("want 3 provider calls (primary refusal → chain hop → edit), got %d", rec.count())
|
||
}
|
||
spent, err := r.Store.EscalationSpentUSD(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if spent <= 0 {
|
||
t.Errorf("the hop must be booked as escalation spend, got %v", spent)
|
||
}
|
||
}
|
||
|
||
// TestLabelledRefusalWithRefusingHopFlagsAndSkips is the terminal branch: a refusal ANY provider repeats
|
||
// stays flag+skip (D2) — no new "18+ refusal" class, no retry of a deterministic refusal, and the edit
|
||
// stage is skipped so nothing garbage reaches TM/export.
|
||
func TestLabelledRefusalWithRefusingHopFlagsAndSkips(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isEditBody(body) {
|
||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||
}
|
||
return "Не могу помочь с этим фрагментом.", "refusal" // primary AND hop refuse
|
||
})
|
||
defer srv.Close()
|
||
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, labelledRouting()))
|
||
defer r.Close()
|
||
res, err := r.TranslateBook(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("a refusal is a disposition, never a run error: %v", err)
|
||
}
|
||
oc := res.Chunks[0]
|
||
if oc.Disposition != DispFlagged || oc.FlagReason != FlagHardRefusal {
|
||
t.Fatalf("want flagged/hard_refusal, got %s/%s", oc.Disposition, oc.FlagReason)
|
||
}
|
||
if oc.FinalText != "" {
|
||
t.Errorf("a flagged unit must ship nothing (no garbage into TM/export), got %q", oc.FinalText)
|
||
}
|
||
if len(oc.Stages) < 2 || oc.Stages[1].Disposition != DispSkipped {
|
||
t.Errorf("the edit stage must be SKIPPED after a flagged draft, got %+v", oc.Stages)
|
||
}
|
||
if rec.count() != 2 {
|
||
t.Errorf("want exactly 2 calls (primary + one hop, never a retry of a deterministic refusal), got %d", rec.count())
|
||
}
|
||
if res.Flagged != 1 || res.ExitCode() != 2 {
|
||
t.Errorf("want flagged=1 exit=2, got flagged=%d exit=%d", res.Flagged, res.ExitCode())
|
||
}
|
||
}
|
||
|
||
// TestLabelledRoutingReachesGuardsAndSnapshot is the positive assertion the acceptance добор C asks for
|
||
// (a rate guard degrades SILENTLY into "unlimited", unlike r.clientFor which is loud): the label-resolved
|
||
// models must be in the eager client set AND own a rate guard. It also pins that the resolved model —
|
||
// not the configured one — is what the snapshot folds.
|
||
func TestLabelledRoutingReachesGuardsAndSnapshot(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, labelledRouting()))
|
||
defer r.Close()
|
||
// The precompute pass is what builds both sets; TranslateBook runs it, so a test that inspects them
|
||
// without translating must run it too (idempotent).
|
||
if err := r.buildClients(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r.buildRateGuards()
|
||
|
||
if got := r.Pipeline.Stages[0].ResolvedModel; got != "fake-label" {
|
||
t.Fatalf("draft must resolve to the label model, got %q", got)
|
||
}
|
||
for _, m := range r.Pipeline.ReachableModels() {
|
||
if _, ok := r.clients[m]; !ok {
|
||
t.Errorf("reachable model %q has no pre-built client (the wave would fail mid-run)", m)
|
||
}
|
||
if r.rateGuard(m) == nil {
|
||
t.Errorf("reachable model %q has no rate guard — a nil guard degrades SILENTLY to unlimited, unlike the loud client lookup (D39.26 добор C)", m)
|
||
}
|
||
}
|
||
snapID, payload, err := r.snapshotIDForWave(waveDraft)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(payload, `"model":"fake-label"`) {
|
||
t.Errorf("the draft snapshot must fold the RESOLVED model (the assigned snapshot layer for labels, D39.26 point 1): %s", payload)
|
||
}
|
||
// The HOP slot too: without this a revert of the escalate_to fold would keep the CONFIGURED fallback in
|
||
// the snapshot while the run calls another model — a resumed escalated chunk would then serve a
|
||
// checkpoint paid on a different hop (the silent false-hit the fold exists to prevent).
|
||
if !strings.Contains(payload, `"escalate_to":"fake-labelhop"`) {
|
||
t.Errorf("the draft snapshot must fold the RESOLVED hop: %s", payload)
|
||
}
|
||
if snapID == "" {
|
||
t.Error("snapshot id must render")
|
||
}
|
||
}
|
||
|
||
// TestLabelledBookWithoutLabelsIsUnchanged is the byte-identity guard in behavioural form: writing the
|
||
// label DATA into a project the book does not claim must leave routing exactly where the config put it.
|
||
func TestLabelledBookWithoutLabelsIsUnchanged(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
|
||
lbl := labelledRouting()
|
||
lbl.bookLabels = nil // registry + capability declared, but the BOOK claims no label
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, lbl))
|
||
defer r.Close()
|
||
|
||
st := r.Pipeline.Stages[0]
|
||
if st.ResolvedModel != st.Model || st.ResolvedHop != st.EscalateTo {
|
||
t.Fatalf("an unlabelled book must resolve to its configured models, got (%q,%q) want (%q,%q)",
|
||
st.ResolvedModel, st.ResolvedHop, st.Model, st.EscalateTo)
|
||
}
|
||
if len(r.labelsFor()) != 0 {
|
||
t.Errorf("labelsFor must be empty for an unlabelled book, got %v", r.labelsFor())
|
||
}
|
||
}
|
||
|
||
// TestRetiredAdultKeyForms pins the three shapes of the retired book key: `true` is a migration error
|
||
// naming the replacement, while `false` and an ABSENT key both load. Tolerance is not a convenience —
|
||
// 13 book-shaped files outside git carry `adult: false`, and the golden fixture omits the key entirely,
|
||
// so neither form is exercised by any other test.
|
||
func TestRetiredAdultKeyForms(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
base := setupEscalationProject(t, srv.URL, 1.0, nil)
|
||
|
||
write := func(t *testing.T, line string) string {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(base)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dir := t.TempDir()
|
||
body := strings.Replace(string(raw), "pipeline: pipeline.yaml", line+"pipeline: "+filepath.Join(filepath.Dir(base), "pipeline.yaml"), 1)
|
||
body = strings.Replace(body, "models: models.yaml", "models: "+filepath.Join(filepath.Dir(base), "models.yaml"), 1)
|
||
body = strings.Replace(body, "source_file: source.txt", "source_file: "+filepath.Join(filepath.Dir(base), "source.txt"), 1)
|
||
p := filepath.Join(dir, "book.yaml")
|
||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
if _, err := NewRunner(write(t, "adult: true\n"), obs.NewLogger()); err == nil || !strings.Contains(err.Error(), "content_labels") {
|
||
t.Errorf("`adult: true` must fail loud naming content_labels, got: %v", err)
|
||
}
|
||
for _, line := range []string{"adult: false\n", ""} {
|
||
r, err := NewRunner(write(t, line), obs.NewLogger())
|
||
if err != nil {
|
||
t.Errorf("book with %q must load (the key is retired, its zero value is tolerated): %v", strings.TrimSpace(line), err)
|
||
continue
|
||
}
|
||
if len(r.Book.ContentLabels) != 0 {
|
||
t.Errorf("book with %q must carry no labels, got %v", strings.TrimSpace(line), r.Book.ContentLabels)
|
||
}
|
||
r.Close()
|
||
}
|
||
}
|
||
|
||
// The single-egress invariant this file used to guard by scanning bytes for `.Complete(` now lives in
|
||
// internal/archguard (egressseam). It reports a call that carries the llm.LLMRequest → llm.LLMResponse
|
||
// pair whatever the callee is called, OR one spelled Complete against internal/llm — so it tells our
|
||
// client's method from any other type's method of that name, which the byte scan could not, and it no
|
||
// longer has to exempt its own source from matching its own needle.
|
||
|
||
// TestClientForRefusesUnknownModel keeps the pre-existing loud behaviour of the client lookup intact:
|
||
// the label assert is an ADDITION to it, not a replacement (an un-enumerated model is still an error,
|
||
// not a lazy build under a race).
|
||
func TestClientForRefusesUnknownModel(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, nil))
|
||
defer r.Close()
|
||
if err := r.buildClients(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := r.clientFor("never-enumerated", nil); err == nil || !strings.Contains(err.Error(), "no pre-built client") {
|
||
t.Fatalf("an un-enumerated model must stay a loud error, got: %v", err)
|
||
}
|
||
if _, err := r.clientFor("fake-model", nil); err != nil {
|
||
t.Fatalf("an enumerated model with no labels wanted must resolve: %v", err)
|
||
}
|
||
if _, err := r.clientFor("fake-model", []string{synthLabel}); err == nil || !strings.Contains(err.Error(), "refusing to send content labelled") {
|
||
t.Fatalf("a label the provider does not accept must be refused, got: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSourceEchoExposureWarnsGenerically covers the echo half of the reasoning-off closure (D39.26 добор
|
||
// B). It is a WARNING by design, so the test asserts the PREDICATE rather than a log line: a stage that
|
||
// ships the source to a thinking-suppressing model on a CJK source is reported, and the three ways out
|
||
// (a model that does not suppress, a stage that does not render the source, a non-CJK source) are not.
|
||
// Reverting ThinksOnWire to "reasoning != off" makes the suppressing case stop being reported.
|
||
func TestSourceEchoExposureWarnsGenerically(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, nil))
|
||
defer r.Close()
|
||
|
||
// Baseline: the fixture's provider has no off-switch capability, so reasoning "off" is a NO-OP —
|
||
// thinking stays on and nothing is exposed (this is the deepseek shape of the shipping stack).
|
||
if got := r.sourceEchoExposure(); len(got) != 0 {
|
||
t.Fatalf("a model with no off-switch does not suppress thinking, want no exposure, got %v", got)
|
||
}
|
||
|
||
// Give the model a real off-switch: now reasoning "off" reaches the wire and both source-bearing
|
||
// stages (translator AND the bilingual editor — D30.1) are exposed.
|
||
mod := r.Models.Models["fake-model"]
|
||
mod.Capabilities = &config.CapabilitiesConfig{Reasoning: &config.ReasoningCapCfg{Control: "effort", OffEffort: "none"}}
|
||
r.Models.Models["fake-model"] = mod
|
||
got := r.sourceEchoExposure()
|
||
if len(got) != 2 {
|
||
t.Fatalf("both source-bearing stages must be reported (the editor is bilingual since D30.1), got %v", got)
|
||
}
|
||
for _, want := range []string{"draft→fake-model", "edit→fake-model"} {
|
||
if !strings.Contains(strings.Join(got, ","), want) {
|
||
t.Errorf("exposure %v must contain %q", got, want)
|
||
}
|
||
}
|
||
|
||
// A non-CJK source is out of the measured class → no warning, no noise.
|
||
r.Book.SourceLang = "en"
|
||
if got := r.sourceEchoExposure(); len(got) != 0 {
|
||
t.Errorf("a non-CJK source must not be reported, got %v", got)
|
||
}
|
||
}
|
||
|
||
// TestRoutingMoneyProvenanceIsDerived covers the two read-only aggregates B6 added (EscalationHops,
|
||
// SpendByModel): both answer "what did the label-routed endpoint cost" without a schema migration and
|
||
// without a synthetic call class, so both are derived from durable checkpoints — and both were otherwise
|
||
// untested, i.e. a future column rename would turn the money report into a silent zero.
|
||
func TestRoutingMoneyProvenanceIsDerived(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isEditBody(body) {
|
||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||
}
|
||
if strings.Contains(body, "fake-labelhop") {
|
||
return "Тихое утро в библиотеке.", "stop"
|
||
}
|
||
return "Не могу помочь с этим фрагментом.", "refusal"
|
||
})
|
||
defer srv.Close()
|
||
|
||
r := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, labelledRouting()))
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("translate: %v", err)
|
||
}
|
||
|
||
hops, err := r.Store.EscalationHops(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if hops != 1 {
|
||
t.Errorf("EscalationHops = %d, want 1 — the per-unit `escalated` boolean cannot count hops, which is why this exists", hops)
|
||
}
|
||
byModel, err := r.Store.SpendByModel(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// One line per model the run actually paid: the label's primary (refused, still billed), the label's
|
||
// chain head (the hop) and the configured editor the label does not re-route.
|
||
for _, want := range []string{"fake-label", "fake-labelhop", "fake-model"} {
|
||
if byModel[want] <= 0 {
|
||
t.Errorf("SpendByModel is missing a positive line for %q: %v", want, byModel)
|
||
}
|
||
}
|
||
if _, unexpected := byModel["fake-fallback"]; unexpected {
|
||
t.Errorf("the configured escalate_to was substituted by the label chain and never called, so it must not appear: %v", byModel)
|
||
}
|
||
// The quality report is the surface that ships them; it must agree with the store.
|
||
q, err := r.QualityReport()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if q.EscalationHops != hops || len(q.SpendByModel) != len(byModel) {
|
||
t.Errorf("quality report money provenance diverged from the store: hops=%d/%d models=%d/%d",
|
||
q.EscalationHops, hops, len(q.SpendByModel), len(byModel))
|
||
}
|
||
if len(q.ContentLabels) != 1 || len(q.Routing) != 2 {
|
||
t.Errorf("quality report must carry the label provenance, got labels=%v routing=%v", q.ContentLabels, q.Routing)
|
||
}
|
||
}
|
||
|
||
// TestEditorOnlyLabelLeavesTheDraftWaveUntouched is the MONEY claim of D39.26 point 1, asserted on the
|
||
// artefact that decides it: a label that re-routes ONLY the editor must leave the DRAFT-wave snapshot id
|
||
// byte-identical to the unlabelled book's, so an already-paid draft still resumes at $0. An earlier
|
||
// version of this pack substituted the label's chain hop on every escalating stage, which silently moved
|
||
// the draft-wave snapshot (its folded escalate_to changed) and re-billed a wave nobody asked to change.
|
||
func TestEditorOnlyLabelLeavesTheDraftWaveUntouched(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, echoOrClean)
|
||
defer srv.Close()
|
||
|
||
plain := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, nil))
|
||
defer plain.Close()
|
||
plainDraft, _, err := plain.snapshotIDForWave(waveDraft)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
plainEdit, _, err := plain.snapshotIDForWave(waveEdit)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
lbl := labelledRouting()
|
||
lbl.labelModel = "" // the label claims the EDITOR only
|
||
lbl.editModel = "fake-label"
|
||
labelled := newRunner(t, setupEscalationProject(t, srv.URL, 1.0, lbl))
|
||
defer labelled.Close()
|
||
if got := labelled.Pipeline.Stages[0].ResolvedHop; got != "fake-fallback" {
|
||
t.Errorf("the draft stage keeps its CONFIGURED fallback when the label does not claim it, got %q", got)
|
||
}
|
||
if got := labelled.Pipeline.Stages[1].ResolvedModel; got != "fake-label" {
|
||
t.Fatalf("the edit stage must resolve to the label model, got %q", got)
|
||
}
|
||
labelledDraft, _, err := labelled.snapshotIDForWave(waveDraft)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
labelledEdit, _, err := labelled.snapshotIDForWave(waveEdit)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if labelledDraft != plainDraft {
|
||
t.Errorf("an editor-only label moved the DRAFT-wave snapshot (%.12s → %.12s) — the paid draft would be re-billed, against D39.26 point 1", plainDraft, labelledDraft)
|
||
}
|
||
if labelledEdit == plainEdit {
|
||
t.Errorf("the EDIT-wave snapshot must move (%.12s) — the editor is called on another model, so its checkpoints are not the same calls", labelledEdit)
|
||
}
|
||
}
|
||
|
||
// TestAssertOnlyLabelRunsWithoutMovingAnything drives the third policy shape through the REAL driver: a
|
||
// book whose label only ASSERTS must run exactly like the unlabelled one — same wire, same snapshots,
|
||
// same money — while the capability invariant still guards every call. It needs no escalation budget and
|
||
// no chain, which is the whole reason the shape exists.
|
||
func TestAssertOnlyLabelRunsWithoutMovingAnything(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
|
||
plain := newRunner(t, setupEscalationProject(t, srv.URL, 0, nil))
|
||
plainDraft, _, err := plain.snapshotIDForWave(waveDraft)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
plainEdit, _, err := plain.snapshotIDForWave(waveEdit)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
plainRes, err := plain.TranslateBook(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("unlabelled translate: %v", err)
|
||
}
|
||
plainBodies := append([]string(nil), rec.all()...)
|
||
plain.Close()
|
||
|
||
// Same project, plus a label whose policy only asserts (no chain, no label_models, budget 0).
|
||
rec2 := &reqRec{}
|
||
srv2 := newJSONProvider(rec2, draftEdit)
|
||
defer srv2.Close()
|
||
r := newRunner(t, setupEscalationProject(t, srv2.URL, 0, &labelRouting{
|
||
acceptsLabels: []string{synthLabel},
|
||
bookLabels: []string{synthLabel},
|
||
policy: synthLabel,
|
||
action: "allow",
|
||
}))
|
||
defer r.Close()
|
||
res, err := r.TranslateBook(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("an asserting label must not disturb the run: %v", err)
|
||
}
|
||
if res.Chunks[0].Disposition != DispOK || res.TotalUSD != plainRes.TotalUSD {
|
||
t.Errorf("run diverged: disposition=%s cost=%v want ok/%v", res.Chunks[0].Disposition, res.TotalUSD, plainRes.TotalUSD)
|
||
}
|
||
// The wire must be identical body for body — an assertion is not a wire change.
|
||
bodies := rec2.all()
|
||
if len(bodies) != len(plainBodies) {
|
||
t.Fatalf("call count changed: %d vs %d", len(bodies), len(plainBodies))
|
||
}
|
||
for i := range bodies {
|
||
if bodies[i] != plainBodies[i] {
|
||
t.Errorf("call %d body differs under an asserting label:\n got %s\nwant %s", i, bodies[i], plainBodies[i])
|
||
}
|
||
}
|
||
// …and so must both snapshots: an asserting label moves NO route, so it can re-bill nothing.
|
||
draft, _, err := r.snapshotIDForWave(waveDraft)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
edit, _, err := r.snapshotIDForWave(waveEdit)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if draft != plainDraft || edit != plainEdit {
|
||
t.Errorf("an asserting label moved a snapshot (draft %.12s→%.12s, edit %.12s→%.12s) — it changes no model, so it must re-bill nothing",
|
||
plainDraft, draft, plainEdit, edit)
|
||
}
|
||
// The invariant is still the point: strip the capability and the SAME book must refuse to run.
|
||
incapable := setupEscalationProject(t, srv2.URL, 0, &labelRouting{
|
||
bookLabels: []string{synthLabel},
|
||
policy: synthLabel,
|
||
action: "allow",
|
||
})
|
||
if _, err := NewRunner(incapable, obs.NewLogger()); err == nil || !strings.Contains(err.Error(), "may not receive content label") {
|
||
t.Fatalf("an asserting label must still refuse incapable endpoints, got %v", err)
|
||
}
|
||
}
|