Compare commits

...

8 commits

Author SHA1 Message Date
Claude (backend session)
0909e86e8c Commit owner's living brief update: V5 section written into file, SEO item already absorbed as PT-23 2026-08-02 20:49:18 +03:00
Claude (backend session)
b0cf84f4b0 Close orchestrator eleven: pack accepted and landed, standards prompt handed to backend, screen pack design frozen for successor, four owner questions left open 2026-08-02 20:47:31 +03:00
Claude (backend session)
6a956b3aa1 Record owner design input on line 46: isolated detector package with named refusal reasons, named flag replaces FlagCJKArtifact reuse detail 2026-08-02 20:41:35 +03:00
Claude (backend session)
494fc05f3c Ratify owner decisions: line 46 reframed closed-set, repo standards sanctioned with staged backend prompt issued, five shipping configs cleared to low, DeepSeek vendor-checked 2026-08-02 20:30:43 +03:00
Claude (backend session)
4721a1c54e Write S2 session prompt: shell and ui-layer scope, owner layout corrections over stale prompt section, gate-tail backlog rows in scope, S3 entry blocked on API contract 2026-08-02 20:22:21 +03:00
Claude (backend session)
f79d8be88b Accept effort-handle pack by execution: battery, second-path money, adversarial plantings and N=5 raw all confirmed; deviations ratified, D4-D9 deferrals get carrier rows 2026-08-02 19:45:29 +03:00
Claude (backend session)
7832be76f4 Add self-installing pre-commit gate: frontend commits demand green check, zone-mixing and never-commit files blocked, plus exact version pins and engine-strict npmrc 2026-08-02 19:39:29 +03:00
Claude (backend session)
553f1a33cc Give engine-internal calls the reasoning knob through one stage-derivation seam and one request-identity assembly, AST-guarded, with ThinksOnWire now mirroring the extra_body_disable wire 2026-08-02 19:39:16 +03:00
28 changed files with 1353 additions and 126 deletions

View file

@ -87,4 +87,9 @@ hello my dear
2) Считаю что идея со встраиванием в пайплайн фронтир модели -- бессмысленна. Она нам в принципе ничего не дает такого интересного 2) Считаю что идея со встраиванием в пайплайн фронтир модели -- бессмысленна. Она нам в принципе ничего не дает такого интересного
3) Проход книг наперед тоже считаю идеей изначальной не очень удачливой. Что именно и как нам это может дать? Это вообще полезно потенциально? Бустанет перевод хоть как то? Или это оверкилл непонятный и лучше пока сосредоточиться на другом? 3) Проход книг наперед тоже считаю идеей изначальной не очень удачливой. Что именно и как нам это может дать? Это вообще полезно потенциально? Бустанет перевод хоть как то? Или это оверкилл непонятный и лучше пока сосредоточиться на другом?
4) Как мы будем собирать глоссарий? Я так понимаю переводом терминов будет заниматься сама модель, и я считаю глоссарий крайне важным, нужно исследовать как делать запросы для перевода в глоссарий, нужно записывать контекст в котором эти слова чаще всего встречаются, плюс передавать жанр книги наверное. И самое важно некоторые запросы перевода в глоссарий мне кажется надо юзать модель с вебфетчем, например гемини лайт с вебфетчем или любую другую 4) Как мы будем собирать глоссарий? Я так понимаю переводом терминов будет заниматься сама модель, и я считаю глоссарий крайне важным, нужно исследовать как делать запросы для перевода в глоссарий, нужно записывать контекст в котором эти слова чаще всего встречаются, плюс передавать жанр книги наверное. И самое важно некоторые запросы перевода в глоссарий мне кажется надо юзать модель с вебфетчем, например гемини лайт с вебфетчем или любую другую
5) Так же задача на будущее для полигона, сейчас все переводы идут с какого-то языка на русский, но в целом глупо делать бэкенд только под русский. Например промт запросы сейчас идут на русском в модели. Скорее всего надо делать запросы на языке на который мы переводим. Но это вообще не точно. Может нужно писать на яызке оригинала? В любом случае мне кажется в переводе с китайского на анлийский -- русский вообще не должен учавствовать чтоб не сбивать модель с вероятностного прогона 5) Так же задача на будущее для полигона, сейчас все переводы идут с какого-то языка на русский, но в целом глупо делать бэкенд только под русский. Например промт запросы сейчас идут на русском в модели. Скорее всего надо делать запросы на языке на который мы переводим. Но это вообще не точно. Может нужно писать на яызке оригинала? В любом случае мне кажется в переводе с китайского на анлийский -- русский вообще не должен учавствовать чтоб не сбивать модель с вероятностного прогона
Идея проекта V5, 26.07.2026
1) Идеи по фронту.
2) Так как фронт будет писаться под веб сначала, то нужно максимально хорошо сделать для ранжирования гуглом и нужно узнать как там сейчас работают актульные гугловские алгоритмы чтобы сайт не банился и не деранжировался. Просто для примера: политика куков, страницы сироты (на которую никто не ссылается) и так далее

View file

@ -130,3 +130,40 @@ models:
}) })
} }
} }
// TestThinksOnWireMirrorsTheWireForEachControl pins the predicate the load-time echo-exposure warning is
// built on. It exists because the extra_body_disable arm used to read `reasoning != "off"`, which answered
// "it thinks" for an UNSET effort — while Capability.applyToBody merges the thinking-disable for `""` and
// `"off"` alike. The predicate therefore contradicted the wire in exactly the case that matters most: a
// GLM-shaped model configured with no `reasoning:` key at all, i.e. the DEFAULT, shipping dense CJK into
// the echo zone with nothing said out loud.
func TestThinksOnWireMirrorsTheWireForEachControl(t *testing.T) {
m := &Models{
Providers: map[string]Provider{"p": {Kind: "openai"}},
Models: map[string]Model{
"disable": {Provider: "p", Capabilities: &CapabilitiesConfig{Reasoning: &ReasoningCapCfg{
Control: "extra_body_disable", OffExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}}},
"effort": {Provider: "p", Capabilities: &CapabilitiesConfig{Reasoning: &ReasoningCapCfg{
Control: "effort", OffEffort: "none"}}},
"none": {Provider: "p"}, // ReasoningNone — off-by-omission, nothing reaches the wire
},
}
for _, tc := range []struct {
model, effort string
thinks bool
why string
}{
{"disable", "", false, "an unset effort MERGES the disable — this is the case the old predicate got wrong"},
{"disable", "off", false, "an explicit off merges the disable"},
{"disable", "low", true, "an explicit level opts into thinking (OnExtraBody)"},
{"effort", "", true, "an unset effort emits nothing, so the provider's own default stands"},
{"effort", "off", false, "off sends the explicit OffEffort switch"},
{"effort", "high", true, "a level is sent as-is"},
{"none", "", true, "nothing reasoning-related ever reaches the wire"},
{"none", "off", true, "off is a deliberate NO-OP here — the DeepSeek echo-mine guarantee"},
} {
if got := m.ThinksOnWire(tc.model, tc.effort); got != tc.thinks {
t.Errorf("ThinksOnWire(%q, %q) = %v, want %v — %s", tc.model, tc.effort, got, tc.thinks, tc.why)
}
}
}

View file

@ -0,0 +1,91 @@
package config
// internal_call.go: the ONE derivation of a config.Stage for a call the ENGINE makes on its own behalf —
// a bank role (terminologist / type-classifier), a repair span, tomorrow's annotator or Ф2 judge.
//
// Why it exists (D39.87 §2): those calls used to be hand-written `config.Stage{Name: …, Role: …, Model: …}`
// literals — three of them in shipping code plus one in the live rig — and every literal silently dropped
// every OTHER field the owner had configured. That is how the reasoning knob went missing from the bank
// roles: nobody "forgot a key", the shape of the code loses fields by construction. Adding the key to three
// literals would reproduce the defect at the fourth site, so the literals are gone instead: this is the only
// place in the tree that builds a Stage outside the loader, and TestSyntheticStageSeamIsSingle keeps it that
// way.
// ValidReasoningEffort reports whether v is the engine's NEUTRAL effort vocabulary. ONE definition, shared
// by the stage key and by every gate that configures a call class, so the two can never drift into accepting
// different words for the same knob.
//
// ⚠ It is deliberately provider-BLIND, and that is a known limit rather than an oversight: the capability
// layer maps these words per provider, and a per-model enum does not exist in models.yaml. So `medium` on a
// DeepSeek-shaped model is undefined vendor behaviour, and the vendor's own `xhigh`/`max` are unreachable
// from our config. Validating against the RESOLVED model's capability would need a new per-model table — a
// new mechanism, not a use of an existing one — so it stays a named finding, not a silent gap.
func ValidReasoningEffort(v string) bool {
switch v {
case "", "off", "low", "medium", "high":
return true
}
return false
}
// InternalCall is everything an engine-internal call says about itself. Every OTHER Stage field is decided
// ONCE in Stage() below, with the reason written down — so a new call site inherits the decisions instead of
// re-taking them by accident, and a new Stage FIELD is a compile-visible decision rather than a silent zero
// (TestInternalCallDecidesEveryStageField).
type InternalCall struct {
// Name is the stage name the call is accounted under. It is a request-hash axis and the log axis, so it
// must be the name the operator sees in `tmctl report` for this call class.
Name string
// Role is the engine role — the COST axis (RoleSpentUSD, the per-role sub-budgets) and the log axis.
Role string
// Model is the RESOLVED model this call goes to (gates carry their own `model:`, already validated).
Model string
// Reasoning is the effort the owner configured for this call CLASS, in the engine's neutral vocabulary
// ("" | off | low | medium | high). "" means "leave the provider's own default", which is not the same
// as off — on a capability whose control is extra_body_disable it is the provider's default of THINKING
// OFF (llm.Capability.applyToBody), which is why the value has to travel rather than be assumed.
Reasoning string
}
// Stage renders the internal call as the config.Stage the runner's money path takes. It is deliberately
// TOTAL over Stage's fields: what is set is listed here, and what stays zero stays zero for a written reason.
func (c InternalCall) Stage() Stage {
return Stage{
Name: c.Name,
Role: c.Role,
Model: c.Model,
Reasoning: c.Reasoning,
// ResolvedModel mirrors Model: an internal call is NOT label-routed (label routing resolves the
// book's `stages:` at load), so the configured model IS the resolved one. It is set rather than left
// zero because the load-time exposure walk reads ResolvedModel to name a stage×model pair.
ResolvedModel: c.Model,
// --- deliberately zero ---------------------------------------------------------------------
// Temperature: 0, and NOT inherited from a parent prose stage. These calls return STRUCTURE that
// the engine re-parses — a term table, a type verdict, a span replacement — where sampling variance
// is pure risk; the owner's stage temperature is a STYLE choice about prose and means nothing here.
// (It is also a request-hash field, so this is the value every existing bank checkpoint was bought
// at; changing it would re-buy them, which is a reason to be sure, not the reason to choose.)
//
// ReasoningMaxTokens: 0 — it reserves the ADDITIVE reasoning buffer (D6.2/D13.6), and the gates that
// make internal calls carry no such key. That is not an oversight left open here: the loader REFUSES
// an additive-billing provider for gates.terminology.model / gates.repair.model precisely because
// the block cannot reserve the buffer, so a call reaching this seam is never on an additive provider.
// A future gate that wants an additive model must add the key AND lift that refusal together.
//
// PromptOverride / PromptPath / PromptVersion: internal calls arrive with their messages ALREADY
// rendered (each gate resolves and versions its own prompt), so the stage template machinery is not
// consulted for them.
//
// EscalateTo / ResolvedHop: no escalation hop. maybeEscalate is reached from runStage, not from the
// internal-call paths, and these calls degrade by leaving their work unchanged rather than by paying
// a second model.
//
// LabelModels: see ResolvedModel — no label routing. A model that may not receive the book's content
// labels is refused loudly by clientFor before any money moves, so this is fail-closed, not silent.
//
// FewShot: nil — few-shot is a prose-translation device; a term table has its own prompt.
//
// LegacyPrompt / LegacyPrompts / LegacyChannel: retired keys, declared only to be REJECTED at load.
}
}

View file

@ -0,0 +1,96 @@
package config
import (
"reflect"
"testing"
)
// TestInternalCallDecidesEveryStageField is the second half of the "a fourth synthetic stage cannot be
// forgotten" guarantee. The seam test in package pipeline forces every CALL SITE through InternalCall; this
// one forces every stage FIELD through a decision.
//
// The defect it prevents is the one that produced this pack: a field exists on Stage, an engine-internal
// call needs it, and nobody notices it is missing because a zero value is indistinguishable from a choice.
// Adding a field to Stage now fails HERE, at the one place that has to say what internal calls do with it —
// carry it, or deliberately leave it zero with the reason written next to it in internal_call.go.
func TestInternalCallDecidesEveryStageField(t *testing.T) {
carried := map[string]bool{
"Name": true,
"Role": true,
"Model": true,
"Reasoning": true,
"ResolvedModel": true,
}
// Every entry here is argued in InternalCall.Stage()'s "deliberately zero" block; the value is the
// one-line reason, kept next to the name so this list cannot silently become a dumping ground.
deliberatelyZero := map[string]string{
"Temperature": "structured replies the engine re-parses; sampling variance is pure risk",
"ReasoningMaxTokens": "gates carry no such key, and the loader refuses additive-billing models for them",
"PromptOverride": "internal calls arrive with messages already rendered",
"PromptPath": "same",
"PromptVersion": "same — each gate versions its own prompt",
"EscalateTo": "no hop; these calls degrade by leaving their work unchanged",
"ResolvedHop": "same",
"LabelModels": "no label routing; clientFor refuses an ineligible model loudly",
"FewShot": "few-shot is a prose-translation device",
"LegacyPrompt": "retired key, declared only to be rejected at load",
"LegacyPrompts": "same",
"LegacyChannel": "same",
}
st := reflect.TypeOf(Stage{})
for i := 0; i < st.NumField(); i++ {
name := st.Field(i).Name
if carried[name] == (deliberatelyZero[name] != "") {
t.Errorf("Stage field %q is not decided for engine-internal calls: name it in InternalCall.Stage() "+
"(carrying it) or in deliberatelyZero here (leaving it, with the reason) — exactly one of the two", name)
}
}
// And the reverse direction: a field REMOVED from Stage must not leave a stale decision behind.
for _, m := range []map[string]bool{carried, boolKeys(deliberatelyZero)} {
for name := range m {
if _, ok := st.FieldByName(name); !ok {
t.Errorf("%q is decided here but no longer exists on Stage — drop the stale entry", name)
}
}
}
}
func boolKeys(m map[string]string) map[string]bool {
out := make(map[string]bool, len(m))
for k := range m {
out[k] = true
}
return out
}
// TestInternalCallCarriesTheKnobAndPinsTheRest is the value-level companion: the fields the seam DOES set
// arrive intact, and the ones it pins stay pinned. Cheap, but it is what makes the completeness test above
// mean something — a seam could satisfy "every field is listed" while carrying the wrong value.
func TestInternalCallCarriesTheKnobAndPinsTheRest(t *testing.T) {
got := InternalCall{Name: "terminology", Role: "terminologist", Model: "m1", Reasoning: "low"}.Stage()
if got.Name != "terminology" || got.Role != "terminologist" || got.Model != "m1" || got.Reasoning != "low" {
t.Fatalf("the call's own four facts must arrive intact: %+v", got)
}
if got.ResolvedModel != "m1" {
t.Fatalf("an internal call is not label-routed, so the resolved model IS the model: %q", got.ResolvedModel)
}
if got.Temperature != 0 || got.ReasoningMaxTokens != 0 || got.FewShot != nil || got.EscalateTo != "" {
t.Fatalf("the pinned fields must stay zero: %+v", got)
}
}
func TestValidReasoningEffortIsOneVocabulary(t *testing.T) {
for _, ok := range []string{"", "off", "low", "medium", "high"} {
if !ValidReasoningEffort(ok) {
t.Errorf("%q must be accepted — it is the engine's neutral effort vocabulary", ok)
}
}
// The vendor's own words are NOT ours: they are mapped by the capability layer, and accepting them here
// would let a config name a level the wire has no meaning for.
for _, bad := range []string{"none", "xhigh", "max", "minimal", "LOW"} {
if ValidReasoningEffort(bad) {
t.Errorf("%q must be refused — the engine's vocabulary is provider-neutral", bad)
}
}
}

View file

