Compare commits
8 commits
3025fd862b
...
0909e86e8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0909e86e8c | ||
|
|
b0cf84f4b0 | ||
|
|
6a956b3aa1 | ||
|
|
494fc05f3c | ||
|
|
4721a1c54e | ||
|
|
f79d8be88b | ||
|
|
7832be76f4 | ||
|
|
553f1a33cc |
28 changed files with 1353 additions and 126 deletions
|
|
@ -87,4 +87,9 @@ hello my dear
|
|||
2) Считаю что идея со встраиванием в пайплайн фронтир модели -- бессмысленна. Она нам в принципе ничего не дает такого интересного
|
||||
3) Проход книг наперед тоже считаю идеей изначальной не очень удачливой. Что именно и как нам это может дать? Это вообще полезно потенциально? Бустанет перевод хоть как то? Или это оверкилл непонятный и лучше пока сосредоточиться на другом?
|
||||
4) Как мы будем собирать глоссарий? Я так понимаю переводом терминов будет заниматься сама модель, и я считаю глоссарий крайне важным, нужно исследовать как делать запросы для перевода в глоссарий, нужно записывать контекст в котором эти слова чаще всего встречаются, плюс передавать жанр книги наверное. И самое важно некоторые запросы перевода в глоссарий мне кажется надо юзать модель с вебфетчем, например гемини лайт с вебфетчем или любую другую
|
||||
5) Так же задача на будущее для полигона, сейчас все переводы идут с какого-то языка на русский, но в целом глупо делать бэкенд только под русский. Например промт запросы сейчас идут на русском в модели. Скорее всего надо делать запросы на языке на который мы переводим. Но это вообще не точно. Может нужно писать на яызке оригинала? В любом случае мне кажется в переводе с китайского на анлийский -- русский вообще не должен учавствовать чтоб не сбивать модель с вероятностного прогона
|
||||
5) Так же задача на будущее для полигона, сейчас все переводы идут с какого-то языка на русский, но в целом глупо делать бэкенд только под русский. Например промт запросы сейчас идут на русском в модели. Скорее всего надо делать запросы на языке на который мы переводим. Но это вообще не точно. Может нужно писать на яызке оригинала? В любом случае мне кажется в переводе с китайского на анлийский -- русский вообще не должен учавствовать чтоб не сбивать модель с вероятностного прогона
|
||||
|
||||
Идея проекта V5, 26.07.2026
|
||||
|
||||
1) Идеи по фронту.
|
||||
2) Так как фронт будет писаться под веб сначала, то нужно максимально хорошо сделать для ранжирования гуглом и нужно узнать как там сейчас работают актульные гугловские алгоритмы чтобы сайт не банился и не деранжировался. Просто для примера: политика куков, страницы сироты (на которую никто не ссылается) и так далее
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
backend/internal/config/internal_call.go
Normal file
91
backend/internal/config/internal_call.go
Normal 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.
|
||||
}
|
||||
}
|
||||
96
backend/internal/config/internal_call_test.go
Normal file
96
backend/internal/config/internal_call_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -374,8 +374,19 @@ func (m *Models) providerReasoning(modelName string) string {
|
|||
// AdditiveReasoningTokens.
|
||||
func (m *Models) ThinksOnWire(modelName, reasoning string) bool {
|
||||
switch m.ResolveCapability(modelName).Reasoning.Control {
|
||||
case llm.ReasoningEffortField, llm.ReasoningExtraBodyDisable:
|
||||
return reasoning != "off" // an explicit off-switch exists and is sent at "off"
|
||||
case llm.ReasoningExtraBodyDisable:
|
||||
// ⚠ 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
|
||||
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
|
||||
// raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via
|
||||
// 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
|
||||
// default, and these keys exist only to move it off that default.
|
||||
// at any depth, is the mine, whatever the value.
|
||||
//
|
||||
// ⚠ 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{
|
||||
"thinking": true, "enable_thinking": true, "reasoning_effort": true, "reasoning": true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// declaration, so a book running that channel without the terminologist may set it with the gate off.
|
||||
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
|
||||
// LoadPipeline by the ordinary role convention. Not a config key.
|
||||
PromptPath string `yaml:"-"`
|
||||
|
|
@ -360,6 +373,16 @@ func (g TerminologyGate) ClassifierModel() string {
|
|||
type RepairGate struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
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
|
||||
// a SINGLE round — a repaired text is never re-repaired.
|
||||
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)
|
||||
}
|
||||
}
|
||||
switch st.Reasoning {
|
||||
case "", "off", "low", "medium", "high":
|
||||
default:
|
||||
if !ValidReasoningEffort(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
|
||||
|
|
@ -1035,6 +1056,9 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P
|
|||
if rep.MaxCallsPerUnit <= 0 {
|
||||
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 == "" {
|
||||
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 {
|
||||
|
|
@ -1054,6 +1078,11 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P
|
|||
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)")
|
||||
}
|
||||
// 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
|
||||
// (terminology.ScriptByName), so an accepted name can never fail to resolve later.
|
||||
if tg.TargetScript == "" {
|
||||
|
|
|
|||
|
|
@ -51,11 +51,21 @@ type ReasoningControl string
|
|||
const (
|
||||
// ReasoningNone is the OpenAI-compat baseline: low|medium|high go out as
|
||||
// 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
|
||||
// side: DeepSeek-flash on the draft path defaults to thinking ON, and "off" here is
|
||||
// a deliberate NO-OP (emitting nothing keeps it ON) — DISABLING DeepSeek thinking
|
||||
// arms the echo mine (it returns the untranslated CJK source at HTTP 200;
|
||||
// config.echoMineViolation fail-fasts on it). This is the WRONG control for a model
|
||||
// Use it for a model that must never be TURNED OFF from our side: DeepSeek-flash
|
||||
// defaults to thinking ON, and "off" here is a deliberate NO-OP (emitting nothing
|
||||
// keeps it ON) — DISABLING DeepSeek thinking arms the echo mine (it returns the
|
||||
// untranslated CJK source at HTTP 200; config.echoMineViolation fail-fasts on it).
|
||||
//
|
||||
// ⚠ "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
|
||||
// thinks), so a role that must NOT think (the editor) uses ReasoningEffortField with
|
||||
// OffEffort "none" to send an EXPLICIT reasoning_effort:"none" instead.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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
|
||||
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The
|
||||
// hash mirrors runAttempt's for (model=ResolvedHop, attempt=0, maxTokens=hopMaxTokens).
|
||||
fbHash := RequestHash(Request{
|
||||
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: 0,
|
||||
Stage: st.Name, Role: st.Role, Model: st.ResolvedHop, Temperature: st.Temperature, Reasoning: st.Reasoning,
|
||||
JSONOnly: false, MaxTokens: hopMaxTokens, SnapshotID: snapID, Messages: msgs,
|
||||
})
|
||||
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The identity is BUILT by the
|
||||
// same helper the hop's own runAttempt uses (attemptRequest), so "mirrors runAttempt" is structural
|
||||
// rather than a promise two field lists keep by vigilance.
|
||||
fbHash := RequestHash(r.attemptRequest(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs))
|
||||
fbExists, err := r.Store.GetCheckpoint(fbHash)
|
||||
if err != nil {
|
||||
return out, err
|
||||
|
|
|
|||
|
|
@ -23,13 +23,15 @@ package pipeline
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/backend/internal/chunk"
|
||||
"textmachine/backend/internal/config"
|
||||
"textmachine/backend/internal/llm"
|
||||
"textmachine/backend/internal/obs"
|
||||
"textmachine/backend/internal/terminology"
|
||||
"textmachine/backend/internal/text"
|
||||
|
|
@ -84,58 +86,107 @@ func TestLiveClassifierHarmSet(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("render production classifier messages: %v", err)
|
||||
}
|
||||
model := r.Pipeline.Gates.Terminology.ClassifierModel()
|
||||
_, maxTokens := r.bankCallBudget(model, msgs)
|
||||
// The stage comes from the PRODUCTION derivation, not a literal: a probe that hand-built its stage would
|
||||
// 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
|
||||
// instead of re-buying the answer — the same property the production bank roles have.
|
||||
const snapID = "reprobe-classifier-6of6"
|
||||
// jobs.snapshot_id is a FOREIGN KEY into snapshots — the production path upserts the wave snapshot
|
||||
// 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"}`); err != nil {
|
||||
t.Fatalf("register the probe snapshot axis: %v", err)
|
||||
// N SAMPLES, not one. The verdict this probe produces is a judgement call by a stochastic model, and a
|
||||
// single call cannot tell a level difference from ordinary variance — reading n=1 as a measurement is
|
||||
// exactly the manufactured-convergence failure this repo has paid for before. Each run gets its OWN
|
||||
// snapshot axis so it is a FRESH call; a repeat of the same index still replays that run for $0.
|
||||
// Default 5, not 1. The comment above condemns n=1 and the first version of this rig then DEFAULTED to
|
||||
// it — so the next person to run it would have got exactly the reading it warns against. A run costs
|
||||
// ~$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}
|
||||
job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure job: %v", err)
|
||||
effort := st.Reasoning
|
||||
if effort == "" {
|
||||
effort = "unset"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
att, err := r.runBankAttempt(ctx, roleClassifier, model, st, snapID, chunk.Chunk{Chapter: 0, ChunkIdx: 0}, job, msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("live classifier call failed: %v", err)
|
||||
type sample struct {
|
||||
Run int `json:"run"`
|
||||
Finish string `json:"finish"`
|
||||
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"`
|
||||
}
|
||||
|
||||
got, stats := terminology.ParseTypes(att.text, candKeys(harmSet), text.NormalizeSourceKey)
|
||||
hits, decided := 0, 0
|
||||
for _, c := range harmSet {
|
||||
if got[c.Key] != "term" {
|
||||
continue
|
||||
samples := make([]sample, 0, runs)
|
||||
full, totalCost := 0, 0.0
|
||||
for i := 0; i < runs; i++ {
|
||||
snapID := fmt.Sprintf("reprobe-classifier-6of6-%s-%d", effort, i)
|
||||
// jobs.snapshot_id is a FOREIGN KEY into snapshots — the production path upserts the wave snapshot
|
||||
// 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++
|
||||
if c.Key != "元石" && c.Key != "灵泉" { // the two the prompt names verbatim
|
||||
decided++
|
||||
job, jerr := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
|
||||
if jerr != nil {
|
||||
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",
|
||||
att.modelActual, maxTokens, att.finish, att.cls.Reason, time.Since(start).Milliseconds(), att.usage, att.runCost, stats.Bad, hits, decided, got)
|
||||
t.Logf("CLASSIFIER 6/6 SUMMARY [effort=%s]: %d/%d runs reached the 6/6 threshold · total_cost_usd=%.6f", effort, full, runs, totalCost)
|
||||
|
||||
// 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{
|
||||
"model": att.modelActual, "max_tokens": maxTokens, "finish": att.finish,
|
||||
"verdict": string(att.cls.Reason), "usage": att.usage, "cost_usd": att.runCost,
|
||||
"reply": att.text, "parsed": got, "bad_lines": stats.Bad,
|
||||
"hits_term": hits, "discriminating_hits": decided,
|
||||
"model": st.Model, "effort": effort, "max_tokens": maxTokens,
|
||||
"runs": runs, "runs_at_threshold": full, "total_cost_usd": totalCost, "samples": samples,
|
||||
}, "", " ")
|
||||
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)
|
||||
}
|
||||
|
||||
if hits != len(harmSet) {
|
||||
t.Fatalf("acceptance threshold is 6/6 term (D39.69 §2); got %d/6: %v — reply:\n%s", hits, got, att.text)
|
||||
// ⚠ THE THRESHOLD AND THE SAMPLE DISAGREE, and that is an OWNER decision, not something to soften here.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// mirrors runRepairAttempt's request identity exactly (same synthetic stage, same ordinal, same budget
|
||||
// derivation), because the two must address the same checkpoint — the escalation hop keeps the same
|
||||
// discipline for the same reason (a crash after settle but before chunk_status must not lose paid work).
|
||||
// mirrors runRepairAttempt's request identity BY CONSTRUCTION — same derived stage, same helper — because
|
||||
// the two must address the same checkpoint; the doccomment used to promise that mirroring while the two
|
||||
// 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) {
|
||||
model, maxTokens := r.repairCallBudget(msgs)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(Request{
|
||||
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: ordinal,
|
||||
Stage: st.Name, Role: roleRepair, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs,
|
||||
}))
|
||||
rst := r.repairStage(st, model)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(rst, model, snapID, ch, ordinal, maxTokens, msgs)))
|
||||
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
|
||||
// checkpoint probe and the call itself can never address different request hashes.
|
||||
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,
|
||||
// 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
|
||||
// prevent. The synthetic 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 —
|
||||
// on DeepSeek that means thinking stays ON and the echo mine is not armed.
|
||||
// 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 INHERITS the repaired stage's effort.
|
||||
//
|
||||
// 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,
|
||||
job *store.Job, ordinal int, msgs []llm.Message) (stageAttempt, error) {
|
||||
|
||||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"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
|
||||
// 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.
|
||||
// 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 {
|
||||
if !lang.IsCJKScriptLang(r.Book.SourceLang) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, st := range r.Pipeline.Stages {
|
||||
t := r.templates[st.Name]
|
||||
if t == nil || !strings.Contains(t.System+t.FewShot+t.User, sourcePlaceholder) {
|
||||
continue
|
||||
}
|
||||
if !r.Models.ThinksOnWire(st.ResolvedModel, st.Reasoning) {
|
||||
out = append(out, st.Name+"→"+st.ResolvedModel)
|
||||
shipsSource := func(t *PromptTemplate) bool {
|
||||
return t != nil && strings.Contains(t.System+t.FewShot+t.User, sourcePlaceholder)
|
||||
}
|
||||
add := func(name, model, reasoning string) {
|
||||
if !r.Models.ThinksOnWire(model, reasoning) {
|
||||
out = append(out, name+"→"+model)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -393,17 +393,31 @@ type stageAttempt struct {
|
|||
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
|
||||
// hit is classified for free (self-heal, incl. legacy Phase-0 empty/decode
|
||||
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
|
||||
// checkpoint. It returns an error only on an infra failure; a bad completion
|
||||
// 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) {
|
||||
reqHash := RequestHash(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,
|
||||
})
|
||||
reqHash := RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
|
||||
att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
|
||||
|
||||
// Resume on the attempt axis: a checkpoint means THIS attempt already happened
|
||||
|
|
|
|||
395
backend/internal/pipeline/synthetic_stage_seam_test.go
Normal file
395
backend/internal/pipeline/synthetic_stage_seam_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
canon := r.approvedNeighbours()
|
||||
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) },
|
||||
}
|
||||
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()
|
||||
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")
|
||||
if err != nil {
|
||||
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,
|
||||
// so the pre-call number and the reserved number are the same number.
|
||||
func (r *Runner) bankCallEstimateUSD(model string, msgs []llm.Message) float64 {
|
||||
_, maxTokens := r.bankCallBudget(model, msgs)
|
||||
func (r *Runner) bankCallEstimateUSD(st config.Stage, msgs []llm.Message) float64 {
|
||||
_, maxTokens := r.bankCallBudget(st.Model, msgs)
|
||||
promptEst := 0
|
||||
for _, m := range msgs {
|
||||
promptEst += EstimateTokens(m.Content)
|
||||
}
|
||||
return ledger.EstimateUSD(r.Pricer.PriceFor(model), promptEst, maxTokens,
|
||||
r.Models.AdditiveReasoningTokens(model, "", 0))
|
||||
// The buffer is read off the SAME stage the call will use rather than passed as a literal, so this line
|
||||
// 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
|
||||
// request-hash axis.
|
||||
func (r *Runner) bankCheckpointExists(role, model string, st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
|
||||
_, maxTokens := r.bankCallBudget(model, msgs)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(Request{
|
||||
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: 0,
|
||||
Stage: st.Name, Role: role, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs,
|
||||
}))
|
||||
// request-hash axis — the SAME identity runBankAttempt will address (attemptRequest), never a hand-rebuilt
|
||||
// copy of it: this probe gates the role sub-budget, so an identity that drifts from the attempt's turns the
|
||||
// gate off (see attemptRequest).
|
||||
func (r *Runner) bankCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
|
||||
_, maxTokens := r.bankCallBudget(st.Model, msgs)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, st.Model, snapID, ch, 0, maxTokens, msgs)))
|
||||
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;
|
||||
// the intrinsic classifier still runs and is a real guard (a refusal reply would otherwise be parsed as
|
||||
// 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) {
|
||||
|
||||
_, maxTokens := r.bankCallBudget(model, msgs)
|
||||
tst := config.Stage{Name: st.Name, Role: role, Model: model}
|
||||
_, maxTokens := r.bankCallBudget(st.Model, msgs)
|
||||
// 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
|
||||
// multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch ordinal.
|
||||
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)
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
role string
|
||||
model string
|
||||
budgetUSD float64
|
||||
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.
|
||||
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))}
|
||||
// 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))
|
||||
for i, b := range batches {
|
||||
m, err := plan.messages(b)
|
||||
|
|
@ -555,17 +593,16 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
|
|||
return run, err
|
||||
}
|
||||
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",
|
||||
"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)
|
||||
|
||||
spent, err := r.Store.RoleSpentUSD(r.Book.BookID, plan.role)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// batch ordinal is the chunk index, so two batches can never collide on one checkpoint.
|
||||
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 {
|
||||
return run, perr
|
||||
}
|
||||
// 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.
|
||||
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",
|
||||
"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)
|
||||
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 {
|
||||
// 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.
|
||||
|
|
|
|||
56
docs/BACKEND_REPO_STANDARDS_SESSION_PROMPT.md
Normal file
56
docs/BACKEND_REPO_STANDARDS_SESSION_PROMPT.md
Normal 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.91–92 в `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` не читать.
|
||||
- Деньги: этапы 0–C и 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
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.90 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
# Журнал решений оркестратора — контракт D1–D39.91 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1–D39.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-эмбед не строить.
|
||||
- **Строка 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 91–1278 · промах run3=91 ток. с 1/4 по дискриминирующему подмножеству; high 5/5 · $0.002335 · 920–3104; цена ×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 — по плану, не по сводке): потерь сжатия НЕТ.** Шаги 1–2 (корпус-фикстура + регресс-тест запаса) → строка 113 ✓ · шаг 3 (два дока-дефекта: 0.9510→0.9767 на 1055, база 1.5%→3.02%) — УЖЕ исполнен №10 в теле строки 46, других публикаций неверных чисел grep не нашёл ✓ · шаги 4–6 (чистая функция → офлайн-дифф → бамп `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.1–5.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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Отчёт бэкенд-сессии: ручка эффорта · экран целевого языка · холодный прогон (строки 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).
|
||||
**Дата:** 02.08.2026. **Не коммичено** — лендит оркестратор.
|
||||
|
|
|
|||
6
frontend/.npmrc
Normal file
6
frontend/.npmrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# engines из package.json — ошибка установки, а не молчаливое предупреждение:
|
||||
# на старом Node каскад падает дальше по цепочке непонятнее.
|
||||
engine-strict=true
|
||||
|
||||
# Пины точные; ^ при доустановке пакета появляться не должен (политика STACK_DECISIONS §1).
|
||||
save-exact=true
|
||||
|
|
@ -16,6 +16,10 @@ npm run shot # снимок витрины в .shots/ — открыть
|
|||
Для скриншот-цикла нужен Chromium Playwright и локальные библиотеки в `.tooling/`
|
||||
(ставятся без 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, а **дашборд + читалка**: библиотека книг,
|
||||
|
|
|
|||
|
|
@ -433,7 +433,13 @@ JSX-спред `<div {...props} />` со `style` внутри объекта ·
|
|||
обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint.
|
||||
`npm run check:full` = `check` → `vite build` → `shot`. Отдельного e2e-набора в S1 нет:
|
||||
единственная браузерная проверка — скриншот-цикл, он и стоит в `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** (и не должно появиться раньше срока): экраны, оболочка трёх панелей,
|
||||
слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
## Текущее состояние
|
||||
|
||||
- **Пройдено:** 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 копирайт.
|
||||
- **Ждёт движка:** до S2 — ничего. **Вход в S3 — контракт API v0 (единый бэклог, строка 95,
|
||||
`docs/architecture/14-api-contract.md`): файла нет, а без него моки и API разойдутся —
|
||||
|
|
@ -18,6 +20,9 @@
|
|||
после того, как две ошибки S1 пришли из непрочитанного канона; читать до кода, не после.
|
||||
- `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` закрытие дыр после ревью.
|
||||
|
||||
|
|
|
|||
113
frontend/docs/S2_SESSION_PROMPT.md
Normal file
113
frontend/docs/S2_SESSION_PROMPT.md
Normal 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.1–0.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 — вход заблокирован, см. ниже) · экраны S4–S7 ·
|
||||
светлая тема · мобильная · 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`: обновлено «Текущее состояние», добавлена хроника сессии
|
||||
(что построено · что видно на снимках · где отошёл от референса и почему · что осталось);
|
||||
строки бэклога получили диспозиции.
|
||||
|
|
@ -107,7 +107,10 @@ npm run check:full # + vite build + e2e
|
|||
|
||||
CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне 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. Вместо него **контракт-тест токенов**:
|
||||
|
|
|
|||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -16,8 +16,8 @@
|
|||
"react-router": "8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
|
||||
"@axe-core/playwright": "4.12.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "22.20.1",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"preview": "vite preview",
|
||||
"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",
|
||||
"shot": "node scripts/shot.mjs"
|
||||
"shot": "node scripts/shot.mjs",
|
||||
"prepare": "node scripts/githooks/install.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
|
@ -23,8 +24,8 @@
|
|||
"react-router": "8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
|
||||
"@axe-core/playwright": "4.12.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "22.20.1",
|
||||
|
|
|
|||
57
frontend/scripts/githooks/install.mjs
Normal file
57
frontend/scripts/githooks/install.mjs
Normal 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}`);
|
||||
48
frontend/scripts/githooks/pre-commit
Executable file
48
frontend/scripts/githooks/pre-commit
Executable 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
|
||||
Loading…
Add table
Reference in a new issue