Derive corpus-test data paths from the repo root and HOME instead of one machine's absolute paths, and document which tests stay dark without the stand data

This commit is contained in:
heaven 2026-08-03 02:44:07 +03:00
parent f66518a860
commit 4d2d1449bc
4 changed files with 114 additions and 10 deletions

View file

@ -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); внешнее ревью — оркестратор.

View file

@ -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("<home-not-found>", "books", "gu-zhenren", "labels")
}
return filepath.Join(home, "books", "gu-zhenren", "labels")
}
// --- corpus / label record shapes -------------------------------------------------
type corpusUnit struct {

View file

@ -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("<home-not-found>", "books", "gu-zhenren", "labels")
}
return filepath.Join(home, "books", "gu-zhenren", "labels")
}
type k6Pool struct {
ID string `json:"id"`

View file

@ -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("<repo-root-not-found>", 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("<home-not-found>", "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} {