@ -374,8 +374,19 @@ func (m *Models) providerReasoning(modelName string) string {
// AdditiveReasoningTokens. // AdditiveReasoningTokens.
func (m *Models) ThinksOnWire(modelName, reasoning string) bool { func (m *Models) ThinksOnWire(modelName, reasoning string) bool {
switch m.ResolveCapability(modelName).Reasoning.Control { switch m.ResolveCapability(modelName).Reasoning.Control {
case llm.ReasoningEffortField, llm.ReasoningExtraBodyDisable: case llm.ReasoningExtraBodyDisable:
return reasoning != "off" // an explicit off-switch exists and is sent at "off" // ⚠ EMPTY is not "the provider default" for this control — it is DISABLED. Capability.applyToBody
// merges OffExtraBody for `effort == "" || effort == "off"` alike, because a disable-capability's
// whole purpose is to keep thinking off unless someone opts in. This arm used to read
// `reasoning != "off"` and so answered "it thinks" for the unset case, disagreeing with the wire it
// exists to describe — which silently exempted the shape it matters most for: a GLM-shaped model
// configured with no `reasoning:` key at all (the DEFAULT), shipping dense CJK into the echo zone
// with no warning. Must mirror applyToBody exactly.
return reasoning != "" && reasoning != "off"
case llm.ReasoningEffortField:
// Here empty DOES mean the provider default: applyToBody emits nothing, and the off-switch is sent
// only at an explicit "off" (grok then thinks at its own default `low`).
return reasoning != "off"
default: // none (off-by-omission) | mandatory — no switch reaches the wire default: // none (off-by-omission) | mandatory — no switch reaches the wire
return true return true
} }
@ -441,8 +452,17 @@ func (m *Models) ResolveCapability(modelName string) llm.Capability {
// on the wire — top-level (GLM/DeepSeek {"thinking":…}, Qwen {"enable_thinking":…}, a // on the wire — top-level (GLM/DeepSeek {"thinking":…}, Qwen {"enable_thinking":…}, a
// raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via // raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via
// chat_template_kwargs.thinking:false). On an echo-prone provider their mere PRESENCE, // chat_template_kwargs.thinking:false). On an echo-prone provider their mere PRESENCE,
// at any depth, is the mine, whatever the value: thinking must stay at the provider // at any depth, is the mine, whatever the value.
// default, and these keys exist only to move it off that default. //
// ⚠ What is banned is the RAW WIRE channel, not the subject: since D39.87 a stage or a
// gate MAY set the effort level (`reasoning: "low"`) on such a provider, and on a
// ReasoningNone capability that emits the very same reasoning_effort key legally. The
// difference is not the byte on the wire, it is who decided it: the config path goes
// through Capability.applyToBody, which knows what "off" means for this control and
// keeps a disable from ever being emitted here, while extra_body is merged verbatim and
// can therefore SUPPRESS thinking — which is the mine. So: the effort level is the
// owner's to choose; the on/off switch is not, and this map is what keeps the second
// one out of the first one's clothing.
var thinkingControlExtraKeys = map[string]bool{ var thinkingControlExtraKeys = map[string]bool{
"thinking": true, "enable_thinking": true, "reasoning_effort": true, "reasoning": true, "thinking": true, "enable_thinking": true, "reasoning_effort": true, "reasoning": true,
} }

View file

@ -317,6 +317,19 @@ type TerminologyGate struct {
// LangpackVersion and re-buy both waves of every book of the pair. The banknote fold reads the same // LangpackVersion and re-buy both waves of every book of the pair. The banknote fold reads the same
// declaration, so a book running that channel without the terminologist may set it with the gate off. // declaration, so a book running that channel without the terminologist may set it with the gate off.
TargetScript string `yaml:"target_script"` TargetScript string `yaml:"target_script"`
// Reasoning is how much the bank roles may THINK, in the engine's neutral vocabulary
// ("" | off | low | medium | high) — the same key a stage carries, because it is the same knob: the
// gate is simply the place a BOOK-LEVEL call class is configured, having no stage of its own. ONE knob
// for both roles: they share the batching, the money path and the checkpoint axis, and the difference
// between a long render and a short classification is a max_tokens difference, not an effort one — a
// second key would be split without a measurement asking for it.
//
// "" is NOT "no thinking": it means "leave the provider's default", which on a DeepSeek-shaped
// capability is thinking ON at the vendor's own effort (that default moved to `high` on 2026-07-31 and
// walled the role — D39.86), and on a GLM-shaped one (control extra_body_disable) is thinking OFF. That
// asymmetry is why the value has to be configurable here at all instead of assumed; Runner
// .sourceEchoExposure names the second case out loud at load.
Reasoning string `yaml:"reasoning"`
// PromptPath is the RESOLVED role prompt (`<prompts root>/<pair>/terminologist.md`), filled by // PromptPath is the RESOLVED role prompt (`<prompts root>/<pair>/terminologist.md`), filled by
// LoadPipeline by the ordinary role convention. Not a config key. // LoadPipeline by the ordinary role convention. Not a config key.
PromptPath string `yaml:"-"` PromptPath string `yaml:"-"`
@ -360,6 +373,16 @@ func (g TerminologyGate) ClassifierModel() string {
type RepairGate struct { type RepairGate struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Model string `yaml:"model"` Model string `yaml:"model"`
// Reasoning is how much a repair call may THINK, in the engine's neutral vocabulary
// ("" | off | low | medium | high) — its OWN key, deliberately NOT inherited from the stage whose text
// is being repaired. Inheriting was the first design and it was wrong twice over: the effort's MEANING
// is per-CONTROL (on an extra_body_disable capability "off" is a live thinking disable, on a
// ReasoningNone one it is a documented no-op), and `gates.repair.model` is independent of the stage's
// model — so a final stage carrying `reasoning: "off"` as a no-op for DeepSeek would have armed a real
// disable on a GLM repair model. It also moves the request hash, so inheritance would have re-bought
// every repair call already paid for by every book whose final stage sets the key (which is all of
// them). Unset ⇒ "" ⇒ byte-identical to the pre-key behaviour.
Reasoning string `yaml:"reasoning"`
// MaxCallsPerUnit bounds the calls one output unit may spend (the blast-radius cap); the loop itself is // MaxCallsPerUnit bounds the calls one output unit may spend (the blast-radius cap); the loop itself is
// a SINGLE round — a repaired text is never re-repaired. // a SINGLE round — a repaired text is never re-repaired.
MaxCallsPerUnit int `yaml:"max_calls_per_unit"` MaxCallsPerUnit int `yaml:"max_calls_per_unit"`
@ -951,9 +974,7 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P
bad("stage %q: prompt_override template %s is not readable: %v", st.Name, p.Stages[i].PromptPath, err) bad("stage %q: prompt_override template %s is not readable: %v", st.Name, p.Stages[i].PromptPath, err)
} }
} }
switch st.Reasoning { if !ValidReasoningEffort(st.Reasoning) {
case "", "off", "low", "medium", "high":
default:
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning) bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
} }
// D13.6: on an ADDITIVE-billing provider (xAI — reasoning bills ON TOP of completion) a // D13.6: on an ADDITIVE-billing provider (xAI — reasoning bills ON TOP of completion) a
@ -1035,6 +1056,9 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P
if rep.MaxCallsPerUnit <= 0 { if rep.MaxCallsPerUnit <= 0 {
bad("gates.repair.max_calls_per_unit must be > 0 when the gate is enabled") bad("gates.repair.max_calls_per_unit must be > 0 when the gate is enabled")
} }
if !ValidReasoningEffort(rep.Reasoning) {
bad("gates.repair.reasoning must be off|low|medium|high, got %q", rep.Reasoning)
}
if pair == "" { if pair == "" {
bad("gates.repair is enabled but the book declares no language pair — the repair prompts are resolved as <prompts root>/<pair>/repair/<class>.md") bad("gates.repair is enabled but the book declares no language pair — the repair prompts are resolved as <prompts root>/<pair>/repair/<class>.md")
} else { } else {
@ -1054,6 +1078,11 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P
if tg.BudgetUSD <= 0 { if tg.BudgetUSD <= 0 {
bad("gates.terminology.budget_usd must be > 0 when the gate is enabled (a gate that can never spend is a silent no-op)") bad("gates.terminology.budget_usd must be > 0 when the gate is enabled (a gate that can never spend is a silent no-op)")
} }
// The bank roles' effort knob answers to the same vocabulary as a stage's, by the same validator —
// the gate is where a book-level call class is configured, not a second dialect of the same key.
if !ValidReasoningEffort(tg.Reasoning) {
bad("gates.terminology.reasoning must be off|low|medium|high, got %q", tg.Reasoning)
}
// Validated against the same standard-library table the runner resolves with // Validated against the same standard-library table the runner resolves with
// (terminology.ScriptByName), so an accepted name can never fail to resolve later. // (terminology.ScriptByName), so an accepted name can never fail to resolve later.
if tg.TargetScript == "" { if tg.TargetScript == "" {

View file

@ -51,11 +51,21 @@ type ReasoningControl string
const ( const (
// ReasoningNone is the OpenAI-compat baseline: low|medium|high go out as // ReasoningNone is the OpenAI-compat baseline: low|medium|high go out as
// reasoning_effort; off and "" emit nothing, leaving the provider's OWN default. // reasoning_effort; off and "" emit nothing, leaving the provider's OWN default.
// Use it for a model whose thinking must NOT be pushed off that default from our // Use it for a model that must never be TURNED OFF from our side: DeepSeek-flash
// side: DeepSeek-flash on the draft path defaults to thinking ON, and "off" here is // defaults to thinking ON, and "off" here is a deliberate NO-OP (emitting nothing
// a deliberate NO-OP (emitting nothing keeps it ON) — DISABLING DeepSeek thinking // keeps it ON) — DISABLING DeepSeek thinking arms the echo mine (it returns the
// arms the echo mine (it returns the untranslated CJK source at HTTP 200; // untranslated CJK source at HTTP 200; config.echoMineViolation fail-fasts on it).
// config.echoMineViolation fail-fasts on it). This is the WRONG control for a model //
// ⚠ "Never turned off" is NOT "never configured" (D39.87): low|medium|high are a
// ratified way to size the thinking BUDGET on such a model, and they go out here as
// reasoning_effort with thinking still on — proven on the wire, 22 bodies carrying
// reasoning_effort:"low", zero `thinking` keys, every reply with non-empty
// reasoning_content. That distinction became load-bearing when DeepSeek moved its OWN
// default effort to `high` on 2026-07-31 and dense-Han calls began hitting max_tokens
// with an empty body: riding the provider default is a choice whose owner is the
// vendor, not an absence of one.
//
// This is the WRONG control for a model
// whose thinking is ON BY OMISSION: xAI-Grok defaults reasoning_effort to "low" (it // whose thinking is ON BY OMISSION: xAI-Grok defaults reasoning_effort to "low" (it
// thinks), so a role that must NOT think (the editor) uses ReasoningEffortField with // thinks), so a role that must NOT think (the editor) uses ReasoningEffortField with
// OffEffort "none" to send an EXPLICIT reasoning_effort:"none" instead. // OffEffort "none" to send an EXPLICIT reasoning_effort:"none" instead.

View file

@ -103,13 +103,10 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
// checkpoint exists and was already paid — REPLAY it for free regardless of the // checkpoint exists and was already paid — REPLAY it for free regardless of the
// budget, so a crash AFTER the hop settled but BEFORE chunk_status was written // budget, so a crash AFTER the hop settled but BEFORE chunk_status was written
// re-serves it on resume rather than discarding a paid, successful translation // re-serves it on resume rather than discarding a paid, successful translation
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The // and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The identity is BUILT by the
// hash mirrors runAttempt's for (model=ResolvedHop, attempt=0, maxTokens=hopMaxTokens). // same helper the hop's own runAttempt uses (attemptRequest), so "mirrors runAttempt" is structural
fbHash := RequestHash(Request{ // rather than a promise two field lists keep by vigilance.
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: 0, fbHash := RequestHash(r.attemptRequest(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs))
Stage: st.Name, Role: st.Role, Model: st.ResolvedHop, Temperature: st.Temperature, Reasoning: st.Reasoning,
JSONOnly: false, MaxTokens: hopMaxTokens, SnapshotID: snapID, Messages: msgs,
})
fbExists, err := r.Store.GetCheckpoint(fbHash) fbExists, err := r.Store.GetCheckpoint(fbHash)
if err != nil { if err != nil {
return out, err return out, err

View file

@ -23,13 +23,15 @@ package pipeline
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"time" "time"
"textmachine/backend/internal/chunk" "textmachine/backend/internal/chunk"
"textmachine/backend/internal/config" "textmachine/backend/internal/llm"
"textmachine/backend/internal/obs" "textmachine/backend/internal/obs"
"textmachine/backend/internal/terminology" "textmachine/backend/internal/terminology"
"textmachine/backend/internal/text" "textmachine/backend/internal/text"
@ -84,58 +86,107 @@ func TestLiveClassifierHarmSet(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("render production classifier messages: %v", err) t.Fatalf("render production classifier messages: %v", err)
} }
model := r.Pipeline.Gates.Terminology.ClassifierModel() // The stage comes from the PRODUCTION derivation, not a literal: a probe that hand-built its stage would
_, maxTokens := r.bankCallBudget(model, msgs) // measure a shape the engine never sends — and once the effort knob landed, its green 6/6 would say
// nothing about the level the bank roles actually ride (D39.87 §0.2, the fourth synthetic-stage site).
st := r.bankStage(roleClassifier)
_, maxTokens := r.bankCallBudget(st.Model, msgs)
// The probe's own snapshot axis: a literal, so re-running it replays the paid checkpoint for $0 // N SAMPLES, not one. The verdict this probe produces is a judgement call by a stochastic model, and a
// instead of re-buying the answer — the same property the production bank roles have. // single call cannot tell a level difference from ordinary variance — reading n=1 as a measurement is
const snapID = "reprobe-classifier-6of6" // exactly the manufactured-convergence failure this repo has paid for before. Each run gets its OWN
// jobs.snapshot_id is a FOREIGN KEY into snapshots — the production path upserts the wave snapshot // snapshot axis so it is a FRESH call; a repeat of the same index still replays that run for $0.
// before opening jobs, so the probe registers its own axis the same way instead of borrowing one. // Default 5, not 1. The comment above condemns n=1 and the first version of this rig then DEFAULTED to
if err := r.Store.UpsertSnapshot(snapID, "", `{"probe":"classifier-harm-set-6of6"}`); err != nil { // it — so the next person to run it would have got exactly the reading it warns against. A run costs
t.Fatalf("register the probe snapshot axis: %v", err) // ~$0.0002-0.0009, so five samples are free at the scale of any probe that would bother running this.
runs := 5
if v := os.Getenv("TM_CLASSIFY6_N"); v != "" {
if n, cerr := strconv.Atoi(v); cerr == nil && n > 0 {
runs = n
}
} }
st := config.Stage{Name: terminologyStageName, Role: roleClassifier, Model: model} effort := st.Reasoning
job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID) if effort == "" {
if err != nil { effort = "unset"
t.Fatalf("ensure job: %v", err)
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer cancel()
start := time.Now() type sample struct {
att, err := r.runBankAttempt(ctx, roleClassifier, model, st, snapID, chunk.Chunk{Chapter: 0, ChunkIdx: 0}, job, msgs) Run int `json:"run"`
if err != nil { Finish string `json:"finish"`
t.Fatalf("live classifier call failed: %v", err) Verdict string `json:"verdict"`
Usage llm.Usage `json:"usage"`
CostUSD float64 `json:"cost_usd"`
LatencyMS int64 `json:"latency_ms"`
Reply string `json:"reply"`
Parsed map[string]string `json:"parsed"`
BadLines int `json:"bad_lines"`
Hits int `json:"hits_term"`
DiscriminatingHits int `json:"discriminating_hits"`
} }
samples := make([]sample, 0, runs)
got, stats := terminology.ParseTypes(att.text, candKeys(harmSet), text.NormalizeSourceKey) full, totalCost := 0, 0.0
hits, decided := 0, 0 for i := 0; i < runs; i++ {
for _, c := range harmSet { snapID := fmt.Sprintf("reprobe-classifier-6of6-%s-%d", effort, i)
if got[c.Key] != "term" { // jobs.snapshot_id is a FOREIGN KEY into snapshots — the production path upserts the wave snapshot
continue // before opening jobs, so the probe registers its own axis the same way instead of borrowing one.
if err := r.Store.UpsertSnapshot(snapID, "", `{"probe":"classifier-harm-set-6of6","effort":"`+effort+`"}`); err != nil {
t.Fatalf("register the probe snapshot axis: %v", err)
} }
hits++ job, jerr := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
if c.Key != "元石" && c.Key != "灵泉" { // the two the prompt names verbatim if jerr != nil {
decided++ t.Fatalf("ensure job: %v", jerr)
} }
start := time.Now()
att, aerr := r.runBankAttempt(ctx, st, snapID, chunk.Chunk{Chapter: 0, ChunkIdx: 0}, job, msgs)
if aerr != nil {
t.Fatalf("live classifier call %d failed: %v", i, aerr)
}
got, stats := terminology.ParseTypes(att.text, candKeys(harmSet), text.NormalizeSourceKey)
hits, decided := 0, 0
for _, c := range harmSet {
if got[c.Key] != "term" {
continue
}
hits++
if c.Key != "元石" && c.Key != "灵泉" { // the two the prompt names verbatim
decided++
}
}
if hits == len(harmSet) {
full++
}
totalCost += att.runCost
samples = append(samples, sample{i, att.finish, string(att.cls.Reason), att.usage, att.runCost,
time.Since(start).Milliseconds(), att.text, got, stats.Bad, hits, decided})
t.Logf("CLASSIFIER 6/6 [effort=%s run=%d/%d]: model=%s max_tokens=%d finish=%q verdict=%q latency=%dms usage=%+v cost_usd=%.6f bad_lines=%d hits=%d/6 (discriminating %d/4) got=%v",
effort, i+1, runs, att.modelActual, maxTokens, att.finish, att.cls.Reason,
samples[i].LatencyMS, att.usage, att.runCost, stats.Bad, hits, decided, got)
} }
t.Logf("CLASSIFIER 6/6 PROBE: model=%s max_tokens=%d finish=%q verdict=%q latency=%dms usage=%+v cost_usd=%.6f bad_lines=%d hits=%d/6 (discriminating %d/4) got=%v", t.Logf("CLASSIFIER 6/6 SUMMARY [effort=%s]: %d/%d runs reached the 6/6 threshold · total_cost_usd=%.6f", effort, full, runs, totalCost)
att.modelActual, maxTokens, att.finish, att.cls.Reason, time.Since(start).Milliseconds(), att.usage, att.runCost, stats.Bad, hits, decided, got)
// Persist the raw wire evidence next to the probe project — "persist, not scratch": every number in // Persist the raw wire evidence next to the probe project — "persist, not scratch": every number in
// the report must be re-readable from the artifact that produced it. // the report must be re-readable from the artifact that produced it. The file is per-EFFORT, so a
// comparison between levels does not overwrite its own other half.
blob, _ := json.MarshalIndent(map[string]any{ blob, _ := json.MarshalIndent(map[string]any{
"model": att.modelActual, "max_tokens": maxTokens, "finish": att.finish, "model": st.Model, "effort": effort, "max_tokens": maxTokens,
"verdict": string(att.cls.Reason), "usage": att.usage, "cost_usd": att.runCost, "runs": runs, "runs_at_threshold": full, "total_cost_usd": totalCost, "samples": samples,
"reply": att.text, "parsed": got, "bad_lines": stats.Bad,
"hits_term": hits, "discriminating_hits": decided,
}, "", " ") }, "", " ")
if werr := os.WriteFile(filepath.Join(filepath.Dir(cfg), "classifier-6of6.json"), blob, 0o644); werr != nil { if werr := os.WriteFile(filepath.Join(filepath.Dir(cfg), "classifier-6of6-"+effort+".json"), blob, 0o644); werr != nil {
t.Errorf("persist probe evidence: %v", werr) t.Errorf("persist probe evidence: %v", werr)
} }
if hits != len(harmSet) { // ⚠ THE THRESHOLD AND THE SAMPLE DISAGREE, and that is an OWNER decision, not something to soften here.
t.Fatalf("acceptance threshold is 6/6 term (D39.69 §2); got %d/6: %v — reply:\n%s", hits, got, att.text) // D39.69 §2 ratified "6/6" against a SINGLE call; nobody defined it over a sample. Measured 02.08 on
// live 0731 weights: effort `low` reaches 6/6 on 4 of 5 runs, `high` on 5 of 5 — a difference n=5 cannot
// separate — while `high` costs 2.30x per call. So the level this engine now recommends for the bank
// roles makes this gate RED. Reporting that honestly is the point; relaxing the gate to fit the
// recommendation would be fitting the acceptance criterion to the result.
if full != runs {
t.Fatalf("the ratified acceptance threshold is 6/6 term (D39.69 §2) and it was reached on %d of %d runs "+
"at effort %q — see classifier-6of6-%s.json. ⚠ The threshold was defined for ONE call, not for a "+
"sample: decide with the owner whether it means \"every run\" or \"the median run\" before treating "+
"this as a regression.", full, runs, effort, effort)
} }
} }

View file

@ -344,18 +344,29 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
} }
// repairCheckpointExists reports whether THIS repair request was already paid for in an earlier run. It // repairCheckpointExists reports whether THIS repair request was already paid for in an earlier run. It
// mirrors runRepairAttempt's request identity exactly (same synthetic stage, same ordinal, same budget // mirrors runRepairAttempt's request identity BY CONSTRUCTION — same derived stage, same helper — because
// derivation), because the two must address the same checkpoint — the escalation hop keeps the same // the two must address the same checkpoint; the doccomment used to promise that mirroring while the two
// discipline for the same reason (a crash after settle but before chunk_status must not lose paid work). // field lists were maintained apart, which held only while the dropped fields were zero.
func (r *Runner) repairCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, ordinal int, msgs []llm.Message) (bool, error) { func (r *Runner) repairCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, ordinal int, msgs []llm.Message) (bool, error) {
model, maxTokens := r.repairCallBudget(msgs) model, maxTokens := r.repairCallBudget(msgs)
cp, err := r.Store.GetCheckpoint(RequestHash(Request{ rst := r.repairStage(st, model)
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: ordinal, cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(rst, model, snapID, ch, ordinal, maxTokens, msgs)))
Stage: st.Name, Role: roleRepair, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs,
}))
return cp != nil, err return cp != nil, err
} }
// repairStage derives the stage of a repair call from the stage whose output is being repaired. It takes
// the NAME from that stage (the accounting axis) and everything else from the repair gate.
//
// The effort is the GATE's, NOT inherited from the parent stage — see RepairGate.Reasoning for why: the
// value's meaning is per-control, the model is a different one, and inheriting would have moved the
// request hash of every already-paid repair call.
func (r *Runner) repairStage(st config.Stage, model string) config.Stage {
return config.InternalCall{
Name: st.Name, Role: roleRepair, Model: model,
Reasoning: r.Pipeline.Gates.Repair.Reasoning,
}.Stage()
}
// repairCallBudget resolves the model and the max_tokens of a repair call — ONE definition, so the // repairCallBudget resolves the model and the max_tokens of a repair call — ONE definition, so the
// checkpoint probe and the call itself can never address different request hashes. // checkpoint probe and the call itself can never address different request hashes.
func (r *Runner) repairCallBudget(msgs []llm.Message) (string, int) { func (r *Runner) repairCallBudget(msgs []llm.Message) (string, int) {
@ -374,14 +385,19 @@ func (r *Runner) repairCallBudget(msgs []llm.Message) (string, int) {
// runRepairAttempt performs ONE repair call on the shared money path: reserve → call → settle+checkpoint, // runRepairAttempt performs ONE repair call on the shared money path: reserve → call → settle+checkpoint,
// with a checkpoint hit replayed for free. It reuses runAttempt rather than re-implementing the money // with a checkpoint hit replayed for free. It reuses runAttempt rather than re-implementing the money
// sequence — a second copy of that sequence is exactly the drift the snapshot fold helper was extracted to // sequence — a second copy of that sequence is exactly the drift the snapshot fold helper was extracted to
// prevent. The synthetic stage carries the repair ROLE and MODEL (both request-hash fields, so a repair call // prevent. The derived stage carries the repair ROLE and MODEL (both request-hash fields, so a repair call
// can never collide with the stage's own attempts) and no reasoning setting, so the provider default holds — // can never collide with the stage's own attempts) and INHERITS the repaired stage's effort.
// on DeepSeek that means thinking stays ON and the echo mine is not armed. //
// It used to carry no reasoning setting at all, on a doccomment that read "the provider default holds — on
// DeepSeek that means thinking stays ON and the echo mine is not armed". The premise was falsified on the
// wire (D39.86): riding the vendor default is not a safe resting place, because the vendor MOVED it — to
// effort `high`, which walls a dense-Han call at max_tokens with an empty body. Thinking staying on was
// never the whole question; how much of the budget it eats is the other half.
func (r *Runner) runRepairAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk, func (r *Runner) runRepairAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk,
job *store.Job, ordinal int, msgs []llm.Message) (stageAttempt, error) { job *store.Job, ordinal int, msgs []llm.Message) (stageAttempt, error) {
model, maxTokens := r.repairCallBudget(msgs) model, maxTokens := r.repairCallBudget(msgs)
rst := config.Stage{Name: st.Name, Role: roleRepair, Model: model} rst := r.repairStage(st, model)
// isFinal=false: the reply is a SPAN, not a shipping text, so the output sanitizer (which reasons about // isFinal=false: the reply is a SPAN, not a shipping text, so the output sanitizer (which reasons about
// whole chunks — preambles, trailing note blocks) must not judge it. The intrinsic classifier still runs // whole chunks — preambles, trailing note blocks) must not judge it. The intrinsic classifier still runs
// inside runAttempt and is a real guard: a model answering «Не могу помочь…» would otherwise pass every // inside runAttempt and is a real guard: a model answering «Не могу помочь…» would otherwise pass every

View file

@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"unicode" "unicode"
@ -400,20 +401,48 @@ const sourcePlaceholder = "{{text}}"
// It fires on no shipping path today (deepseek resolves to ReasoningNone, so "off" is a no-op and // It fires on no shipping path today (deepseek resolves to ReasoningNone, so "off" is a no-op and
// thinking stays ON) and does fire for a reasoning-off editor arm on a CJK source, which is exactly the // thinking stays ON) and does fire for a reasoning-off editor arm on a CJK source, which is exactly the
// exposure the stale "the editor only ever sees a Russian draft" comments used to deny. // exposure the stale "the editor only ever sees a Russian draft" comments used to deny.
// It covers the ENGINE'S OWN calls too, not just the book's stages. Those calls carry a knob only since
// D39.87, and the knob has an asymmetric empty value: on a ReasoningNone capability "" means the provider's
// default (thinking ON), while on an extra_body_disable one (GLM) it means thinking DISABLED — the echo zone
// — and gates.terminology.model is not restricted to a family. An unset knob pointed at such a model is
// therefore a silent thinking-off with no config line describing it, which is precisely what this walk
// exists to refuse to leave silent.
func (r *Runner) sourceEchoExposure() []string { func (r *Runner) sourceEchoExposure() []string {
if !lang.IsCJKScriptLang(r.Book.SourceLang) { if !lang.IsCJKScriptLang(r.Book.SourceLang) {
return nil return nil
} }
var out []string var out []string
for _, st := range r.Pipeline.Stages { shipsSource := func(t *PromptTemplate) bool {
t := r.templates[st.Name] return t != nil && strings.Contains(t.System+t.FewShot+t.User, sourcePlaceholder)
if t == nil || !strings.Contains(t.System+t.FewShot+t.User, sourcePlaceholder) { }
continue add := func(name, model, reasoning string) {
} if !r.Models.ThinksOnWire(model, reasoning) {
if !r.Models.ThinksOnWire(st.ResolvedModel, st.Reasoning) { out = append(out, name+"→"+model)
out = append(out, st.Name+"→"+st.ResolvedModel)
} }
} }
for _, st := range r.Pipeline.Stages {
if shipsSource(r.templates[st.Name]) {
add(st.Name, st.ResolvedModel, st.Reasoning)
}
}
// The bank roles ship source WITHOUT the placeholder: their candidate blocks carry KWIC contexts cut
// from the book, which is the whole reason the roles can render a term at all. So membership is by
// construction (the template is loaded ⇔ the role runs), not by scanning for a marker they do not use.
bank := r.Pipeline.Gates.Terminology
if r.terminologyTemplate != nil {
add(roleTerminologist, bank.Model, bank.Reasoning)
}
if r.classifierTemplate != nil {
add(roleClassifier, bank.ClassifierModel(), bank.Reasoning)
}
// Repair sends the source SPAN through the ordinary placeholder, per class — so it is scanned like a
// stage, and a class whose prompt is monolingual is correctly not listed.
for class, t := range r.repairTemplates {
if shipsSource(t) {
add(roleRepair+"/"+string(class), r.Pipeline.Gates.Repair.Model, r.Pipeline.Gates.Repair.Reasoning)
}
}
sort.Strings(out) // map iteration above — the warning must not reorder between runs
return out return out
} }

View file

@ -393,17 +393,31 @@ type stageAttempt struct {
freshCall bool // a provider call was made this run freshCall bool // a provider call was made this run
} }
// attemptRequest is the ONE definition of a call's REQUEST IDENTITY — the tuple RequestHash addresses a
// checkpoint by. Every site that needs that identity goes through it: the attempt itself, the escalation
// hop's idempotency probe, and the two "was this already paid for?" probes of the bank and repair paths.
//
// It exists because those probes used to assemble the tuple by hand, each omitting Temperature and
// Reasoning, and agreed with runAttempt only while both values happened to be zero. Giving the internal
// calls a reasoning knob makes them non-zero, and a divergence there is not a stale read — it is money, in
// BOTH directions: a false "unpaid" makes a resumed pass re-run the budget pre-check and can cut a pass off
// before its first call, while a false "paid" skips the ROLE sub-budget gate (which only runs on an unpaid
// batch) and lets the attempt buy a fresh call under nothing but the book ceiling. One tuple, one place.
func (r *Runner) attemptRequest(st config.Stage, model, snapID string, ch chunk.Chunk, attempt, maxTokens int, msgs []llm.Message) Request {
return Request{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role, Model: model, Temperature: st.Temperature, Reasoning: st.Reasoning,
JSONOnly: false, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs,
}
}
// runAttempt executes exactly one attempt on the request-hash axis: a checkpoint // runAttempt executes exactly one attempt on the request-hash axis: a checkpoint
// hit is classified for free (self-heal, incl. legacy Phase-0 empty/decode // hit is classified for free (self-heal, incl. legacy Phase-0 empty/decode
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+ // checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
// checkpoint. It returns an error only on an infra failure; a bad completion // checkpoint. It returns an error only on an infra failure; a bad completion
// comes back as a classification on the attempt. // comes back as a classification on the attempt.
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch chunk.Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) { func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch chunk.Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) {
reqHash := RequestHash(Request{ reqHash := RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role, Model: model, Temperature: st.Temperature, Reasoning: st.Reasoning,
JSONOnly: false, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs,
})
att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model} att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
// Resume on the attempt axis: a checkpoint means THIS attempt already happened // Resume on the attempt axis: a checkpoint means THIS attempt already happened

View file

@ -0,0 +1,395 @@
package pipeline
// synthetic_stage_seam_test.go: the mechanical guards behind the effort knob (D39.87).
//
// The defect the knob fixed was never "somebody forgot a key" — it was the SHAPE of the code: engine-internal
// calls each hand-built their own config.Stage, and a hand-built literal drops every field it does not name.
// Adding the key to the three shipping literals would have left the fourth site (and the fifth, tomorrow)
// exactly as broken, so the literals are gone. These tests are what keeps them gone.
//
// They walk the AST, not the bytes. A byte scan for `config.Stage{` was the first version and it was
// defeated five ways — `var s config.Stage` plus field assignment, `new(config.Stage)`, a type alias, a
// newline between `RequestHash(` and `Request{`, and the most natural rewrite of all, `req := Request{…}`
// followed by `RequestHash(req)`. It also failed the build on a doccomment that merely NAMED the type. A
// guard that a careless edit slips past is worse than none, because it reads as protection.
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// configPkgPath is the import path the guards resolve against. Matching the PATH, not the local package
// NAME, is load-bearing: `import cfg "…/internal/config"` followed by `cfg.Stage{…}` is a perfectly ordinary
// thing for an engineer to write, and a name-based predicate waves it through. That evasion was found by
// planting a realistic fifth call site — an annotator role — in shipping code, and the name-based version of
// this guard reported `ok`.
const configPkgPath = "textmachine/backend/internal/config"
// walkedFileFloor is the smallest number of files a healthy walk parses. Without it, a walk that parses
// NOTHING — a bad root, a rename, a build-tag change — is indistinguishable from a clean tree, and the guard
// silently becomes decoration. That is the exact "reads as protection" failure this file's header warns
// about, so it is asserted rather than assumed. The tree holds ~200 .go files; the floor is deliberately far
// below that, because it exists to catch ZERO, not to track the file count.
const walkedFileFloor = 50
// fileImports maps a file's LOCAL package identifiers to the import paths they stand for. A dot-import is
// recorded under "." so a bare `Stage{…}` can be resolved too.
func fileImports(f *ast.File) map[string]string {
out := map[string]string{}
for _, im := range f.Imports {
path := strings.Trim(im.Path.Value, `"`)
name := path[strings.LastIndex(path, "/")+1:] // the default local name
if im.Name != nil {
name = im.Name.Name
}
out[name] = path
}
return out
}
// walkGoFiles parses every .go file under the REPO root — not just internal/ — and hands each file's AST to
// visit. The reach past the Go module is deliberate and load-bearing: eval/ has no go.mod of its own yet
// imports this module's config package, and the ORIGINAL defect's fourth call site was exactly such a rig.
// Any go/types-based tool would be blind to it.
func walkGoFiles(t *testing.T, exempt func(slashPath string) bool, visit func(path string, fset *token.FileSet, f *ast.File, imports map[string]string)) {
t.Helper()
root := filepath.Join("..", "..", "..") // internal/pipeline -> backend -> repo root
parsed := 0
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Trees that hold no call sites of ours and cost seconds to walk.
switch info.Name() {
case ".git", "node_modules", "vendor", "dist", "backups":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
slash := filepath.ToSlash(path)
if exempt(slash) {
return nil
}
fset := token.NewFileSet()
f, perr := parser.ParseFile(fset, path, nil, 0) // no comments: a doccomment naming the type is not a call site
if perr != nil {
return nil // not part of this module's build (a testdata fixture, a scratch file)
}
parsed++
visit(path, fset, f, fileImports(f))
return nil
})
if err != nil {
t.Fatal(err)
}
if parsed < walkedFileFloor {
t.Fatalf("the guard parsed only %d files (floor %d) — it is walking the wrong root or nothing at all, "+
"which makes it decoration rather than a guard", parsed, walkedFileFloor)
}
}
// namedType reports whether expr denotes pkgPath.name in a file with these imports, unwrapping the
// composite-literal wrappers `[]T`, `map[K]T` and `*T`. The unwrapping matters: `[]config.Stage{{…}}` ELIDES
// the element type on each element, so a literal-only check sees `&ast.CompositeLit{Type:nil}` and misses it
// — verified by planting exactly that and watching the guard report `ok`.
func namedType(expr ast.Expr, imports map[string]string, pkgPath, name string) bool {
switch t := expr.(type) {
case *ast.ArrayType:
return namedType(t.Elt, imports, pkgPath, name)
case *ast.MapType:
return namedType(t.Value, imports, pkgPath, name)
case *ast.StarExpr:
return namedType(t.X, imports, pkgPath, name)
case *ast.Ident: // a dot-imported type, or a package-local one
return t.Name == name && imports["."] == pkgPath
case *ast.SelectorExpr:
if t.Sel == nil || t.Sel.Name != name {
return false
}
id, ok := t.X.(*ast.Ident)
return ok && imports[id.Name] == pkgPath // by PATH, so an alias cannot slip through
}
return false
}
// TestSyntheticStageSeamIsSingle: outside the config package there is exactly ONE way to obtain a Stage for
// an engine-internal call — config.InternalCall.Stage() — so a fifth call site inherits every decision the
// seam took instead of re-taking them by omission. This is the mechanical answer to the acceptance question
// "a FOURTH synthetic stage appears tomorrow: does it get the knob, or is it forgotten again?".
func TestSyntheticStageSeamIsSingle(t *testing.T) {
// Exemptions are FULL PATHS, never basenames: a basename exemption spreads to every file in the repo that
// happens to share the name, which is how `internal/anything/fewshot_test.go` would have inherited a pass.
exemptPaths := []string{
"/backend/internal/pipeline/fewshot_test.go", // pure predicate test: no wire, no ledger, no checkpoint
"/backend/internal/pipeline/synthetic_stage_seam_test.go", // this guard builds a PARENT stage to test inheritance
}
var offenders []string
walkGoFiles(t,
func(slashPath string) bool {
for _, p := range exemptPaths {
if strings.HasSuffix(slashPath, p) {
return true
}
}
// The type's OWN package — the directory exactly, not a substring: `strings.Contains` would also
// exempt every SUBpackage (internal/config/annotator/) and any other tree ending in that path.
return strings.HasSuffix(filepath.ToSlash(filepath.Dir(slashPath)), "/backend/internal/config")
},
func(path string, fset *token.FileSet, f *ast.File, imports map[string]string) {
isStage := func(e ast.Expr) bool { return namedType(e, imports, configPkgPath, "Stage") }
ast.Inspect(f, func(n ast.Node) bool {
var bad ast.Node
switch v := n.(type) {
case *ast.CompositeLit: // config.Stage{...}, []config.Stage{{...}}, map[string]config.Stage{...}
if v.Type != nil && isStage(v.Type) {
bad = v
}
case *ast.ValueSpec: // var s config.Stage (NOT unwrapped: `var ss []config.Stage` is a legitimate slice)
if v.Type != nil && namedTypeExact(v.Type, imports, configPkgPath, "Stage") {
bad = v
}
case *ast.CallExpr: // new(config.Stage)
if id, ok := v.Fun.(*ast.Ident); ok && id.Name == "new" && len(v.Args) == 1 && namedTypeExact(v.Args[0], imports, configPkgPath, "Stage") {
bad = v
}
case *ast.TypeSpec: // type myStage = config.Stage / type myStage config.Stage
if namedTypeExact(v.Type, imports, configPkgPath, "Stage") {
bad = v
}
}
if bad != nil {
offenders = append(offenders, fset.Position(bad.Pos()).String())
}
return true
})
})
if len(offenders) > 0 {
t.Fatalf("an engine-internal call must derive its stage through config.InternalCall.Stage(), not by "+
"building one (a hand-built stage silently drops reasoning/temperature — D39.87 §2); found: %v", offenders)
}
}
// TestRequestIdentitySeamIsSingle: the tuple RequestHash addresses a checkpoint by is assembled in exactly
// ONE place (Runner.attemptRequest). Two assemblies of it agreeing is not a property anyone can maintain by
// eye — it held only while the omitted fields happened to be zero, and the effort knob makes them non-zero.
//
// The guard is "no Request composite literal outside attemptRequest", not "no RequestHash(Request{…})":
// the second form is one refactor away from invisible (hoist the literal into a variable), and it is the
// literal that is the hazard.
func TestRequestIdentitySeamIsSingle(t *testing.T) {
const seamFile = "stagerun.go" // the file that owns attemptRequest
// The tests OF the hashing contract legitimately build Requests — that is their subject. Named
// individually, so the exemption cannot spread to a file that merely happens to end in _test.go.
exemptPaths := []string{
"/backend/internal/pipeline/" + seamFile,
"/backend/internal/pipeline/render.go", // where Request is DEFINED and hashed
// The tests OF the hashing contract legitimately build Requests — that is their subject.
"/backend/internal/pipeline/render_test.go",
"/backend/internal/pipeline/render_memory_test.go",
"/backend/internal/pipeline/synthetic_stage_seam_test.go",
}
var offenders []string
walkGoFiles(t,
func(slashPath string) bool {
for _, p := range exemptPaths {
if strings.HasSuffix(slashPath, p) {
return true
}
}
return false
},
func(path string, fset *token.FileSet, f *ast.File, imports map[string]string) {
inPipeline := strings.HasSuffix(filepath.ToSlash(filepath.Dir(path)), "/backend/internal/pipeline")
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok || lit.Type == nil {
return true
}
hit := namedType(lit.Type, imports, pipelinePkgPath, "Request")
if !hit && inPipeline { // package-local `Request{…}` carries no qualifier to resolve
hit = namedTypeLocal(lit.Type, "Request")
}
if hit {
offenders = append(offenders, fset.Position(lit.Pos()).String())
}
return true
})
})
if len(offenders) > 0 {
t.Fatalf("a call's request identity must come from Runner.attemptRequest, not a re-assembled Request "+
"literal (a probe that drifts from the attempt turns the role sub-budget off — see attemptRequest); found: %v", offenders)
}
}
// TestBankProbeAndAttemptAddressOneCheckpoint is the BEHAVIOURAL half, and it runs the REAL functions:
// bankCheckpointExists is asked about a checkpoint written under the hash the attempt path derives. If the
// two ever address different tuples the probe answers "unpaid" for paid work, or — the expensive direction
// — "paid" for work that was never bought, which skips the role sub-budget gate (it only runs on an unpaid
// batch) and lets the attempt buy a fresh call under nothing but the book ceiling.
//
// An earlier version of this test called attemptRequest twice with identical arguments and compared the
// results. That proves the function is deterministic and nothing else; it would have stayed green through
// exactly the drift it was named for.
func TestBankProbeAndAttemptAddressOneCheckpoint(t *testing.T) {
for _, effort := range []string{"", "low", "high"} {
t.Run("effort="+effort, func(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "probe.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
r := &Runner{Book: &config.Book{BookID: "b1"}, Pipeline: &config.Pipeline{}, Models: &config.Models{}, Store: st}
r.Pipeline.Gates.Terminology.Model = "m1"
r.Pipeline.Gates.Terminology.Reasoning = effort
msgs := []llm.Message{{Role: "user", Content: "candidate block"}}
ch := chunk.Chunk{Chapter: 0, ChunkIdx: 3}
const snapID = "snap-1"
stage := r.bankStage(roleTerminologist)
if stage.Reasoning != effort {
t.Fatalf("the gate's knob must reach the bank stage: want %q, got %q", effort, stage.Reasoning)
}
paid, err := r.bankCheckpointExists(stage, snapID, ch, msgs)
if err != nil {
t.Fatal(err)
}
if paid {
t.Fatal("a fresh store must report the batch as unpaid")
}
// Write a checkpoint under the hash the ATTEMPT path derives, through the PRODUCTION money
// sequence — then the probe must find it. Nothing here re-states the field list, so the test
// cannot pass by agreeing with a copy of the bug.
_, maxTokens := r.bankCallBudget(stage.Model, msgs)
hash := RequestHash(r.attemptRequest(stage, stage.Model, snapID, ch, 0, maxTokens, msgs))
if err := st.UpsertSnapshot(snapID, "", "{}"); err != nil {
t.Fatal(err)
}
job, err := st.EnsureJob("b1", 0, terminologyStageName, snapID)
if err != nil {
t.Fatal(err)
}
resv, verdict, err := st.Reserve("b1", 0.001, store.Ceilings{BookUSD: 100, DayUSD: 100})
if err != nil || verdict != store.ReserveOK {
t.Fatalf("reserve: %v verdict=%v", err, verdict)
}
if err := st.SettleWithCheckpoint(resv, 0.001, store.Checkpoint{
RequestHash: hash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Stage: terminologyStageName,
Role: roleTerminologist, ModelRequested: stage.Model, ModelActual: stage.Model,
ResponseText: "reply", UsageJSON: "{}", CostUSD: 0.001, FinishReason: "stop",
}); err != nil {
t.Fatal(err)
}
paid, err = r.bankCheckpointExists(stage, snapID, ch, msgs)
if err != nil {
t.Fatal(err)
}
if !paid {
t.Fatalf("the probe missed the checkpoint the attempt path addressed (hash %s) — the two have "+
"drifted apart, which turns the role sub-budget gate off", hash[:12])
}
})
}
}
// TestReasoningIsPartOfTheRequestIdentity pins the axis itself, changing exactly ONE field. The previous
// version varied the effort AND the model at once, so it would have passed with Reasoning removed from
// RequestHash entirely — a green test over the very defect it named.
func TestReasoningIsPartOfTheRequestIdentity(t *testing.T) {
r := &Runner{Book: &config.Book{BookID: "b1"}, Pipeline: &config.Pipeline{}, Models: &config.Models{}}
r.Pipeline.Gates.Terminology.Model = "m1"
msgs := []llm.Message{{Role: "user", Content: "candidates"}}
ch := chunk.Chunk{Chapter: 0, ChunkIdx: 3}
hashAt := func(effort string) string {
r.Pipeline.Gates.Terminology.Reasoning = effort
st := r.bankStage(roleTerminologist)
if st.Model != "m1" {
t.Fatalf("model must be held constant across the comparison, got %q", st.Model)
}
return RequestHash(r.attemptRequest(st, st.Model, "snap", ch, 0, 4096, msgs))
}
unset, low, high := hashAt(""), hashAt("low"), hashAt("high")
if unset == low || low == high || unset == high {
t.Fatalf("reasoning must be part of the request identity; got unset=%s low=%s high=%s",
unset[:12], low[:12], high[:12])
}
}
// TestRepairStageTakesItsEffortFromItsOwnGate pins the decision the adversarial review forced: repair does
// NOT inherit the repaired stage's effort. Inheriting moved the request hash of every already-paid repair
// call (every shipping pipeline sets `reasoning: "off"` on its final stage), and it re-pointed a value
// whose MEANING is per-control at a DIFFERENT model — a documented DeepSeek no-op would have armed a live
// thinking-disable on a GLM repair model.
func TestRepairStageTakesItsEffortFromItsOwnGate(t *testing.T) {
r := &Runner{Book: &config.Book{BookID: "b1"}, Pipeline: &config.Pipeline{}}
parent := config.Stage{Name: "edit", Role: roleEditor, Model: "m-edit", Reasoning: "off", Temperature: 0.4}
rst := r.repairStage(parent, "m-repair")
if rst.Reasoning != "" {
t.Fatalf("with gates.repair.reasoning unset the repair call must carry NO effort (byte-identical to "+
"the pre-key behaviour, so paid checkpoints survive), got %q", rst.Reasoning)
}
r.Pipeline.Gates.Repair.Reasoning = "low"
if got := r.repairStage(parent, "m-repair").Reasoning; got != "low" {
t.Fatalf("the repair gate's own key must reach the call, got %q", got)
}
if rst.Role != roleRepair || rst.Model != "m-repair" {
t.Fatalf("repair keeps its OWN role and model (the hash axis that separates it from the stage): %+v", rst)
}
if rst.Temperature != 0 {
t.Fatalf("a repair span is re-parsed structure, not prose — temperature stays 0, got %v", rst.Temperature)
}
}
// pipelinePkgPath is this package's own import path, for resolving `pipeline.Request{…}` written from
// outside it (eval/ harnesses do exactly this).
const pipelinePkgPath = "textmachine/backend/internal/pipeline"
// namedTypeExact is namedType WITHOUT the []/map/* unwrapping. `var ss []config.Stage` is a legitimate
// slice variable, not a hand-built stage, so unwrapping there would false-flag ordinary code — it did, on
// waveStages' parameter list.
func namedTypeExact(expr ast.Expr, imports map[string]string, pkgPath, name string) bool {
switch t := expr.(type) {
case *ast.Ident:
return t.Name == name && imports["."] == pkgPath
case *ast.SelectorExpr:
if t.Sel == nil || t.Sel.Name != name {
return false
}
id, ok := t.X.(*ast.Ident)
return ok && imports[id.Name] == pkgPath
}
return false
}
// namedTypeLocal matches an UNQUALIFIED type name, unwrapping composite-literal wrappers. Only meaningful
// for a file inside the package that declares the type.
func namedTypeLocal(expr ast.Expr, name string) bool {
switch t := expr.(type) {
case *ast.ArrayType:
return namedTypeLocal(t.Elt, name)
case *ast.MapType:
return namedTypeLocal(t.Value, name)
case *ast.StarExpr:
return namedTypeLocal(t.X, name)
case *ast.Ident:
return t.Name == name
}
return false
}

