textmachine/backend/internal/pipeline/content_routing_test.go

610 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}