diff --git a/backend/README.md b/backend/README.md index 8ad655d6..cdc4ac97 100644 --- a/backend/README.md +++ b/backend/README.md @@ -47,7 +47,8 @@ Стенд: WSL2 (localhost из-под прокси = 403 — для local-вызовов no-proxy транспорт). Книга-стенд: `/home/ubuntu/books/gu-zhenren/` (GB18030; тексты и производные ВНЕ git; парити-тесты майнера читают отсюда под `TM_MINER_PARITY=1`). ```bash -go build ./... && go vet ./... && go test ./... -race # всё зелёное = норма +go build ./... && go vet ./... && go test ./... -race # зелёное = норма ДЛЯ ГЕРМЕТИЧНОЙ ЧАСТИ (см. ниже) +go test ./... -race -v | grep -- '--- SKIP' # ЧТО НЕ ВЫПОЛНЯЛОСЬ — вклеивать в отчёт, не опускать go run ./cmd/tmctl translate --config example/book.yaml # реальные вызовы — ключи в .env (пример: zh→ru) go run ./cmd/tmctl status --config example/book.yaml --json # $0, N/M+паспорта+деньги, живой прогон ок go run ./cmd/tmctl report --config example/book.yaml # $0, quality-report (KPI/rates) @@ -56,6 +57,21 @@ go run ./cmd/tmctl export --config example/book.yaml # $0, экспор set -a; . ./.env; set +a; TM_LIVE=1 go test -tags live -run TestLive -v ./internal/pipeline/ ``` +⚠ **«Зелёное» ≠ «всё проверено»: четыре теста — ИЗМЕРЕНИЯ на корпусе, а не юнит-тесты, и на чистом клоне они молча скипаются.** Голая батарея выше их НЕ выполняет, поэтому «батарея зелёная» без списка SKIP — неполный отчёт, а не приёмка. + +| Что тёмное без данных | Флаг | Данные | +|---|---|---| +| `TestMinerFullBookParity` — единственная сверка Go-порта против Python-эталона на полной книге | `TM_MINER_PARITY=1` | jieba-словарь по внутрирепозиторному ПУТИ `eval/exp16/data/`, но ВНЕ git (`eval/.gitignore:4`) — клон его не получает, регенерируется из jieba 0.42.1 (SHA в `experiments/16-bank-mining.md:183`); плюс `~/books/gu-zhenren/{rerun/records.json, guzhenren-seed-v2.yaml}` | +| `TestCheckerLabelsBaseline`, `TestCheckerLabelsCandidates`, `TestK6LabelsBaseline` — единственный замер precision/recall $0-гейтов | `TM_CHECKER_LABELS=1` | `~/books/gu-zhenren/labels/` | + +Флаг переводит отсутствие данных из тихого скипа в громкий `Fatal` — то есть «я это гонял» становится проверяемым. Пути дефолтятся ОТ `$HOME` и от корня репозитория (не от `/home/ubuntu`), так что клон под любым пользователем и по любому пути находит то, что есть; точечные переопределения — `TM_CHECKER_LABELS_DIR`, `TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}`. + +Полная батарея на стенде: +```bash +go build ./... && go vet ./... && go vet -tags live ./... && test -z "$(gofmt -l .)" && \ + TM_MINER_PARITY=1 TM_CHECKER_LABELS=1 go test ./... -race -count=1 +``` + **Golden-гард детерминизма** (`golden_test.go` + `testdata/golden/`): пинит бит-в-бит snapshotID, request_hash, wire-тела, вердикты и resume-байты. Красный golden = wire/вердикты изменились = `--resnapshot` = переоплата. Обновлять ТОЛЬКО на ратифицированной смене поведения: `TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden`; при re-capture — **маскированный структурный дифф** (хеши/версии → плейсхолдер) обязан быть пустым, если смена версий не меняла вердиктов. Финал каждой вехи — агентское адверсариальное селфревью (мандат CLAUDE.md 12.07); внешнее ревью — оркестратор. diff --git a/backend/internal/checks/labelharness_test.go b/backend/internal/checks/labelharness_test.go index d09e32d3..9e83ec52 100644 --- a/backend/internal/checks/labelharness_test.go +++ b/backend/internal/checks/labelharness_test.go @@ -40,7 +40,7 @@ import ( ) var ( - labelsDir = envOrChecks("TM_CHECKER_LABELS_DIR", "/home/ubuntu/books/gu-zhenren/labels") + labelsDir = envOrChecks("TM_CHECKER_LABELS_DIR", standLabelsDir()) langpackRoot = envOrChecks("TM_CHECKER_LANGPACK_ROOT", "../../configs/langpacks") ) @@ -51,6 +51,18 @@ func envOrChecks(key, def string) string { return def } +// standLabelsDir derives the labelled-corpus root from $HOME rather than hardcoding /home/ubuntu, so the +// default is correct for any user on any machine holding the stand data. An unresolvable HOME yields a +// deliberately un-plausible path: "I could not find your home directory" must not be reported to the +// operator as "the corpus is absent", because those two call for completely different actions. +func standLabelsDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("", "books", "gu-zhenren", "labels") + } + return filepath.Join(home, "books", "gu-zhenren", "labels") +} + // --- corpus / label record shapes ------------------------------------------------- type corpusUnit struct { diff --git a/backend/internal/membank/labelharness_test.go b/backend/internal/membank/labelharness_test.go index 6d896219..5b2eb3af 100644 --- a/backend/internal/membank/labelharness_test.go +++ b/backend/internal/membank/labelharness_test.go @@ -28,7 +28,20 @@ func envOrMem(key, def string) string { return def } -var memLabelsDir = envOrMem("TM_CHECKER_LABELS_DIR", "/home/ubuntu/books/gu-zhenren/labels") +var memLabelsDir = envOrMem("TM_CHECKER_LABELS_DIR", memStandLabelsDir()) + +// memStandLabelsDir mirrors standLabelsDir in internal/checks — same root, same $HOME derivation, same +// un-plausible path when HOME cannot be resolved (that is a broken environment, not an absent corpus). +// The copy is a deliberate deferral, not a necessity: internal/chunk/chunktest shows this repo is willing +// to host a shared test-only package, and these two halves plus miner's standFile are the third user — +// enough to justify extracting one. Until then TM_CHECKER_LABELS_DIR is what actually keeps them in step. +func memStandLabelsDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("", "books", "gu-zhenren", "labels") + } + return filepath.Join(home, "books", "gu-zhenren", "labels") +} type k6Pool struct { ID string `json:"id"` diff --git a/backend/internal/miner/miner_parity_test.go b/backend/internal/miner/miner_parity_test.go index c4359168..1d8b2bf7 100644 --- a/backend/internal/miner/miner_parity_test.go +++ b/backend/internal/miner/miner_parity_test.go @@ -3,6 +3,8 @@ package miner import ( "encoding/json" "os" + "path/filepath" + "runtime" "strings" "testing" @@ -25,14 +27,22 @@ import ( // they are absent (CI / a fresh checkout) and runs on the stand (or when TM_MINER_PARITY=1 forces it, // failing loud if the data is missing). $0, deterministic — Python is the reference; a divergence means // the Go port is wrong (fix Go), never the reference. -// The stand-data paths default to the stand layout but are overridable via env so the test is not -// pinned to one machine's absolute paths (test hygiene): TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}. -// The data is out of git (CLAUDE.md), so the test still SKIPS when a path is absent unless -// TM_MINER_PARITY=1 forces it (then a missing path fails loud). +// The three inputs are of TWO different kinds and their defaults are derived, never hardcoded to one +// machine, so a clone at any path under any username finds whatever is actually present: +// - the jieba contrast artifact sits at an in-repo PATH (eval/exp16/data) but is NOT in git — it falls +// under the polygon's blanket raw-data rule (eval/.gitignore:4), so no clone ever receives it; it is +// regenerated from jieba 0.42.1 (SHA in docs/experiments/16-bank-mining.md:183). The path is resolved +// against the repo root because the old absolute default was additionally unfindable in every clone +// not at /home/ubuntu/projects/textmachine — a portability bug ON TOP of the data gap, not instead of it; +// - records.json and the seed are book derivatives and live OUT of git in the stand (~/books, CLAUDE.md), +// so they are resolved from $HOME. +// +// All three stay overridable: TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}. The test still SKIPS when a path +// is absent unless TM_MINER_PARITY=1 forces it (then a missing path fails loud). var ( - minerParityContrast = envOr("TM_MINER_PARITY_CONTRAST", "/home/ubuntu/projects/textmachine/eval/exp16/data/jieba_dict_general_zh.txt") - minerParityRecords = envOr("TM_MINER_PARITY_RECORDS", "/home/ubuntu/books/gu-zhenren/rerun/records.json") - minerParitySeed = envOr("TM_MINER_PARITY_SEED", "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml") + minerParityContrast = envOr("TM_MINER_PARITY_CONTRAST", repoFile("eval", "exp16", "data", "jieba_dict_general_zh.txt")) + minerParityRecords = envOr("TM_MINER_PARITY_RECORDS", standFile("gu-zhenren", "rerun", "records.json")) + minerParitySeed = envOr("TM_MINER_PARITY_SEED", standFile("gu-zhenren", "guzhenren-seed-v2.yaml")) ) // envOr returns the environment override for key, or def when it is unset/empty. @@ -43,6 +53,59 @@ func envOr(key, def string) string { return def } +// repoRoot finds the repository root by MARKER (backend/go.mod) rather than by counting "..", so the +// derivation cannot quietly go wrong. It starts from this file's own directory, and falls back to the +// working directory when that path is not absolute — which is what -trimpath does: it rewrites +// runtime.Caller to a module-relative path, and a naive "../../.." then yields a RELATIVE root that can +// only ever miss, turning the parity test into a permanent silent skip on every machine including the +// stand. `go test` always runs in the package directory, so the fallback is sound. +func repoRoot() string { + start := "" + if _, self, _, ok := runtime.Caller(0); ok && filepath.IsAbs(self) { + start = filepath.Dir(self) + } + if start == "" { + wd, err := os.Getwd() + if err != nil { + return "" + } + start = wd + } + for dir := start; ; { + if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// repoFile resolves parts against the repository root so an in-repo path is found wherever the clone +// lives. When the root cannot be established the returned path is deliberately un-plausible: a missing +// marker must read as "my derivation is broken", never as "the data is simply absent". +func repoFile(parts ...string) string { + root := repoRoot() + if root == "" { + return filepath.Join("", filepath.Join(parts...)) + } + return filepath.Join(append([]string{root}, parts...)...) +} + +// standFile resolves parts against the out-of-git stand root ($HOME/books). Deriving the root from $HOME +// instead of a literal /home/ubuntu keeps the default correct for any user. An unresolvable HOME yields a +// deliberately un-plausible path for the same reason as repoFile: "I could not find your home directory" +// must not be reported as "the stand data is absent". +func standFile(parts ...string) string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("", "books", filepath.Join(parts...)) + } + return filepath.Join(append([]string{home, "books"}, parts...)...) +} + func TestMinerFullBookParity(t *testing.T) { force := os.Getenv("TM_MINER_PARITY") == "1" for _, p := range []string{minerParityContrast, minerParityRecords, minerParitySeed} {