View file

@ -283,7 +283,7 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
// The signed bank is read ONCE for the whole role, not per batch: it is the same law for every batch. // The signed bank is read ONCE for the whole role, not per batch: it is the same law for every batch.
canon := r.approvedNeighbours() canon := r.approvedNeighbours()
plan := bankRolePlan{ plan := bankRolePlan{
role: roleTerminologist, model: r.Pipeline.Gates.Terminology.Model, budgetUSD: r.Pipeline.Gates.Terminology.BudgetUSD, role: roleTerminologist, budgetUSD: r.Pipeline.Gates.Terminology.BudgetUSD,
messages: func(b []terminology.Candidate) ([]llm.Message, error) { return r.terminologyMessages(b, canon) }, messages: func(b []terminology.Candidate) ([]llm.Message, error) { return r.terminologyMessages(b, canon) },
} }
run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "render") run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "render")
@ -392,7 +392,7 @@ func (r *Runner) runClassifier(ctx context.Context, snapID string, cands []termi
} }
batchRunes, _, _ := r.terminologyOpts() batchRunes, _, _ := r.terminologyOpts()
batches := terminology.Batch(cands, batchRunes, nil) batches := terminology.Batch(cands, batchRunes, nil)
plan := bankRolePlan{role: roleClassifier, model: g.ClassifierModel(), budgetUSD: g.ClassifyBudgetUSD, messages: r.classifierMessages} plan := bankRolePlan{role: roleClassifier, budgetUSD: g.ClassifyBudgetUSD, messages: r.classifierMessages}
run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "classify") run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "classify")
if err != nil { if err != nil {
return nil, run, err return nil, run, err
@ -481,24 +481,28 @@ const terminologyReplyFloor = 256
// bankCallEstimateUSD projects ONE call's cost with the same price/estimate arithmetic the reservation uses, // bankCallEstimateUSD projects ONE call's cost with the same price/estimate arithmetic the reservation uses,
// so the pre-call number and the reserved number are the same number. // so the pre-call number and the reserved number are the same number.
func (r *Runner) bankCallEstimateUSD(model string, msgs []llm.Message) float64 { func (r *Runner) bankCallEstimateUSD(st config.Stage, msgs []llm.Message) float64 {
_, maxTokens := r.bankCallBudget(model, msgs) _, maxTokens := r.bankCallBudget(st.Model, msgs)
promptEst := 0 promptEst := 0
for _, m := range msgs { for _, m := range msgs {
promptEst += EstimateTokens(m.Content) promptEst += EstimateTokens(m.Content)
} }
return ledger.EstimateUSD(r.Pricer.PriceFor(model), promptEst, maxTokens, // The buffer is read off the SAME stage the call will use rather than passed as a literal, so this line
r.Models.AdditiveReasoningTokens(model, "", 0)) // cannot drift from the attempt's. ⚠ It is structurally 0 on every path today and that is NOT because of
// the effort: AdditiveReasoningTokens ignores its effort argument entirely (D39.26 добор B) and returns 0
// whenever the declared buffer is 0, which InternalCall pins. The gate's additive-provider refusal
// (config.LoadPipeline) is what makes that safe rather than blind.
return ledger.EstimateUSD(r.Pricer.PriceFor(st.Model), promptEst, maxTokens,
r.Models.AdditiveReasoningTokens(st.Model, st.Reasoning, st.ReasoningMaxTokens))
} }
// bankCheckpointExists reports whether THIS batch was already paid for in an earlier run, on the role's own // bankCheckpointExists reports whether THIS batch was already paid for in an earlier run, on the role's own
// request-hash axis. // request-hash axis — the SAME identity runBankAttempt will address (attemptRequest), never a hand-rebuilt
func (r *Runner) bankCheckpointExists(role, model string, st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) { // copy of it: this probe gates the role sub-budget, so an identity that drifts from the attempt's turns the
_, maxTokens := r.bankCallBudget(model, msgs) // gate off (see attemptRequest).
cp, err := r.Store.GetCheckpoint(RequestHash(Request{ func (r *Runner) bankCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: 0, _, maxTokens := r.bankCallBudget(st.Model, msgs)
Stage: st.Name, Role: role, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs, cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, st.Model, snapID, ch, 0, maxTokens, msgs)))
}))
return cp != nil, err return cp != nil, err
} }
@ -508,27 +512,58 @@ func (r *Runner) bankCheckpointExists(role, model string, st config.Stage, snapI
// isFinal=false: the reply is a term table, not shipping prose, so the output sanitizer must not judge it; // isFinal=false: the reply is a term table, not shipping prose, so the output sanitizer must not judge it;
// the intrinsic classifier still runs and is a real guard (a refusal reply would otherwise be parsed as // the intrinsic classifier still runs and is a real guard (a refusal reply would otherwise be parsed as
// terminology). // terminology).
func (r *Runner) runBankAttempt(ctx context.Context, role, model string, st config.Stage, snapID string, ch chunk.Chunk, func (r *Runner) runBankAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk,
job *store.Job, msgs []llm.Message) (stageAttempt, error) { job *store.Job, msgs []llm.Message) (stageAttempt, error) {
_, maxTokens := r.bankCallBudget(model, msgs) _, maxTokens := r.bankCallBudget(st.Model, msgs)
tst := config.Stage{Name: st.Name, Role: role, Model: model}
// The log axis, exactly as runStage sets it for a chunk stage: without it every bank-role line in a paid // The log axis, exactly as runStage sets it for a chunk stage: without it every bank-role line in a paid
// run reads "calling model deepseek-v4-flash" with no book, no role and no batch — unattributable in a // run reads "calling model deepseek-v4-flash" with no book, no role and no batch — unattributable in a
// multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch ordinal. // multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch ordinal.
ri, _ := obs.ReqInfoFromContext(ctx) ri, _ := obs.ReqInfoFromContext(ctx)
ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, tst.Name, role ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, st.Role
ctx = obs.WithReqInfo(ctx, ri) ctx = obs.WithReqInfo(ctx, ri)
return r.runAttempt(ctx, tst, model, snapID, ch, job, 0, maxTokens, msgs, false, false) return r.runAttempt(ctx, st, st.Model, snapID, ch, job, 0, maxTokens, msgs, false, false)
}
// bankRoleSettings resolves what a bank ROLE is called with. The classifier phase may run its OWN model
// (classification is cheaper than a render), but BOTH phases share ONE effort knob — deliberately, and on
// evidence: paired samples of the ratified 6/6 acceptance set put the classifier at 4/5 on `low` against
// 5/5 at `high`, which n=5 cannot separate, while `high` costs 2.3× per call. The render phase, by
// contrast, is FORCED to a low effort (at the vendor default it burns the whole budget thinking and
// returns an empty body). One level satisfies both, so a second key would be a split nothing measured.
func (r *Runner) bankRoleSettings(role string) (model, reasoning string) {
g := r.Pipeline.Gates.Terminology
switch role {
case roleClassifier:
return g.ClassifierModel(), g.Reasoning
case roleTerminologist:
return g.Model, g.Reasoning
}
// A future bank role (the annotator, the Ф2 judge this seam anticipates) must not silently inherit the
// terminologist's model and effort: that is the same "a default is indistinguishable from a decision"
// shape this pack exists to remove. Panic is right here — the role set is a compile-time constant of the
// engine, so reaching this is a programming error, not a config one, and it can only happen before any
// money moves.
panic("pipeline: bankRoleSettings has no settings for bank role " + role + " — add its resolution to the gate")
}
// bankStage is the ONE place a bank-role call's stage is derived — for BOTH roles and for the live rig, so
// "the classifier probe measured the production shape" is structural rather than two literals kept in step
// by hand. The settings come from the gate that owns the call class (config.InternalCall).
func (r *Runner) bankStage(role string) config.Stage {
model, reasoning := r.bankRoleSettings(role)
return config.InternalCall{Name: terminologyStageName, Role: role, Model: model, Reasoning: reasoning}.Stage()
} }
// bankRolePlan is one bank-level role's plan over a batch list: its cost axis (role/model), its own book-wide // bankRolePlan is one bank-level role's plan over a batch list: its cost axis (role/model), its own book-wide
// ceiling, and how it builds one batch's wire messages. The money sequence — estimate, budget-gate, // ceiling, and how it builds one batch's wire messages. The money sequence — estimate, budget-gate,
// checkpoint-or-call — is identical for the classifier and the terminologist; only the messages and the reply // checkpoint-or-call — is identical for the classifier and the terminologist; only the messages and the reply
// PARSING differ, and parsing is the caller's job on the returned texts. // PARSING differ, and parsing is the caller's job on the returned texts.
// ⚠ NO `model` field: the model is derived from the role by bankRoleSettings, inside bankStage. It used to
// be carried here too and read only by a log line, which made the log a SECOND source of truth about what
// was billed — the exact shape this pack removed everywhere else.
type bankRolePlan struct { type bankRolePlan struct {
role string role string
model string
budgetUSD float64 budgetUSD float64
messages func(batch []terminology.Candidate) ([]llm.Message, error) messages func(batch []terminology.Candidate) ([]llm.Message, error)
} }
@ -548,6 +583,9 @@ type bankRoleRun struct {
// aborts on this optional step. logKind names the phase (render|classify) in the logs. // aborts on this optional step. logKind names the phase (render|classify) in the logs.
func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) { func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) {
run := bankRoleRun{texts: make([]string, len(batches))} run := bankRoleRun{texts: make([]string, len(batches))}
// ONE stage for the whole pass — the estimate, the checkpoint probe and the attempt all read it, so the
// three can never be sized against different knobs (the estimate is the reservation's own upper bound).
st := r.bankStage(plan.role)
msgsPer := make([][]llm.Message, len(batches)) msgsPer := make([][]llm.Message, len(batches))
for i, b := range batches { for i, b := range batches {
m, err := plan.messages(b) m, err := plan.messages(b)
@ -555,17 +593,16 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
return run, err return run, err
} }
msgsPer[i] = m msgsPer[i] = m
run.estimateUSD += r.bankCallEstimateUSD(plan.model, m) run.estimateUSD += r.bankCallEstimateUSD(st, m)
} }
r.Log.InfoContext(ctx, "terminology "+logKind+": estimate before any call", r.Log.InfoContext(ctx, "terminology "+logKind+": estimate before any call",
"book", r.Book.BookID, "role", plan.role, "batches", len(batches), "model", plan.model, "book", r.Book.BookID, "role", plan.role, "batches", len(batches), "model", st.Model, "reasoning", st.Reasoning,
"estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD), "budget_usd", plan.budgetUSD, "version", terminologyVersion) "estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD), "budget_usd", plan.budgetUSD, "version", terminologyVersion)
spent, err := r.Store.RoleSpentUSD(r.Book.BookID, plan.role) spent, err := r.Store.RoleSpentUSD(r.Book.BookID, plan.role)
if err != nil { if err != nil {
return run, fmt.Errorf("pipeline: %s budget read: %w", plan.role, err) return run, fmt.Errorf("pipeline: %s budget read: %w", plan.role, err)
} }
st := config.Stage{Name: terminologyStageName, Role: plan.role, Model: plan.model}
job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID) job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
if err != nil { if err != nil {
return run, fmt.Errorf("pipeline: %s job: %w", plan.role, err) return run, fmt.Errorf("pipeline: %s job: %w", plan.role, err)
@ -574,19 +611,19 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
// The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the // The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the
// batch ordinal is the chunk index, so two batches can never collide on one checkpoint. // batch ordinal is the chunk index, so two batches can never collide on one checkpoint.
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i} ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
paid, perr := r.bankCheckpointExists(plan.role, plan.model, st, snapID, ch, msgsPer[i]) paid, perr := r.bankCheckpointExists(st, snapID, ch, msgsPer[i])
if perr != nil { if perr != nil {
return run, perr return run, perr
} }
// The budget is checked BEFORE the call and against what the call would COST (the reservation's own // The budget is checked BEFORE the call and against what the call would COST (the reservation's own
// upper bound), so it refuses on the conservative side and the config number is the bound it looks like. // upper bound), so it refuses on the conservative side and the config number is the bound it looks like.
if want := r.bankCallEstimateUSD(plan.model, msgsPer[i]); !paid && spent+want > plan.budgetUSD { if want := r.bankCallEstimateUSD(st, msgsPer[i]); !paid && spent+want > plan.budgetUSD {
r.Log.WarnContext(ctx, "terminology "+logKind+": budget would be exceeded by the next batch; the remaining terms are left unchanged", r.Log.WarnContext(ctx, "terminology "+logKind+": budget would be exceeded by the next batch; the remaining terms are left unchanged",
"book", r.Book.BookID, "role", plan.role, "spent_usd", fmt.Sprintf("%.6f", spent), "book", r.Book.BookID, "role", plan.role, "spent_usd", fmt.Sprintf("%.6f", spent),
"next_batch_usd", fmt.Sprintf("%.6f", want), "budget_usd", plan.budgetUSD, "batches_left", len(batches)-i) "next_batch_usd", fmt.Sprintf("%.6f", want), "budget_usd", plan.budgetUSD, "batches_left", len(batches)-i)
break break
} }
att, aerr := r.runBankAttempt(ctx, plan.role, plan.model, st, snapID, ch, job, msgsPer[i]) att, aerr := r.runBankAttempt(ctx, st, snapID, ch, job, msgsPer[i])
if aerr != nil { if aerr != nil {
// A ceiling denial must not abort the book: this step is optional and the draft wave is already // A ceiling denial must not abort the book: this step is optional and the draft wave is already
// paid for. Degrade to "no change" exactly as the repair sub-step degrades. // paid for. Degrade to "no change" exactly as the repair sub-step degrades.

View file

@ -0,0 +1,56 @@
# Промт бэкенд-сессии: индустриальные стандарты репозитория — линтер · CI · бамп зависимостей · гарды-в-analyzer (строки 107 · 112 · Д7)
**Мандат:** решение владельца 02.08 (D39.92): «делаем по стандартам индустрии: CI, линтер, бамп x/net и всех библиотек; осторожный, затем полный подход; спилить самописные AST-тест-гарды в пользу штатной формы». Ограничение владельца, равное по силе самому мандату: **стандарты не должны мешать разрабатывать, тормозить и приводить модели к хакам.**
**Онбординг:** CLAUDE.md → `docs/README.md` → CURRENT-STATE в `docs/PROGRESS.md` → голова D39.9192 в `05-decisions-log.md``backend/README.md` + `12-go-style-notes.md`. Контекст предмета: отчёт `archive/reports/EFFORT_HANDLE_2026-08-02.md` §1.3(2), §5.5, Д7; D39.90 п.7; строка 107 трекера.
## §0. Скоуп, деньги, стоп-правила
- Зона записи: `backend/` ТОЛЬКО. НЕ коммитить — лендит оркестратор. `git add`/`commit` не запускать вовсе; `.env` не читать.
- Деньги: этапы 0C и E — **$0**. Этап D (бамп зависимостей) включает живую мини-пробу провода: потолок **$0.50**, каждый цент в леджер, тела предъявить.
- **Анти-хак-правило (владелец):** запрещено «зеленить» линтер обходами — переименованиями без смысла, пустыми обёртками, дроблением функций ради метрики, гашением через рефактор-мусор. Спорное правило линтера → ВЫКЛЮЧИТЬ правило в конфиге с одной строкой причины — это честнее, чем хак в коде. Голый `//nolint` без причины запрещён (включить `nolintlint`).
- **СТОП-пинги оркестратору:** этап A даёт >1000 находок → пинг с числами ДО любых правок · этап B требует правок >30 файлов разом → пинг с планом · этап D меняет поведение провода (красный квирк-тест, флейк пробы, новый заголовок/фрейминг) → СТОП, откат бампа, пинг. Молча продавливать нельзя ни одно из трёх.
- Мандат самопроверки (CLAUDE.md 12.07): ревью ИСПОЛНЕНИЕМ своего кода и конфигов; адверсариальные посадки для этапа E обязательны; спорное — author≠reviewer.
## Этап 0 (15 минут, независим от остального): строка 112 — санкционированная правка пяти шиппинг-конфигов
`stages[draft].reasoning: "off"``"low"` в: `configs/pipeline-c1.yaml:49` · `pipeline-c2.yaml:26,39` · `pipeline-arm-mistral.yaml:30` · `pipeline-arm-glm.yaml:34` · `pipeline-arm-deepseek-pro.yaml:34`. Санкция владельца 02.08 (D39.92); перекупка снапшотов старых книг принята, новые книги $0.
- ⚠ **НЕ ТРОГАТЬ** `reasoning: "off"` на РЕДАКТОРСКИХ/финальных стадиях (`c1:77` · `c2:51` · `arm-glm:43` · `arm-deepseek-pro:43`) — они осознанные (D39.91 Д11: на glm это живой thinking-off от таймаутов ×3, на dspro — no-op-страховка эхо-мины).
- ⚠ У arm-mistral черновик — проверь МОДЕЛЬ стадии прежде чем править: ключ имеет смысл только там, где стадия резолвится в reasoning-модель; no-op на не-reasoning модели можно оставить `"off"` с комментарием, но реши ЯВНО и запиши.
- После правки: `tmctl status --config` на каждом из пяти + полная батарея. В отчёт: дифф построчно, что не тронуто и почему.
## Этап A ($0-замер, НИЧЕГО не чинить): объём находок стандартного набора
1. `golangci-lint` ПИНОВАННОЙ версией (бинарь вне репо, версию в отчёт) — дефолтный набор + `staticcheck, errcheck, unused, ineffassign, misspell, nolintlint` в **report-only** на `./backend/...`.
2. Отчёт ЧИСЛАМИ: находки по правилам × по пакетам × prod/test раздельно. Ни одной правки на этом этапе — это замер, санкционированный владельцем как первый шаг (D39.90 п.7).
## Этап B (состав и режим — предложение, решение с оркестратором)
- Предложить `.golangci.yml`: каждое правило ВКЛ/ВЫКЛ с обоснованием ЧИСЛОМ из этапа A (не вкусом). База: `go vet` уже чист — зафиксировать.
- Режим внедрения: baseline «только новый/правленый код» (`--new-from-rev`) против big-bang по правилам с малым хвостом (например, всё с ≤20 находками — big-bang, остальное — baseline). Предложить, НЕ исполнять до ответа на СТОП-пинг, если хвост большой.
- `exhaustruct` НЕ включать глобально (16 находок, 15 в тестах — замерено §5.5); точечно на `config.Stage`/`pipeline.Request` рассмотреть — это ровно класс исходного бага.
## Этап C (CI + Makefile: одна команда для всех)
- `Makefile` (или `make.go`): цели `build · vet · lint · test · battery` — где `battery` = ровно текущая ручная батарея (`build && vet && vet -tags live && gofmt -l && test -race -count=1` + голден/парити/labels/K6 по env-флагам). Сессии перестают собирать её руками.
- CI-воркфлоу: проверь `git remote -v`; если удалёнки/раннера нет — файл workflow подготовить (`go build/vet/lint/test`), обязательной точкой входа до его оживления становится `make battery`. Пины версий тулчейна и линтера в конфиге, не latest.
- Прекоммит: у фронта уже стоит самоустанавливающийся гейт (`7832be7`, `frontend/scripts/githooks/`) — НЕ ломать и НЕ дублировать; если расширяешь на backend-пути, только согласованной правкой через оркестратора (это смежная зона).
## Этап D (бамп зависимостей — осторожно: x/net это наш транспорт)
- Бамп прямых зависимостей `go.mod` (включая `x/net` 0.26 → текущий, `x/tools` под этап E). Go toolchain — только если требуется, отдельной строкой в отчёте.
- ⚠ `x/net/http2` — транспорт ко ВСЕМ провайдерам. После бампа обязательны: полная батарея · голден (PASS без пере-захвата — вердикты не двигаются) · **живая мини-проба провода на КАЖДЫЙ провайдер** из `models.yaml` (1 короткий вызов; потолок $0.50 суммарно; сверить: тела, ключи запроса, `reasoning_content`, finish_reason, отсутствие новых заголовков/фреймингов против `00-provider-quirks.md`). Любое расхождение = СТОП-пинг, откат.
## Этап E (гарды → analyzer; снос тест-гардов ТОЛЬКО после доказанного паритета)
Предмет: `synthetic_stage_seam_test.go` (два AST-гарда + пол числа файлов) и давний байтовый `TestProviderEgressSeamIsSingle` (Д7) → ОДИН обход на `x/tools/go/analysis` с `types.Info` (алиасы/дот-импорты закрываются по построению), запуск через `go vet -vettool=` из `make battery`/CI.
- **Инварианты — предмет контракта, не форма:** (i) `config.Stage` строится вне лоадера только швом `InternalCall.Stage()`; (ii) `pipeline.Request` собирается только в `attemptRequest`; (iii) egress-инвариант старого гарда — перенести его словарь исключений осознанно, не копипастой; (iv) рефлексионный `TestInternalCallDecidesEveryStageField`НЕ трогать, он не про обход дерева и в analyzer не нуждается.
- **Паритет посадками:** все 12 известных обходов (5 байтовых + алиас + `[]config.Stage{{…}}` из отчёта §1.3; `var`-форма, `new()`, hoisted `Request` + алиас-литерал + слайс — посадки приёмки D39.91) воспроизвести как тест-кейсы АНАЛИЗАТОРА (`analysistest`). Analyzer обязан ловить все 12 + иметь эквивалент пола «я реально видел файлы».
- ⚠ **Известная слепая зона `go/packages`:** старый гард ходил и по `eval/` ВНЕ Go-модуля (исторически там жил четвёртый сайт дефекта). Реши явно: гонять analyzer и на eval-модуле, или закрыть этот класс иначе — и запиши решение.
- Снос `_test.go`-гардов — ПОСЛЕДНИМ коммитом пака, только при 12/12 у analyzer. До того оба механизма живут параллельно.
## Отчёт
`docs/archive/reports/REPO_STANDARDS_<дата>.md`: эхо-шапка · числа этапа A · состав+режим B с обоснованиями · что даёт `make battery` · дифф бампа и предъявление живой пробы D · паритет-таблица 12 посадок E · снесённое/оставленное · найдено-не-починено с носителями · «заявление = команда» на каждое число. **СТОП — приёмка оркестратора.**

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.90 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07) # Журнал решений оркестратора — контракт D1D39.91 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`. > ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`.
@ -1332,3 +1332,55 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
- **Дописано в строку 46:** порог abstain 200 букв · база off-target на ЧЕРНОВОЙ вызов **3.02%**, а не опубликованные ~1.5% (знаменатель пулил редакторские выходы — третья самопоправка сессии) · **не проверено, что редактор делает с английским черновиком** — от этого зависит, чем являются остаточные 0.18%: потерей юнита или тихо отгруженным релейным zh→en→ru · `cultivation` (86 из 1055 здоровых) чинить в СИДЕ, а не порогом · Hunspell/GlotScript-эмбед не строить. - **Дописано в строку 46:** порог abstain 200 букв · база off-target на ЧЕРНОВОЙ вызов **3.02%**, а не опубликованные ~1.5% (знаменатель пулил редакторские выходы — третья самопоправка сессии) · **не проверено, что редактор делает с английским черновиком** — от этого зависит, чем являются остаточные 0.18%: потерей юнита или тихо отгруженным релейным zh→en→ru · `cultivation` (86 из 1055 здоровых) чинить в СИДЕ, а не порогом · Hunspell/GlotScript-эмбед не строить.
- **Строка 108 получила первого жильца:** сиблинг-угроза (выход на языке, близком к целевому) с дискриминатором «плотность ы/э/ё НИЖЕ ожидания, а не замыкание алфавита». **Строка 36б приведена в соответствие** собственному снятию (зона → полигон-замер через строку 5; прежняя форма «отдельный дизайн (Р4)» завышала счёт бэкенд-очереди). - **Строка 108 получила первого жильца:** сиблинг-угроза (выход на языке, близком к целевому) с дискриминатором «плотность ы/э/ё НИЖЕ ожидания, а не замыкание алфавита». **Строка 36б приведена в соответствие** собственному снятию (зона → полигон-замер через строку 5; прежняя форма «отдельный дизайн (Р4)» завышала счёт бэкенд-очереди).
- **Норма, нарушенная этим же лендингом и потому названная вслух:** сжимая отчёт, я сохранил всё, у чего есть число, и потерял часть того, у чего числа нет. Для отчётов с секцией «пошаговый план следующей сессии» правило сверки — идти по ней ПУНКТАМИ, а не по сводке. - **Норма, нарушенная этим же лендингом и потому названная вслух:** сжимая отчёт, я сохранил всё, у чего есть число, и потерял часть того, у чего числа нет. Для отчётов с секцией «пошаговый план следующей сессии» правило сверки — идти по ней ПУНКТАМИ, а не по сводке.
## D39.91 — ПРИЁМКА бэкенд-пака строк 104/46/16 (оркестратор №11): пак ПРИНЯТ и заленден `553f1a3`, обе девиации РАТИФИЦИРОВАНЫ, две находки приёмки, все отложки Д4Д9 получили носителей, строка 104 ЗАКРЫТА (02.08). ✅
**Мандат — D39.90 п.1 и задача №1 хендоффа. Всё ниже — исполнением, не чтением отчёта.**
**1. Батарея с фрозен-числами: 5/5 совпали.** `build`/`vet`/`vet -tags live`/`gofmt` чисты, `go test -race -count=1 ./...` — все 14 пакетов ok (pipeline 82.2s). Голден PASS **без пере-захвата** · парити EXACT `n=13618 catastrophe{方源:0 蛊:1 蛊师:2 古月:22} recall@proposed=0.9655 (56/58)` · labels-фриз `k4b 10/1/36/99 · k4_inverse 3/0/1/71 · k2 34/1/6/51 · k4a 0/0/0/348` · K6 `tp=1 fp=6 fn=0 tn=251`.
**2. Деньги вторым путём: сошлись до нуля тремя маршрутами.** Леджер Σ чекпойнтов $0.045095 ≡ мой независимый пересчёт usage×цены `models.yaml` (flash $0.034745 + pro $0.010350) ≡ Σ `request_log.cost_usd`; `decode_error` 0, `estimated` 0 (расхождения строки 78 не было). Разбивка по ролям сверена с логом: терминолог $0.005750 (5 вызовов, 4 stop/1 length) · классификатор $0.007706 = осн. $0.004286 + риг $0.003420 · черновик flash $0.021289 (23 вызова: 20 stop + 3 length-ретрая) + pro $0.010350 (2 эскалации). **НАХОДКА-1 приёмки:** §2.1 отчёта и D39.90 п.2 маркируют «$0.031639 (flash)» — на деле это **flash+pro черновой волны** ($0.021289+$0.010350); суммы и леджер верны, врёт метка. Поправлено ревью-шапкой отчёта; при цитировании черновая волна flash-only = $0.021289.
**3. Адверсариал по несущим клеймам — все выдержали.** (а) Рукописных `config.Stage{}` в не-тестовом коде НОЛЬ (grep), единственный `Request{}`-литерал — внутри самого `attemptRequest` (`stagerun.go:407`); все четыре `RequestHash`-сайта едут через шов (`escalation.go:109`, `stagerun.go:420`, `terminologist.go:505`, `repair.go:353`). (б) Перекупки нет: `snapshot.go` паком не тронут (diff пуст), `Gates.Terminology` в снапшоте отсутствует, `repairSnap` нового ключа не фолдит; обратная сторона проверена и признана штатной — СМЕНА `gates.*.reasoning` потом двигает request-hash без resnapshot-гейта, но это тот же контракт, что у смены `gates.*.model` (перекупка ограничена классом вызовов гейта, видима леджером; доккоммент `TerminologyGate` это аргументирует). (в) Гарды: **пять НЕЗАВИСИМЫХ посадок приёмки** (алиас-импорт-литерал · `[]cfg.Stage{{…}}` · hoisted `Request` · `var`-форма · `new()`) — все CAUGHT с позициями, после удаления посадок гарды зелёные; рефлексионный гард полноты верифицирован чтением (XOR карт carried/deliberatelyZero корректен в обе стороны). (г) ⚠ Клейм «`8e4495006b978ed4` в обе стороны» НЕВОСПРОИЗВОДИМ как таковой (старая форма удалена из дерева, хеш не запинен тестом) — принят ПО ЭКВИВАЛЕНТНОСТИ: `RequestHash` длина-префиксует `req.Reasoning`, опущенное поле композитного литерала = `""` побайтно, плюс `TestBankProbeAndAttemptAddressOneCheckpoint` (боевой путь `Reserve``SettleWithCheckpoint`, 3 уровня эффорта) и голден без пере-захвата. (д) N=5 сверен с сырьём `classifier-6of6-{low,high}.json`: low 4/5 · $0.001016 · completion 911278 · промах run3=91 ток. с 1/4 по дискриминирующему подмножеству; high 5/5 · $0.002335 · 9203104; цена ×2.298. (е) Провод: 35 тел `reasoning_effort:"low"`, ключей `thinking` 0, бюджетных WARN 0; `terminology finished`: consolidated=72 · unanswered=18 · reclassified=14 · bad_lines=0 · off_language=0.
**4. Девиации строки 104 — обе РАТИФИЦИРОВАНЫ этой нотой.** (а) **Repair: ключ СВОЙ, наследование снято** (против буквы промта и D39.87) — деньги и семантика подтверждены исполнением: `reasoning: "off"` стоит на финальной стадии всех пяти шиппинг-конфигов (`c1:77` · `c2:51` · `arm-glm:43` · `arm-deepseek-pro:43`, + стенд `coldrun-b:44`) ⇒ наследование двигало хеш всех оплаченных repair-вызовов; и то же слово «off» на другой модели меняет СМЫСЛ (no-op deepseek → живой `thinking:{type:disabled}` на glm). Запинено `TestRepairStageTakesItsEffortFromItsOwnGate`. (б) **`ThinksOnWire`**: область правки ровно случай «ключ отсутствует» на `extra_body_disable` — провод (`applyToBody`: `""`→disable) существовал ДО пака и не менялся, менялся врущий предикат; явный `off` в армах предупреждался и до, и после (Д11); 8 кейсов теста на три control. (в) Вывод «одна ручка» принят с оговоркой Д8 (2/6 термов — припоминание; носитель Д8 — строка 116). (г) Качество прозы не судилось — остаётся на строках 16/13б, в контракт этой приёмки не входит.
**5. НАХОДКА-2 приёмки: стейл-доккоммент `capability.go:78`.** Комментарий константы `ReasoningExtraBodyDisable` до сих пор говорит «effort "" leaves the provider default», противореча и проводу (`applyToBody` мержит disable на `""`), и inline-коменту самого arm, и новому предикату. Пак чинил ровно эту путаницу (D39.86-обязанность доккомментов) и хвост в 30 строках выше собственной правки не заметил. Однострочный фикс — носитель: строка 114 (тем же касанием, что Д4).
**6. Носители отложек — гигиена D39.90 исполнена ТЕМ ЖЕ лендингом.** **114** = Д4 (`gates.terminology.target_script` не сверяется с `book.target_lang` — мисконфиг тихо ИНВЕРТИРУЕТ банковский экран) + находка-2 (оба — конфиго-слой ручки, вне снапшота, дёшево). **115** = Д6 (`//go:embed` в `lang/embedded.go:22` — ЯВНЫЙ список файлов: новая ЦЕЛЬ требует правки Go, `CompileCheckers` паникует на неполном файле цели ⇒ на ревью-вопрос общности ответ сегодня НЕТ — дыра в ратифицированной «ФАЗЕ 2 ОБЩНОСТИ ✅» D39.64, и она не про язык, а про строку эмбеда). **116** = Д8 (ратифицированный порог классификатора «6/6» определён для ОДНОГО вызова; на рекомендованном `low` риг N=5 красный — порог обязан стать выборочным контрактом ДО применения (б) где-либо; решается вместе со строкой 112). **Реестр 108** получил двух жильцов: Д5 (enum эффорта провайдер-слеп; решение оркестратора этой нотой — пер-модельную таблицу НЕ строить: новый механизм без носителя качества/денег, misconfig ограничен бюджетами и назван в доккомменте `ValidReasoningEffort`; триггер реопена — первый живой инцидент кривого поведения уровня на проводе) и Д9 (аддитивные провайдеры отказываются бэнк/repair-гейтам — блок не несёт `reasoning_max_tokens`; лоадер fail-closed; триггер — первая потребность гейта в аддитивной модели). **107** дописан Д7 (свести давний байтовый `TestProviderEgressSeamIsSingle` с AST-обходом в один — тем же решением формы гардов). Д1→46 · Д2→46 · Д3→105 · Д10→112 · Д11 закрыт — носители существовали до приёмки, сверено.
**7. Сверка §5.4 отчёта ПУНКТАМИ (правило D39.90 п.9 — по плану, не по сводке): потерь сжатия НЕТ.** Шаги 12 (корпус-фикстура + регресс-тест запаса) → строка 113 ✓ · шаг 3 (два дока-дефекта: 0.9510→0.9767 на 1055, база 1.5%→3.02%) — УЖЕ исполнен №10 в теле строки 46, других публикаций неверных чисел grep не нашёл ✓ · шаги 46 (чистая функция → офлайн-дифф → бамп `classifierVersion` с подписью) → тело строки 46 ✓ · шаг 7 (`cultivation` в сиде, не порогом) → хвост 46 ✓ · шаг 8 (сиблинг строкой с триггером) → первый жилец 108 ✓.
**8. Лендинг и очередь.** Пак закоммичен pathspec-формой: `553f1a3`, ровно 13 файлов backend/, чужие файлы (фронт-сессия ЖИВАЯ, её правки в дереве) и `START_PROMT.MD` не тронуты. Отчёту дана ревью-шапка ПРИНЯТ. Очередь скриптом по таблице: **97 строк (95 закрытая 104 + новые 114/115/116) · «скоро» 15 (2·4·5·18·46·49·55·95·107·108·109·112·113·114·116) · блокеров 0 · «на приёмке» 0**; гейт строки 44 «при приёмке 104» наступил — строка пере-диспозиционирована (вариационный замер флора при `low` не проводился, вопрос числа жив). **На владельце без изменений три решения:** развилка (а)/(б)/(в) строки 46 — выбор сессии И приёмки: **(а)**; строка 107 (форма гардов + линтер/CI; первым шагом $0-замер); строка 112 (применять ли (б) к пяти шиппинг-конфигам и когда платить перекупку — теперь в связке со 116).
## D39.92 — РЕШЕНИЯ ВЛАДЕЛЬЦА 02.08 (вечер): строка 46 пере-поставлена как closed-set «не тот язык, что просили», строка 107 САНКЦИОНИРОВАНА целиком (CI+линтер+бампы, промт выдан), строка 112 САНКЦИОНИРОВАНА, вендор-сверка DeepSeek исполнена. ✅
**1. Строка 46 — переформулировка владельца ратифицирована как ПОСТАНОВКА задачи.** Слово владельца: нужен не языковой детектор, а детектор «вернуло НЕ на том языке, что просили» (closed-set, нулевая гипотеза «текст на N», аттракторы: en · язык источника · сиблинги N); полноценный LID — максимум запасной флаггер ВНЕ пути вердикта; «сбой» — четыре режима (эхо · уход в другой язык · дрейф в середине · мета/деградация), гнать всё через один детектор — ошибка проектирования; в коде проверку изолировать отдельным слоем. **Констатация приёмки: ресёрч бэкендеров ЗАПИСАН и отвечает этой постановке почти пункт-в-пункт** (`EFFORT_HANDLE_2026-08-02.md` §5.15.3, живые носители — строки 46/113/108): four-modes = Guerreiro (разные детекторы на режим, §5.1); closed-set и аттракторы подтверждены (en — доминирующий аттрактор, наши 2/20 английские); эхо у нас ловится БЕЗ LID и лучше Левенштейна — `sourceScriptShare` 18/20 при 0 FP и запасе 13× (для zh→ru письменности далёкие); слой «скрипт детерминированно» = и есть предлагаемый экран `targetScriptShare` на `lang.LangScripts` (шаг 4 плана §5.4 — ЧИСТОЙ изолированной функцией, ровно «изолировать отдельно»); abstain-вердикт есть (пол 200 букв); срез терминов/латиницы = `cultivation` в сиде. **Расхождения с текстом владельца — ДВА, оба замером на нашем корпусе:** (а) гарантия Неймана–Пирсона FPR ≤ 0.1% на этом корпусе не покупается — независимых позиций 114, правило трёх даёт ≤ 2.594% (в 26 раз слабее), а калибровка порога на той же выборке двоит данные ⇒ порог ставится консервативной КОНСТАНТОЙ в пустом интервале [0.0000; 0.9767] и версионируется как контракт (не как тюнинг); (б) замыкание алфавита (L2) и посегментный worst-of LR (L4) на СЕГОДНЯШНЕМ корпусе отвергнуты числами (болгарский алфавит ⊂ русского — L2 нулевой; у L4 ноль положительных примеров и умножение FPR на число сегментов; lingua-go недетерминистична 6/23 на 20k повторов даже в closed-set режиме — в путь вердикта нельзя, как ЗАПАСНОЙ офлайн-флаггер вне вердикта допустима) — это отказ ПО ДАННЫМ ОДНОЙ КНИГИ, при сиблинг-инциденте реестр 108 возвращает вопрос (дискриминатор уже назван: плотность ы/э/ё ниже ожидания). Слой 0 владельца (отношение длин len(out)/len(in) вне полосы пары) в ресёрче НЕ рассматривался — добавить ЗАМЕРОМ на корпус-фикстуре строки 113 (дёшево, решит фикстура). **Исполнение:** форма (а) из D39.90 п.3 + изолированный модуль; порядок §5.4 в силе (фикстура 113 → чистая функция → офлайн-дифф → бамп `classifierVersion`); ⚠ денежный шаг 6 (бамп версии = снапшот всех книг) — ПО-ПРЕЖНЕМУ под отдельную подпись владельца, сегодня не санкционирован.
**2. Строка 107 — САНКЦИЯ ЦЕЛИКОМ: «делаем по стандартам индустрии».** CI + линтер + бамп `x/net` и библиотек; подход «осторожный, затем полный»; самописные AST-тест-гарды заменить штатной формой, «спилив лишний мусор»; ограничение равной силы: **стандарты не должны мешать разрабатывать, тормозить и приводить модели к хакам** (в промте — анти-хак-правило: спорное правило линтера ВЫКЛЮЧАЕТСЯ с причиной, а не обходится в коде; голый nolint запрещён). Промт выдан: `docs/BACKEND_REPO_STANDARDS_SESSION_PROMPT.md` — этапы: 0) строка 112 · A) $0-замер report-only · B) состав+режим по числам · C) Makefile/`make battery`+CI · D) бамп с живой пробой провода ≤$0.50 (x/net/http2 = транспорт) · E) гарды→analyzer с паритетом 12 посадок, снос тест-гардов только после 12/12 (Д7 исполняется там же). Три СТОП-пинга: >1000 находок · >30 файлов правок разом · любое изменение поведения провода.
**3. Строка 112 — САНКЦИЯ: править все пять шиппинг-конфигов сейчас** (`reasoning: "low"` на черновике), перекупка снапшотов старых книг при их следующем прогоне принята, новые книги $0. Внесено этапом 0 промта п.2; редакторские/финальные `off` не трогаются (Д11).
**4. Строка 116 — рекомендация оркестратора подана владельцу:** порог классификатора пере-ратифицировать выборочным — **«≥4/5 прогонов N=5 достигают 6/6»** (пер-прогонный критерий 6/6 сохраняется, контракт становится выборочным; совпадает с замеренным `low` и дефолтом рига). Ждёт слова владельца; альтернатива, если хочет жёстче: порог по дискриминирующему подмножеству 4/4 без двух термов-припоминаний.
**5. Вендор-сверка DeepSeek исполнена по правилу 10.07 (веб, не гадание).** Вопрос владельца: «полного лечения эха нет? модель хуже прежней? мигрировать?» Ответ тремя фактами: (а) наши замеры — `low` стену НЕ убирает, а разрежает (1/5 батчей всё ещё умирает на потолке с `reasoning=7999`), эхо лечится эскалацией (2/20 в пробе), т.е. РЕЖИМ УПРАВЛЯЕМ, но не вылечен; (б) интернет ДЕГРАДАЦИЮ НЕ подтверждает: 0731 = официальный релиз flash (re-post-train, бенчи ВВЕРХ, вкл. agentic) — наш отказ это не «модель хуже», а СМЕНА ПОЛИТИКИ ДЕФОЛТА (thinking по умолчанию `high`) на плотном хане, и ключ `reasoning_effort` — штатный вендорский рычаг, который мы теперь и используем; (в) ⚠ вендор анонсировал смену МАППИНГА эффорта `deepseek-v4-pro` «early August 2026» ⇒ квирк 3а («у pro эффорт не настраивается, low→high») может устареть со дня на день — **вахта заведена в реестр 108** (триггер: аномалия редакторского арма / дата > 05.08 ⇒ пере-проба маппинга + сверка changelog; абсорбция в 00-provider-quirks — полигоном по его зоне). Миграция черновика с deepseek — вопрос ЗАМЕРА, не решения сейчас: при работающем `low` приоритет низкий; кандидаты (glm-5, mistral, qwen) меряются той же пробой формы bank-low, когда/если владелец даст добро — строкой не заводится до его слова.
**6. Строка 5 (банк-ресёрч) — статус подтверждён владельцу:** промт `BANK_ARBITRATION_RESEARCH_SESSION_PROMPT.md` жив, но заход по нему прерван владельцем досрочно, артефактов в дереве НЕТ (проверено №10) — находки существуют только в истории той закрытой сессии. Диспозиция пере-запуска ратифицирована (D39.90 п.5: §3-A+B1+B3, B2 → под-проба, §5-C отложен, потолок $1.20); промт будет подрезан оркестратором ПЕРЕД пере-выдачей. Вопрос владельцу открыт: вытащит ли он находки из истории прерванной сессии (тогда они входом в подрезанный заход), или пере-запуск с нуля.
## D39.93 — Дизайн-ввод владельца по строке 46 (02.08, ночь): проверки выхода — ИЗОЛИРОВАННЫМ модулем с ИМЕНОВАННЫМИ причинами; деталь «переиспользовать FlagCJKArtifact» СНЯТА в пользу именованного флага. ✅
Слово владельца: «какая разница почему пришёл отказ — бэкенду надо ПРИЧИНУ назвать (эхо · не тот язык · вообще не ответила); я выступаю за степень изолированности кода» + классический вопрос «сколько стоит добавить пару». Следствия, вносимые в дизайн пака экрана ДО его выдачи: (1) детекторы выхода живут отдельным пакетом чистых функций (вход: текст + дата-план цели; выход: именованный вердикт + счёт), движок только вызывает и маппит в диспозицию; (2) **деталь §3.6 отчёта «переиспользовать `FlagCJKArtifact` для нового экрана» СНЯТА** — она экономила правку `escalatable()`, но ВРЁТ о причине (off-target-язык лёг бы в телеметрию как «артефакт исходной письменности»); правильно: новый именованный флаг + явное включение в белый список `escalatable()` (диспозиция та же: эскалируем, не ретраим); (3) пары: сам экран пар-слепой (скрипт-набор цели из `data/lang-script.txt`), новая пара = строка данных; известные ограничения — 115 (явный embed-список: новая ЦЕЛЬ сегодня требует правки Go) и семантика ПОДМНОЖЕСТВ письменностей для ja/ko (чисто китайский выход для →ja набирает 1.000 — решается в данных набором скриптов, не в Go). Попутно: предложение владельца по DeepSeek «поднять токены размышления при low» = ровно открытый замер строки 44 (вариация флора при `low` не проводилась; при `high` фальсифицировано D39.86) — мини-проба ждёт его санкции.
## D39.94 — ЗАКРЫТИЕ СЕССИИ ОРКЕСТРАТОРА №11 (02.08): состояние передачи. ✅
**1. Сделано:** приёмка бэкенд-пака 104/46/16 исполнением — ПРИНЯТ, код `553f1a3`, строка 104 закрыта (D39.91) · решения владельца ратифицированы: 46 пере-поставлена closed-set, 107 санкционирована целиком, 112 санкционирована (D39.92) · дизайн-ввод по 46: изолированный пакет детекторов, именованные причины отказа, именованный флаг вместо реюза `FlagCJKArtifact` (D39.93) · выдан `BACKEND_REPO_STANDARDS_SESSION_PROMPT.md` (линтер/CI/бампы/гарды→analyzer; этап 0 = пять шиппинг-конфигов на `low`).
**2. Ждёт исполнения:** бэкенд-сессия по промту стандартов; её приёмка — преемником по образцу D39.91 (батарея + фрозен-числа + посадки-паритет 12 обходов этапа E + предъявление живой пробы этапа D ≤$0.50).
**3. Ждёт выдачи (преемник):** промт пака экрана 46. Порядок жёсткий: фикстура+регресс (113) → изолированный модуль с именованными вердиктами → офлайн-дифф по всем чекпойнтам (ожидание: ровно 2 строки меняются, СТОП при любой здоровой) → денежный шаг (бамп `classifierVersion`) ТОЛЬКО с подписью владельца. Ограничения: D39.92 п.1 (порог константой-контрактом · abstain 200 букв · слой-0 длин добавить замером · Hunspell/GlotScript/LID в путь вердикта НЕ строить) + D39.93 (именованный флаг + `escalatable()`; скрипт-НАБОРЫ цели в данных — ja/ko). Тем же диффом: Д2 (`detectLatinInsertion` под `TargetScriptNonLatin`) и Д3 (`SourceEchoExpected` для classifier) — строки 46/105.
**4. Открыто на владельце (ответа при закрытии НЕТ):** 116 — форма порога классификатора (подано «≥4/5 прогонов N=5 дают 6/6»; альтернатива — 4/4 по дискриминирующему подмножеству) · строка 5 — вытащить находки прерванного банк-ресёрч-захода из истории той сессии ИЛИ пере-запуск с нуля; в обоих случаях промт ПОДРЕЗАТЬ перед выдачей по D39.90 п.5 (§3-A+B1+B3 · B2 → под-проба · §5-C отложен · потолок $1.20) · мини-проба строки 44 «low + потолок 16k на упавшем классе батчей» (~$0.05) — идея владельца, санкции нет · подпись денежного шага по 46 (когда дойдёт).
**5. Вахты и грабли преемнику:** вендор-анонс смены маппинга эффорта `deepseek-v4-pro` «early August 2026» — реестр 108, триггер: дата >05.08 или аномалия редакторского арма; `medium` на DeepSeek не ставить (вендор документирует low/high/max — Д5, реестр 108); фронт-сессия ЖИВАЯ и коммитит сама (D39.88, только pathspec); дерево на закрытии чисто от бэкенд/докс-зон, незакоммиченное в дереве — фронта и `START_PROMT.MD`.
**6. Очередь скриптом:** 97 строк · «скоро» 15 (2·4·5·18·46·49·55·95·107·108·109·112·113·114·116) · блокеров 0.

