Files
gbrain/scripts/check-test-real-names.sh
T
94aaf7e396 v0.40.1.0 Track D — eval infrastructure (catch retrieval regressions, prove answer-quality wins) (#1298)
* feat(eval-longmemeval): --by-type flag + question field + resume-replace

Per-question JSONL row gains `question`, `question_type`, and (when
ground truth is available) `recall_hit` — additive fields that existing
consumers (LongMemEval's `evaluate_qa.py`) ignore. New `--by-type` flag
emits a `{kind:"by_type_summary", recall_by_type, aggregate}` line at
the end of the output, resume-safe: rebuilt from existing rows so the
final aggregate covers cumulative resumed questions, prior summary at
the tail replaced rather than appended. New `--by-type-floor F` exits
non-zero per breached question_type. Empty-bucket guard emits null rate
not NaN. Exports `buildByTypeSummary` + `emitByTypeSummary` +
`seedRecallByTypeFromFile` for unit testing.

* feat(eval-cross-modal): --batch flag + semaphore + DI seam

Adds `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT]
[--yes]` to the existing eval cross-modal command. Mutually exclusive
with --task. Reads LongMemEval-shape JSONL output, filters by_type_summary
rows automatically, fans out via a new `runWithLimit<T>` semaphore
primitive (default --concurrent 3 x 3 model slots = 9 simultaneous calls;
below tier-1 rate limits on all 3 providers). Pre-flight cost estimate
refuses past --max-usd (default $5) unless --yes. Per-question receipts
written to a per-batch tempdir + deleted at end of run so
~/.gbrain/eval-receipts/ stays clean; summary receipt inlines verdicts.

Exit precedence (new batch-level policy, not inherited from aggregate.ts):
ERROR > FAIL > INCONCLUSIVE > PASS — any per-question runtime error exits 2.

New `runEvalCrossModal(args, opts?: {runEval?})` DI seam mirrors the
existing eval-longmemeval pattern. Tests pass a stub runEval so unit tests
don't need API keys; gateway availability check is also skipped when
opts.runEval is provided. Pinned by 17 cases.

* test: hermetic qrels retrieval gate against synthetic basis-vector corpus

Adds test/eval-replay-gate.test.ts as a unit-shard test (NOT under
test/e2e/ — the unit-shard CI matrix runs every PR via bun test;
test/e2e/ is fixed-file). Seeds a PGLite engine with synthetic
placeholder-name pages whose embeddings are basis vectors (same pattern
as test/e2e/search-quality.test.ts:23-28) so retrieval is hermetic — no
API keys, no DATABASE_URL, fully deterministic.

The qrels fixture at test/fixtures/eval-baselines/qrels-search.json has
12 hand-curated queries; each maps to a ranked list of relevant slugs +
`first_relevant_slug` (expected top-1). For each query, the gate asserts
`top1_match_rate >= 0.80` AND `recall_at_10 >= 0.85`. Env-overridable
floors via GBRAIN_REPLAY_GATE_TOP1_FLOOR / GBRAIN_REPLAY_GATE_RECALL_FLOOR
through withEnv(). Gate-fire prints per-query HIT/miss + recall to stderr.

When ranking changes intentionally move expected slugs, edit
qrels-search.json directly with a 'Why:' line in the commit body —
documented in docs/eval-bench.md.

scripts/check-test-real-names.sh allowlist gains 6 entries for the
privacy-grep regression guard inside the test, which must literally
spell the names it forbids to assert they're NOT in the fixture (same
meta-rule exception as skillpack-harvest privacy tests).

* feat(autopilot): opt-in nightly cross-modal quality probe + doctor check

Composes `gbrain eval longmemeval --by-type` + `gbrain eval cross-modal
--batch` into a 24h-cadenced quality check. Default DISABLED — opt-in via
`gbrain config set autopilot.nightly_quality_probe.enabled true` so new
users don't discover background API spend.

src/core/cycle/nightly-quality-probe.ts ships the phase implementation
with a full NightlyProbeDeps DI surface (isEnabled, hasEmbeddingProvider,
resolveMaxUsd, resolveRepoRoot, runLongMemEval, runCrossModalBatch, now)
so tests stub every external effect — no PGLite, no real LLM calls.
Pure `shouldRunNightly(now, recentEvents, windowMs?)` rate-limit fn.

src/core/audit-quality-probe.ts is the ISO-week-rotated JSONL writer
(mirrors audit-slug-fallback.ts; honors GBRAIN_AUDIT_DIR). One event per
run: outcome (pass/fail/inconclusive/error/budget_exceeded/rate_limited/
no_embedding_key), exit code, pass/fail/error counts, est_cost_usd,
fixture_sha8.

src/commands/doctor.ts gains a `nightly_quality_probe_health` check:
SKIPPED with paste-ready enable command when disabled; OK with timestamp
when all PASS in last 7 days; WARN with per-outcome counts when any
FAIL/ERROR/BUDGET_EXCEEDED. Extracted as pure
`computeNightlyQualityProbeHealthCheck(probeEnabled, events)` for
unit testing.

test/fixtures/longmemeval-nightly.jsonl is a 10-question placeholder
dataset (synthetic names only) distinct from the existing 5-question
mini fixture so the probe has consistent regression signal.

Real expected cost: ~$0.35/night = ~$10.50/month. Worst-case at
default $5 cap: $150/month.

Pinned by 21 cases in test/nightly-quality-probe.test.ts covering the
rate-limit pure function, every outcome branch, and all 7 branches of
the doctor check.

Autopilot scheduler wiring deferred to v0.41+ — the phase is callable
in isolation today (via the DI surface); cycle-loop dispatcher
integration filed in TODOS.md as a follow-up.

* docs: document Track D eval surfaces + file v0.41+ follow-up TODOs

docs/eval-bench.md gains a 'v0.40.1.0 Track D — Eval infrastructure'
section covering: --by-type usage + resume-replace semantics, the
hermetic qrels gate workflow + 'Why:' commit-body refresh convention,
--batch end-to-end with cost-bound + concurrency knobs, and the opt-in
nightly probe enable workflow + cost ceiling.

TODOS.md files two follow-ups:
- v0.41+: contributor-mode CI capture for BrainBench-Real replay gate
  (the deferred original Task 2 design — replay against real captured
  queries is more valuable than synthetic qrels long-term, but needs CI
  secret + nightly capture pipeline + commit automation; deferred to a
  dedicated wave)
- v0.41+: wire the nightly quality probe into autopilot scheduling
  (phase callable in isolation today; cycle-loop dispatcher integration
  is a ~3-hour follow-up)

CLAUDE.md Key Files annotations extended for the four lanes:
eval-longmemeval gains the --by-type description, eval-cross-modal
gains the --batch + DI seam description, new entries for the qrels
gate test + the nightly probe + audit-quality-probe writer.

* chore: bump version and changelog (v0.40.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(eval): close 4 codex-flagged eval-integrity bugs

Codex adversarial review on the Track D wave found 4 real ways the new
eval-gate code could silently bypass its gates. Each fix below either
counts what was previously dropped, fails fast on a parser edge case,
or enforces a gate that was previously skipped on an early-return path.

CDX-1: cross-modal --batch silently dropped failed/corrupt LongMemEval
rows. `gbrain eval longmemeval` emits {error:..., hypothesis:''} when
runOneQuestion throws; the batch reader's missing-field skip threw those
rows away, shrinking the denominator. A green eval on a subset is now
impossible:
  - eval-longmemeval.ts: error rows now carry `question` + `question_type`
    so the batch consumer can identify them as upstream failures, not
    skip them as malformed.
  - eval-cross-modal.ts: readBatchRows now returns {rows, upstream_errors,
    malformed_count}. Upstream errors fold into per_question with verdict
    'upstream_error'. BatchSummary gains `upstream_error_count` and
    `malformed_count`. ERROR exit precedence widens to include both, so
    any upstream failure exits 2.

CDX-2: --limit 0 was a direct CI bypass — zero-row check fired before
slicing, then the empty result fell through to verdict='pass'. Fixed
with a hard `limit >= 1` check.

CDX-3: --resume-from + --by-type-floor was a real gate skip. When a
prior run had every question answered, the early "nothing to do" return
fired BEFORE summary emission and floor enforcement. Now the no-op
resume path still seeds recallByType from the existing file, emits the
by_type_summary at the tail, and runs the floor gate.

CDX-5: doctor nightly_quality_probe_health only flagged fail / error /
budget_exceeded as warn. no_embedding_key / rate_limited / inconclusive
were silently reported as PASS — hiding misconfigurations and queue
backpressure. The bad-event filter is now `outcome !== 'pass'`, and the
counts string surfaces every bucket so the operator sees exactly what
went wrong.

scripts/check-privacy.sh: adds test/eval-replay-gate.test.ts to the
allowlist (the qrels test's privacy-grep regression guard literally
names what it forbids, same meta-rule exception as the existing
test/recency-decay.test.ts + skillpack-harvest allowlist entries).

Pinned by 8 new regression cases across eval-longmemeval (CDX-3),
eval-cross-modal-batch (CDX-1 + CDX-2), and nightly-quality-probe
(CDX-5). 76 Track D tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:16:25 -07:00

168 lines
7.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# CI guard: fail if any test fixture references a real person's name.
#
# CLAUDE.md's "Privacy rule" section is unambiguous: never reference real
# people, companies, funds, or private agent names in any public-facing
# artifact. Tests are checked-in code distributed with every release and
# indexed by GitHub search. This guard catches the patterns the rule names.
#
# Design (post-Codex F4 review):
# - Banned names: exact-string allowlist of known real identifiers. Adding
# a name when CLAUDE.md flags one is a one-line edit.
# - Banned emails: specific addresses that identify real contacts. NOT a
# broad corporate-email regex — those would catch legitimate fixture
# domains in billing/auth tests (`customer@stripe.com` etc.).
# - Allowlist: exact "file:offending-string" pairs that are intentional
# and pre-existing (e.g., the user's own email is not a "contact").
#
# Scope: test/**/*.test.ts only. Historical CHANGELOG entries, doc examples,
# and skill READMEs each have their own scrub status and are out of scope
# for this guard.
#
# Usage: scripts/check-test-real-names.sh
# Exit: 0 clean, 1 banned reference found, 2 setup error (rg + grep missing).
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# Banned real-name strings (matched as whole words, case-insensitive).
# Add an entry when CLAUDE.md flags a new real-person name.
BANNED_NAMES=(
'Diana' # Diana Hu, named in CLAUDE.md privacy example
'Wintermute' # private OpenClaw fork name (CLAUDE.md rule)
'Hermes' # downstream agent fork name
'Technium' # real GP handle
'McGrew' # ex-OpenAI exec
'YC Labs' # internal team name
)
# Banned specific email addresses. NOT a generic corporate-email regex —
# those would catch legitimate fixture domains in billing/auth tests
# (`customer@stripe.com`, `account@openai.com` etc).
BANNED_EMAILS=(
'diana@ycombinator.com'
)
# Exact "file:offending-string" pairs that are intentional and pre-existing.
# These pre-date the rule, the file's own author confirmed the use, the
# string identifies the user themselves (not a contact), OR the reference
# is structural (e.g., a regression test that ASSERTS the banned name does
# NOT appear in production code — the name MUST be in the test file as a
# literal).
ALLOWLIST=(
"test/writer.test.ts:garry@ycombinator.com" # user's own email — CLAUDE.md rule does not apply
"test/integrations.test.ts:Wintermute" # regex pattern in personal-info filter test (structural)
"test/recency-decay.test.ts:Wintermute" # regression-prevention test asserting wintermute is absent (structural)
"test/scripts/check-proposal-pii.test.ts:Wintermute" # privacy-guard test asserting docs/proposals/ rejects wintermute (structural; same meta-rule exception as check-privacy.sh)
"test/scripts/check-proposal-pii.test.ts:WINTERMUTE" # case-insensitive sentinel literal for the same privacy-guard test
"test/serve-stdio-lifecycle.test.ts:Hermes" # comment naming a downstream-agent scenario — pre-existing, low signal
"test/extract.test.ts:Hermes" # markdown-link extraction test fixture — pre-existing, ambiguous (Greek god vs fork)
"test/readme-hero-anchors.test.ts:Hermes" # v0.36.0.0 D9 anchor test — asserts README mentions Hermes as a credit
"test/readme-hero-anchors.test.ts:OpenClaw" # v0.36.0.0 D9 anchor test — asserts README mentions OpenClaw as a credit
# v0.36.0.0: skillpack-harvest privacy linter tests structurally
# require the literal "Wintermute" to verify the linter catches it.
# Same meta-rule exception as integrations.test.ts and the proposal-pii
# privacy guard test above.
"test/skillpack-harvest.test.ts:Wintermute"
"test/skillpack-harvest-lint.test.ts:Wintermute"
"test/e2e/skillpack-flow.test.ts:Wintermute"
# v0.40.1.0 Track D: eval-replay-gate.test.ts has a privacy-grep regression
# guard whose block list necessarily SPELLS the real names so the test can
# assert they're NOT in the qrels fixture. Same meta-rule exception as the
# skillpack-harvest privacy tests above.
"test/eval-replay-gate.test.ts:Pedro Franceschi"
"test/eval-replay-gate.test.ts:Brex"
"test/eval-replay-gate.test.ts:Wintermute"
"test/eval-replay-gate.test.ts:Garry Tan"
"test/eval-replay-gate.test.ts:Y Combinator"
"test/eval-replay-gate.test.ts:YC"
)
# Build the combined regex. Names matched as whole words (\b), emails matched
# literally with dot escapes.
PATTERN_PARTS=()
for n in "${BANNED_NAMES[@]}"; do
# Escape any regex metacharacters in the name (defensive — most are bare
# words but YC Labs has a space).
escaped="${n//./\\.}"
escaped="${escaped// /\\s}"
PATTERN_PARTS+=("\\b${escaped}\\b")
done
for e in "${BANNED_EMAILS[@]}"; do
escaped="${e//./\\.}"
PATTERN_PARTS+=("${escaped}")
done
# Join with |.
IFS='|' eval 'PATTERN="${PATTERN_PARTS[*]}"'
# Find tool.
if command -v rg >/dev/null 2>&1; then
matches="$(rg -niH --no-heading -t ts "$PATTERN" test/ 2>/dev/null || true)"
elif command -v grep >/dev/null 2>&1; then
matches="$(grep -rniE --include='*.test.ts' "$PATTERN" test/ 2>/dev/null || true)"
else
echo "check-test-real-names: ERROR: neither rg nor grep available." >&2
exit 2
fi
if [ -z "$matches" ]; then
exit 0
fi
# Apply allowlist. Each line is "file:lineno:content"; check whether
# "file:<needle>" appears in ALLOWLIST for any needle in BANNED_EMAILS+NAMES
# that matches the content.
filtered=""
while IFS= read -r line; do
[ -z "$line" ] && continue
# Extract filename and content (everything after second :).
file="${line%%:*}"
rest="${line#*:}"
# rest is "lineno:content" — strip lineno.
content="${rest#*:}"
matched_needle=""
for needle in "${BANNED_EMAILS[@]}" "${BANNED_NAMES[@]}"; do
if echo "$content" | grep -qi -- "$needle"; then
matched_needle="$needle"
break
fi
done
allow_key="${file}:${matched_needle}"
allowed=0
for allow_entry in "${ALLOWLIST[@]}"; do
if [ "$allow_entry" = "$allow_key" ]; then
allowed=1
break
fi
done
if [ "$allowed" = "0" ]; then
filtered+="${line}"$'\n'
fi
done <<< "$matches"
if [ -z "$filtered" ]; then
exit 0
fi
echo "check-test-real-names: banned real-name references found in test/ fixtures." >&2
echo "" >&2
echo "$filtered" >&2
echo "" >&2
echo "Fix: replace with canonical placeholders per CLAUDE.md 'Name mapping' table." >&2
echo " alice-example / @alice-example for people" >&2
echo " bob-example / charlie-example for additional people" >&2
echo " alice@example.com for emails (example.com is RFC 6761 reserved)" >&2
echo " acme-example / widget-co for companies" >&2
echo " fund-a / fund-b for funds" >&2
echo " a-team / agent-fork for teams / OpenClaw forks" >&2
echo "" >&2
echo "If the match is intentional (e.g., the user's own identifier, not a contact)," >&2
echo "add an exact 'file:string' entry to ALLOWLIST in scripts/check-test-real-names.sh." >&2
exit 1