View file

@ -1,6 +1,6 @@
# Отчёт бэкенд-сессии: ручка эффорта · экран целевого языка · холодный прогон (строки 104 · 46 · 16) # Отчёт бэкенд-сессии: ручка эффорта · экран целевого языка · холодный прогон (строки 104 · 46 · 16)
> ⚠⚠ **ОТЧЁТ НЕ ПРИНЯТ. Коммит этого файла ≠ ратификация (оркестратор №10, 02.08, D39.90).** Файл закоммичен ТОЛЬКО чтобы артефакт не потерялся при смене сессий — приёмки не было: батарея независимо не пере-прогонялась, деньги вторым путём не пере-выводились, адверсариальный проход по правкам не гонялся. **Код сессии лежит в дереве НЕЗАКОММИЧЕННЫМ** (`backend/internal/config/internal_call.go` + `internal_call_test.go` + `pipeline/synthetic_stage_seam_test.go` — новые; `models.go`/`pipeline.go`/`capability.go`/`escalation.go`/`repair.go`/`runner.go`/`stagerun.go`/`terminologist.go`/`live_reprobe_test.go`/`capability_test.go` — правленые). **Дерево НЕ чистить, `reset --hard`/`checkout` поверх него НЕ делать.** Приёмка — задача №1 преемника: пере-прогнать батарею с фрозен-числами, пере-вывести $0.045095 вторым путём из сырья, адверсариально проверить несущие клеймы (два шва · отсутствие перекупки · гарды · парная выборка N=5), затем лендить. До приёмки числа отчёта — заявления сессии, а не ратифицированные факты. > **ПРИНЯТ приёмкой оркестратора №11 (02.08, D39.91). Код заленден коммитом `553f1a3` (13 файлов backend/, pathspec-форма).** Приёмка исполнением: батарея пере-прогнана — все пять фрозен-чисел совпали (голден PASS без пере-захвата · парити EXACT `n=13618 {方源:0 蛊:1 蛊师:2 古月:22} recall 0.9655` · labels-фриз · K6 1/6/0/251); деньги пере-выведены вторым путём — леджер $0.045095 ≡ независимый пересчёт usage×цены ≡ Σ `request_log`, дельта $0.000000, `decode_error`/`estimated` 0; адверсариал — рукописных `config.Stage{}`/`Request{}` вне швов НОЛЬ (grep), **пять НЕЗАВИСИМЫХ посадок приёмки** (алиас-литерал · `[]cfg.Stage{{…}}` · hoisted `Request` · `var`-форма · `new()`) все CAUGHT; парная выборка N=5 сверена с сырьём `classifier-6of6-{low,high}.json` побайтно. **Обе девиации РАТИФИЦИРОВАНЫ** (repair-ключ свой — деньги+семантика подтверждены по пяти шиппинг-конфигам; `ThinksOnWire` — область правки ровно «ключ отсутствует», провод не менялся). **Поправки приёмки:** (1) §2.1 «$0.031639 (flash)» — на деле flash+pro черновой волны (flash **$0.021289** + pro $0.010350; итоги и леджер верны, врёт метка; так же скопировано в D39.90 п.2); (2) клейм «`8e4495006b978ed4` в обе стороны» невоспроизводим (старая форма удалена, хеш не запинен) — принят ПО ЭКВИВАЛЕНТНОСТИ (длина-префикс `Reasoning` + опущенное поле = `""` + `TestBankProbeAndAttemptAddressOneCheckpoint` + голден); (3) стейл-доккоммент `capability.go:78` («effort "" leaves the provider default» у `ReasoningExtraBodyDisable`) противоречит проводу и новому предикату — носитель: строка 114. Отложки Д4Д9 получили носителей: 114 (Д4) · 115 (Д6) · 116 (Д8) · реестр 108 (Д5, Д9) · строка 107 (Д7).
**Промт:** `docs/BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md` (оркестратор №10, D39.87). **Промт:** `docs/BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md` (оркестратор №10, D39.87).
**Дата:** 02.08.2026. **Не коммичено** — лендит оркестратор. **Дата:** 02.08.2026. **Не коммичено** — лендит оркестратор.

6
frontend/.npmrc Normal file
View file

@ -0,0 +1,6 @@
# engines из package.json — ошибка установки, а не молчаливое предупреждение:
# на старом Node каскад падает дальше по цепочке непонятнее.
engine-strict=true
# Пины точные; ^ при доустановке пакета появляться не должен (политика STACK_DECISIONS §1).
save-exact=true

View file

@ -16,6 +16,10 @@ npm run shot # снимок витрины в .shots/ — открыть
Для скриншот-цикла нужен Chromium Playwright и локальные библиотеки в `.tooling/` Для скриншот-цикла нужен Chromium Playwright и локальные библиотеки в `.tooling/`
(ставятся без sudo, процедура — `docs/FRONTEND_PLAN.md` §4). (ставятся без sudo, процедура — `docs/FRONTEND_PLAN.md` §4).
`npm install` заодно ставит pre-commit хук (`scripts/githooks/`): коммит с frontend-путями
не проходит без зелёного `npm run check`; смесь frontend/ с чужой зоной и файлы
«никогда не коммитить» блокируются для всех (D39.88). Обход — `git commit --no-verify`.
## Что здесь будет ## Что здесь будет
Веб-приложение поверх `../platform/`. MVP — не IDE, а **дашборд + читалка**: библиотека книг, Веб-приложение поверх `../platform/`. MVP — не IDE, а **дашборд + читалка**: библиотека книг,

View file

@ -433,7 +433,13 @@ JSX-спред `<div {...props} />` со `style` внутри объекта ·
обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint. обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint.
`npm run check:full` = `check``vite build``shot`. Отдельного e2e-набора в S1 нет: `npm run check:full` = `check``vite build``shot`. Отдельного e2e-набора в S1 нет:
единственная браузерная проверка — скриншот-цикл, он и стоит в `check:full`. единственная браузерная проверка — скриншот-цикл, он и стоит в `check:full`.
Git-хуков нет. Git-хук один — pre-commit (запрос владельца 02.08, вторая фронт-сессия; отменяет прежнее
«хуков нет»: CI в репозитории отсутствует, до его появления хук — единственный машинный рубеж).
Зонный фрагмент `scripts/githooks/pre-commit` зовёт тот же `npm run check` (один список
инструментов, не дубль) только когда в коммите есть frontend-пути; плюс блок файлов
«никогда не коммитить» и блок смеси frontend/ с чужой зоной (картина инцидентов D39.88).
Ставится сам: `npm install` через prepare кладёт диспетчер в `.git/hooks/pre-commit`
(идемпотентно, чужой хук не перетирает). Осознанный обход — `git commit --no-verify`.
**Не входит в S1** (и не должно появиться раньше срока): экраны, оболочка трёх панелей, **Не входит в S1** (и не должно появиться раньше срока): экраны, оболочка трёх панелей,
слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда

View file

@ -8,7 +8,9 @@
## Текущее состояние ## Текущее состояние
- **Пройдено:** S0 (план) и S1 (инструменты, скриншот-цикл, `tokens.css`, витрина). - **Пройдено:** S0 (план) и S1 (инструменты, скриншот-цикл, `tokens.css`, витрина).
- **Дальше:** S2 — оболочка трёх панелей и слой `src/ui/`. - **Дальше:** S2 — оболочка трёх панелей и слой `src/ui/`. **Промт следующей сессии готов —
`docs/S2_SESSION_PROMPT.md`** (02.08, по запросу владельца): скоуп, поправки к устаревшему §2
стоящего промта, строки Ф-4/Ф-7/Ф-9/Ф-10/Ф-12 в работу, блок на вход в S3.
- **Ждёт владельца:** контраст палитры (`BACKLOG.md` Ф-11) и два вопроса ниже — В-1 диск, В-2 копирайт. - **Ждёт владельца:** контраст палитры (`BACKLOG.md` Ф-11) и два вопроса ниже — В-1 диск, В-2 копирайт.
- **Ждёт движка:** до S2 — ничего. **Вход в S3 — контракт API v0 (единый бэклог, строка 95, - **Ждёт движка:** до S2 — ничего. **Вход в S3 — контракт API v0 (единый бэклог, строка 95,
`docs/architecture/14-api-contract.md`): файла нет, а без него моки и API разойдутся — `docs/architecture/14-api-contract.md`): файла нет, а без него моки и API разойдутся —
@ -18,6 +20,9 @@
после того, как две ошибки S1 пришли из непрочитанного канона; читать до кода, не после. после того, как две ошибки S1 пришли из непрочитанного канона; читать до кода, не после.
- `npm run check` зелёный (5 шагов, 31 тест, тип-осведомлённый линт), `npm run check:full` - `npm run check` зелёный (5 шагов, 31 тест, тип-осведомлённый линт), `npm run check:full`
зелёный, гейт доступности в скриншот-цикле зелёный. зелёный, гейт доступности в скриншот-цикле зелёный.
- **Гейты стоят и на коммите:** pre-commit хук (02.08, вторая сессия) гоняет `npm run check`
для коммитов с frontend-путями и блокирует смесь зон и файлы «никогда не коммитить»;
ставится сам при `npm install`. Обход — только осознанный `git commit --no-verify`.
## Решения владельца по продукту ## Решения владельца по продукту
@ -171,7 +176,40 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон
## Хроника ## Хроника
### 02.08 — сессия S0+S1 (первая фронт-сессия) ### 02.08 — вторая фронт-сессия: ревью скелета свежим взглядом + защита коммитов
**Ревью S0/S1 подтверждает состояние:** `check` (5 гейтов, 31 тест), `build` (377 мс, Rolldown)
и `npm audit` (0 уязвимостей) зелёные; конфиги перечитаны построчно — расхождений с доками
не найдено; известные хвосты гейтов уже честно лежат в Ф-9/Ф-10, не дублировал.
**Главная дыра скелета была не в коде, а вокруг него:** CI нет (проверено: ни `.github/`,
ни других CI-конфигов), git-хуков нет — то есть вся построенная S1 система гейтов работала,
только если сессия сама вспомнит про `npm run check`. При этом `START_PROMT.MD` трекается
и почти всегда модифицирован — голый `git commit -a` унёс бы его молча.
**Закрыто pre-commit хуком** (запрос владельца; отменяет «Git-хуков в MVP нет» из
`STACK_DECISIONS.md` §3 — та строка писалась в паре с CI, которого нет):
- зонный фрагмент `scripts/githooks/pre-commit` (трекается): для коммитов с frontend-путями —
тот же `npm run check` (~10 сек), не дубль списка инструментов; для ЛЮБОГО коммита — блок
файлов «никогда не коммитить» (`START_PROMT.MD`, `.claude/settings.local.json`) и блок
смеси frontend/ с чужой зоной — машинное принуждение D39.88 (легитимной смеси не существует:
фронт коммитит только свою зону, чужие зоны frontend/ не коммитят);
- локальный диспетчер `.git/hooks/pre-commit` (не в git) зонно-нейтрален: подхватывает
`<зона>/scripts/githooks/pre-commit` любой зоны без правки себя; ставится инсталлером
`install.mjs` из npm `prepare` — каждый `npm install` сам обновляет защиту;
- проверено девятью сценариями в изолированном клоне: чужая зона проходит мгновенно ·
запрещённый файл блок · смесь зон блок · литеральный цвет в TSX валит check и блок ·
чистый коммит проходит · pathspec-коммит при чужом застейдженном файле не уносит чужое
(временный индекс git виден хуку корректно) · повторная установка идемпотентна ·
чужой pre-commit не перетирается · вне git-репозитория тихий пропуск;
- догфудинг по мандату самопроверки: хук поймал ошибку в собственном инсталлере
(TS7006 в `install.mjs` — strict-тайпчек `checkJs` дотягивается и до `scripts/`).
**Мелочи той же сессии:** два каретных пина (`^4.12.1` axe, `^4.7.2` eslint-comments)
приведены к точным — единственное расхождение с политикой пинов §1; заведён `.npmrc`
(`engine-strict` — несовпадение Node падает на установке, а не непонятно дальше;
`save-exact` — карет не появится при доустановке).
**Заленжено:** `e9a6bb2` план S0 · `b98afb5` весь код S1 · `d8437d6` закрытие дыр после ревью. **Заленжено:** `e9a6bb2` план S0 · `b98afb5` весь код S1 · `d8437d6` закрытие дыр после ревью.

View file

@ -0,0 +1,113 @@
# Промт: фронт-сессия S2 — оболочка трёх панелей и слой `src/ui/`
Ты — фронтенд-сессия TextMachine, третья по счёту. Зона записи — **только `frontend/`**;
право коммита есть, ровно на свою зону, pathspec-формой (протокол — стоящий промт
`FRONTEND_SESSION_PROMPT.md` §«Коммит-права», он в силе целиком). Пре-коммит хук уже стоит
и самоустанавливается при `npm install`: коммит с frontend-путями не пройдёт без зелёного
`npm run check`, смесь зон и файлы «никогда не коммитить» блокируются. Хук — страховка,
не замена дисциплины.
## Что уже стоит (входное состояние, проверено 02.08 второй сессией)
- S0 (план) и S1 (инструменты) пройдены: `npm run check` — 5 гейтов (prettier · eslint
тип-осведомлённый · stylelint · tsc · vitest, 31 тест), `npm run check:full` — + сборка +
скриншот с гейтом доступности axe. Всё зелёное, `npm audit` — 0 уязвимостей.
- `tokens.css` сведён с `references/fleet.png` до пикселя по замеренным значениям;
контракт-тест держит каждое значение и полноту имён.
- Витрина `/showcase` — сверочная страница скриншот-цикла; раскладку владелец видел и правил.
- Шов данных: `src/api/` — единственный вход, `src/mock/` видит только он (закреплено линтом);
типы `src/api/types.ts` — рабочая гипотеза до контракта API.
- Гейты «одно место для цвета/размера/сети» работают и защищены от обхода; известные дыры
гейтов честно записаны в `BACKLOG.md` Ф-9/Ф-10 — часть твоей работы.
## Обязательное чтение до кода (порядок)
1. `frontend/docs/FRONTEND_SESSION_PROMPT.md` — стоящий промт: референсы §1, сценарий §3,
жёсткие ограничения §4, поддерживаемость §5.1, «как работать» §7. **Его не редактировать**
(правит не фронт-сессия). ⚠ Раскладка его §2 частично устарела — см. следующий пункт.
2. `frontend/docs/PROGRESS.md` — журнал зоны, целиком. Секция «Решения владельца по продукту»
**перевешивает §2 стоящего промта**: банк памяти живёт в ПРАВОЙ панели (вкладки справа:
`О книге · Замечания · Банк`; слева только `Книги · Поиск` + `Настройки` внизу), вход
в настройки один, центр — одна панель во всю высоту, подписной экран банка открывается
вкладкой в центре.
3. `frontend/docs/FRONTEND_PLAN.md` — §0.10.2 (карта канона и два провода: движок фронту
не виден никогда), §5 (замеры и протокол сведения), §5.4 (перечень форм обхода гейтов).
4. `frontend/docs/STACK_DECISIONS.md` — пины и ловушки. Не выбирай библиотеки сам и не
«обновляй» версии по памяти: твои знания об экосистеме устарели.
5. `frontend/docs/BACKLOG.md` — строки Ф-4, Ф-7, Ф-9, Ф-10, Ф-12 адресованы S2.
6. `references/fleet.png` и оба antigravity — **открой и посмотри** (ты умеешь читать
изображения). Витрину `.shots/showcase.png` — тоже, до первой правки.
## Скоуп S2 — и ни шагом дальше
Строишь **оболочку** и **примитивы**. Данных S2 не читает (диспозиция Ф-15 — два независимых
скептика подтвердили; не переоткрывать).
1. **`src/shell/`** — верхняя полоса · три панели · статус-полоса. Панели — `react-resizable-panels`
(пин и API v4 `Group/Panel/Separator` — STACK_DECISIONS §2; там же персист раскладки через
`useDefaultLayout`). Сворачивание левой/правой панели кнопками верхней полосы. Вкладки
в шапках панелей. Раскладка — по решению владельца из PROGRESS.md (см. выше), не по §2.
2. **`src/ui/`** — примитивы на `react-aria-components` (ЕДИНСТВЕННАЯ библиотека примитивов,
пин в STACK_DECISIONS): кнопка · вкладки · строка дерева/списка · поле фильтра · выноска
замечания. Стили — только `.module.css` на токенах. Никаких UI-китов, никаких обёрток
над обёртками. Файл = компонент + модуль рядом, >150 строк — делить.
3. **Ф-12 (требование к S2, не после):** каждый список получает поведение при 10³ элементов
ДО того, как рисуется. Дерево глав обязано жить на 2284 главах — заведи в витрине
нагрузочную фикстуру такого размера и посмотри на неё скриншотом. Дефолт виртуализации —
RAC Virtualizer; `@tanstack/react-virtual` — только по замеру (Ф-6).
4. **Ф-9 + Ф-10 — хвосты гейтов и структурных тестов, адресованы «до S2 или в ней»:** закрой
или дай строкам явную диспозицию с причиной. Каждую закрытую дыру проверяй ЖИВЫМ нарушением
и перечисли формы, которые проверил (урок §5.4: «проверено» без перечня форм — ловушка).
5. **Ф-7 (часть S2):** `+` в конце рядов вкладок — вместе с действием, которое он запускает.
6. **Ф-4 — токен-гейт на отступы:** включить В КОНЦЕ сессии, когда оболочка обкатает шкалу
`--space-1…6`. Включённый гейт — тоже проверить живым нарушением.
**Не в скоупе:** MSW и слой данных (S3 — вход заблокирован, см. ниже) · экраны S4S7 ·
светлая тема · мобильная · React Compiler (Ф-2) · Tauri (Ф-5) · редактор текста.
**Вход в S3 заблокирован снаружи:** контракта API v0 нет (`docs/architecture/14-api-contract.md`
отсутствует; единый бэклог, строка 95). Моки, снятые не с того контракта, разойдутся с API —
ровно то, ради чего строка заведена. Если S2 закончилась и осталось время — отполируй список
Ф-14 (вход фронта в контракт), но S3 НЕ начинай без решения владельца.
## Технические рамки
- Новые зависимости — точными пинами, сверенными live по npm на дату сессии (`.npmrc` уже
держит `save-exact` и `engine-strict`). Каждый новый пин — строкой в таблицу
`FRONTEND_PLAN.md` с датой релиза и «зачем нам».
- Новый маршрут → сразу в `KNOWN_ROUTES` скрипта `scripts/shot.mjs` (тест сверяет списки
и упадёт, если забыл). Каждый экран открывается в изоляции своим маршрутом.
- Гейты не ослаблять. Понадобилось точечное подавление — только именованное правило
с причиной (голое отключение падает само).
- Комментарии — одна-две строки «почему». Имена человеческие, без аббревиатур.
- Тексты фикстур — реальные (кириллица/иероглифы в настоящих пропорциях), объём — минимум
под задачу: вопрос авторского права (В-2) у владельца, не расширяй цитаты.
## Как работать
1. **Скриншот-цикл — с первого компонента:** `npm run shot`, открыть PNG, сравнить
с `fleet.png`, править. Без этого код валиден, а вид случаен. Критерий сведения —
не растровое совпадение: цвета, промежутки 8px, радиусы 6px, плотность, впечатление
(стоящий промт §8).
2. **Ревью исполнением — обязательный мандат проекта:** приложение реально запускается,
скриншоты сняты и ПРОСМОТРЕНЫ, гейты проверены живыми нарушениями с перечнем форм,
`npm run check:full` зелёный перед каждым коммитом. Заявление «должно работать» ревью
не является.
3. **Адверсариальная самопроверка перед финишем:** пройди по своим находкам и правкам
с установкой опровергать (author≠reviewer); S1 так поймала пять дефектов в собственных
гейтах, соло-взгляд их не видел.
4. Спорное с каноном или новое продуктовое решение — НЕ решать самому: вопросом в
`PROGRESS.md` «Открытые вопросы к владельцу» и продолжать то, что вопроса не требует.
## Готово — это когда
- Оболочка стоит: три панели · вкладки · статус-полоса · сворачивание · персист раскладки;
примитивы `src/ui/` используются оболочкой, не лежат мёртвым грузом.
- Дерево на 2284 главах прокручивается без затыков и снято скриншотом (Ф-12).
- Ф-9/Ф-10 закрыты или явно диспозиционированы; Ф-4 включён и проверен нарушением.
- `npm run check:full` зелёный; новые маршруты в `shot.mjs`; скриншоты просмотрены,
расхождения с референсом названы вслух.
- Коммиты: только `frontend/`-пути, pathspec-формой, стейдж+коммит одной командой.
- `frontend/docs/PROGRESS.md`: обновлено «Текущее состояние», добавлена хроника сессии
(что построено · что видно на снимках · где отошёл от референса и почему · что осталось);
строки бэклога получили диспозиции.

View file

@ -107,7 +107,10 @@ npm run check:full # + vite build + e2e
CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне job'ов CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне job'ов
(правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой (правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой
`contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled')`. Git-хуков в MVP нет. `contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled')`. ~~Git-хуков в MVP нет~~
пересмотрено 02.08 запросом владельца: CI ещё не поднят, и до него pre-commit — единственный
машинный рубеж. Хук зовёт те же `npm run check`-команды, не дубль списка (`scripts/githooks/`,
детали — `FRONTEND_PLAN.md` §7).
**Визуальный гейт с эталонными скриншотами — ОТЛОЖЕН.** Он флейкует между платформами, а наш **Визуальный гейт с эталонными скриншотами — ОТЛОЖЕН.** Он флейкует между платформами, а наш
референс снят на macOS при 2x, целевая платформа — Windows. Вместо него **контракт-тест токенов**: референс снят на macOS при 2x, целевая платформа — Windows. Вместо него **контракт-тест токенов**:

View file

@ -16,8 +16,8 @@
"react-router": "8.3.0" "react-router": "8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.12.1", "@axe-core/playwright": "4.12.1",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@types/node": "22.20.1", "@types/node": "22.20.1",

View file

@ -12,7 +12,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "prettier --check . && eslint . --max-warnings 0 && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run", "check": "prettier --check . && eslint . --max-warnings 0 && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run",
"check:full": "npm run check && npm run build && npm run shot", "check:full": "npm run check && npm run build && npm run shot",
"shot": "node scripts/shot.mjs" "shot": "node scripts/shot.mjs",
"prepare": "node scripts/githooks/install.mjs"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
@ -23,8 +24,8 @@
"react-router": "8.3.0" "react-router": "8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.12.1", "@axe-core/playwright": "4.12.1",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@types/node": "22.20.1", "@types/node": "22.20.1",

View file

@ -0,0 +1,57 @@
// Ставит в .git/hooks/pre-commit диспетчер зонных хуков. Идемпотентен: свой (по маркеру)
// перезаписывает свежей версией, чужой не трогает. Зовётся из npm prepare — то есть
// каждый npm install/ci в frontend/ ставит защиту сам, отдельного шага у сессии нет.
import { execSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const MARKER = 'textmachine zone-hook dispatcher';
// Диспетчер зонно-нейтрален: каждая зона может положить свой фрагмент в
// <зона>/scripts/githooks/pre-commit, и он подхватится без правки этого файла.
const dispatcher = `#!/bin/sh
# ${MARKER} v1 сгенерирован frontend/scripts/githooks/install.mjs; правки перетираются.
status=0
for hook in */scripts/githooks/pre-commit; do
[ -x "$hook" ] || continue
"$hook" || status=1
done
exit $status
`;
/** @param {string} args */
function git(args) {
return execSync(`git ${args}`, { encoding: 'utf8' }).trim();
}
let gitDir;
try {
gitDir = git('rev-parse --git-common-dir');
} catch {
console.log('githooks: не git-репозиторий — установка хука пропущена');
process.exit(0);
}
let hooksPath = '';
try {
hooksPath = git('config core.hooksPath');
} catch {
// не задан — стандартное расположение, путь ниже
}
if (hooksPath !== '') {
console.log(`githooks: задан core.hooksPath=${hooksPath} — ставить туда не берусь.`);
console.log('Подключите frontend/scripts/githooks/pre-commit из своего pre-commit вручную.');
process.exit(0);
}
const hooksDir = resolve(process.cwd(), gitDir, 'hooks');
const target = resolve(hooksDir, 'pre-commit');
if (existsSync(target) && !readFileSync(target, 'utf8').includes(MARKER)) {
console.log(`githooks: ${target} уже существует и написан не нами — не перетираю.`);
console.log('Подключите frontend/scripts/githooks/pre-commit из своего pre-commit вручную.');
process.exit(0);
}
mkdirSync(hooksDir, { recursive: true });
writeFileSync(target, dispatcher);
chmodSync(target, 0o755);
console.log(`githooks: pre-commit диспетчер установлен → ${target}`);

View file

@ -0,0 +1,48 @@
#!/bin/sh
# Зонный pre-commit фрагмент frontend/. Запускается диспетчером .git/hooks/pre-commit,
# которого ставит scripts/githooks/install.mjs (автоматически при npm install).
# Для коммитов без frontend-путей отрабатывает за миллисекунды и молчит.
# Крайний обход (осознанный, в лог не попадает): git commit --no-verify.
staged=$(git diff --cached --name-only)
[ -z "$staged" ] && exit 0
# 1) Файлы из списка «никогда не коммитить» (CLAUDE.md, гардрейлы): живой бриф владельца
# и личные настройки прав. START_PROMT.MD трекается и часто модифицирован — голый
# `git commit -a` унёс бы его молча.
forbidden=$(printf '%s\n' "$staged" | grep -E '^START_PROMT\.MD$|(^|/)\.claude/settings\.local\.json$')
if [ -n "$forbidden" ]; then
echo 'pre-commit: в коммите файл из списка «никогда не коммитить» (CLAUDE.md, гардрейлы):' >&2
printf ' %s\n' "$forbidden" >&2
echo 'Коммитьте pathspec-формой без него: git commit -m "..." -- <свои пути>' >&2
exit 1
fi
# 2) Смесь frontend/ и чужой зоны в одном коммите — картина обоих инцидентов 02.08
# (голый `git commit` унёс чужой индекс, D39.88). Фронт коммитит ТОЛЬКО frontend/,
# остальные зоны frontend/ не коммитят — легитимной смеси не существует.
front=$(printf '%s\n' "$staged" | grep -c '^frontend/')
other=$(printf '%s\n' "$staged" | grep -cv '^frontend/')
if [ "$front" -gt 0 ] && [ "$other" -gt 0 ]; then
echo 'pre-commit: коммит смешивает frontend/ с другой зоной — так уезжает чужой индекс (D39.88).' >&2
echo 'Разделите: git commit -m "..." -- <пути одной зоны>' >&2
printf '%s\n' "$staged" | sed 's/^/ /' >&2
exit 1
fi
[ "$front" -eq 0 ] && exit 0
# 3) Гейт качества зоны: тот же npm run check, что гоняют сессии руками, — один список
# инструментов, не дубль (STACK_DECISIONS §3 «одна команда проверки»).
if [ ! -d frontend/node_modules ]; then
echo 'pre-commit: нет frontend/node_modules — выполните npm ci в frontend/ и повторите.' >&2
exit 1
fi
if ! git diff --quiet -- frontend/; then
echo 'pre-commit: в frontend/ есть незакоммиченные правки вне индекса; check идёт по рабочему дереву.' >&2
fi
echo 'pre-commit: npm run check (frontend), ~10 сек...' >&2
if ! npm --prefix frontend run check; then
echo 'pre-commit: check красный — коммит остановлен. Чинить, не обходить.' >&2
exit 1
fi