Files
gbrain/eval/runner/queries/validator.ts
T
Garry TanandClaude Opus 4.7 e2a5dc4a2b feat(eval): Phase 2 query validator + Tier 5 Fuzzy + Tier 5.5 synthetic + N=5 tolerance bands
Closes multiple Phase 2 items in one commit since they form a cohesive
package: query schema enforcement + new query tiers + per-query-set
statistical rigor.

Added:
  eval/runner/queries/validator.ts               — hand-rolled Query schema validator
  eval/runner/queries/validator.test.ts          — 24 unit tests, all pass
  eval/runner/queries/tier5-fuzzy.ts             — 30 hand-authored Tier 5 Fuzzy/Vibe queries
  eval/runner/queries/tier5_5-synthetic.ts       — 50 SYNTHETIC-labeled outsider-style queries (author: "synthetic-outsider-v1")
  eval/runner/queries/index.ts                   — aggregator + validateAll()

Modified:
  eval/runner/multi-adapter.ts                   — N=5 runs per adapter (BRAINBENCH_N override), page-order shuffle, mean±stddev reporting

Query validator (hand-rolled, no zod dep to match gbrain codebase style):
  - Temporal verb regex enforces as_of_date (per eng pass 2 spec):
    /\\b(is|was|were|current|now|at the time|during|as of|when did)\\b/i
  - Validates tier enum, expected_output_type enum, gold shape per type
  - gold.relevant must be non-empty slug[] for cited-source-pages queries
  - abstention requires gold.expected_abstention === true
  - externally-authored tier requires author field
  - batch validation catches duplicate IDs

Tier 5 Fuzzy/Vibe (30 queries, hand-authored):
  - Vague recall: "Someone who was a senior engineer at a biotech company..."
  - Trait-based: "The engineer who pushed back on microservices"
  - Cultural/epithet: "Who is known as a 'systems builder' in security?"
  - Abstention bait: "Which Layer 1 project did the crypto guy leave?" (prose
    mentions but never names; good systems abstain)
  - Addresses Codex's circularity critique — vague queries where graph-heavy
    systems shouldn't inherently win.

Tier 5.5 Synthetic Outsider (50 queries, AI-authored placeholder):
  - Clearly labeled author: "synthetic-outsider-v1"
  - Phrasing variety not in the 4 template families:
    * fragment style ("crypto founder Goldman Sachs background")
    * polite/natural ("Can you pull up what we have on...")
    * comparison ("What is the difference between X and Y?")
    * follow-up ("And who else advises Orbit Labs?")
    * typos/misspellings ("adam lopez bioinformatcis")
    * similarity ("Find me someone like Alice Davis...")
    * imperative ("Pull up Alice Davis")
  - Real Tier 5.5 from outside researchers supersedes synthetic via
    PRs to eval/external-authors/ (docs ship in follow-up commit).

N=5 tolerance bands:
  - Default N=5, override via BRAINBENCH_N env var (e.g. BRAINBENCH_N=1 for dev loops)
  - Per-run seeded Fisher-Yates shuffle of page ingest order (LCG seed = run_idx+1)
  - Surfaces order-dependent adapter bugs (tie-break-by-first-seen etc.)
  - Reports mean ± sample-stddev per metric
  - "stddev = 0" is honest signal that the adapter is deterministic, not a bug.
    LLM-judge metrics (future) will naturally produce non-zero stddev.

Validation: all 80 Tier 5 + 5.5 queries pass validateAll(). 24 validator
unit tests pass.

Next commit: world.html contributor explorer (Phase 3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:10:15 +08:00

205 lines
7.3 KiB
TypeScript

/**
* Runtime Query schema validator.
*
* Per the v1.1 eng pass 2 spec. Hand-rolled (no zod dep) to match existing
* gbrain codebase style (see src/core/yaml-lite.ts for precedent).
*
* Validates:
* - Required fields (id, tier, text, expected_output_type, gold)
* - Tier enum
* - expected_output_type enum
* - Temporal `as_of_date` rule: any query with a temporal verb MUST
* set as_of_date to ISO-8601 | "corpus-end" | "per-source"
* - id uniqueness within a batch
* - gold.relevant structure when the expected_output_type is
* 'cited-source-pages' (most common tier-1/2/3 pattern)
*
* Public functions:
* validateQuery(q) -> ValidationResult single-query
* validateQuerySet(qs) -> ValidationResult<batch>
*
* On failure, returns human-readable reasons with the offending query id
* so `eval:query:validate` can point contributors at the exact problem.
*/
import type { Query, Tier, ExpectedOutputType } from '../types.ts';
// ─── Enums ─────────────────────────────────────────────────────────
const VALID_TIERS: readonly Tier[] = [
'easy', 'medium', 'hard', 'adversarial', 'fuzzy', 'externally-authored',
] as const;
const VALID_OUTPUT_TYPES: readonly ExpectedOutputType[] = [
'answer-string',
'canonical-entity-id',
'cited-source-pages',
'time-qualified-answer',
'abstention',
'contradiction-explanation',
'poison-flag',
'confidence-score',
] as const;
// ─── Temporal rule (per eng pass 2) ────────────────────────────────
/**
* Regex for detecting temporal verbs in query text. If any of these
* appear, the query is temporal and MUST carry an `as_of_date` field.
* Without that, scoring is ambiguous (which version of the fact is
* considered correct?).
*/
export const TEMPORAL_VERBS =
/\b(is|was|were|current|now|at the time|during|as of|when did)\b/i;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T.*)?$/;
// ─── Types ─────────────────────────────────────────────────────────
export interface ValidationIssue {
queryId: string;
field: string;
reason: string;
}
export interface ValidationResult {
ok: boolean;
issues: ValidationIssue[];
/** Count of queries processed (for batch). 1 for single-query validation. */
total: number;
}
// ─── Individual query validation ───────────────────────────────────
export function validateQuery(q: Query): ValidationResult {
const issues: ValidationIssue[] = [];
const qid = q.id || '(missing id)';
if (!q.id || typeof q.id !== 'string' || q.id.trim().length === 0) {
issues.push({ queryId: qid, field: 'id', reason: 'id must be a non-empty string (e.g. "q-0001")' });
}
if (!q.text || typeof q.text !== 'string' || q.text.trim().length === 0) {
issues.push({ queryId: qid, field: 'text', reason: 'text must be a non-empty string' });
}
if (!VALID_TIERS.includes(q.tier)) {
issues.push({ queryId: qid, field: 'tier', reason: `tier must be one of ${VALID_TIERS.join(', ')}` });
}
if (!VALID_OUTPUT_TYPES.includes(q.expected_output_type)) {
issues.push({
queryId: qid,
field: 'expected_output_type',
reason: `expected_output_type must be one of ${VALID_OUTPUT_TYPES.join(', ')}`,
});
}
if (!q.gold || typeof q.gold !== 'object') {
issues.push({ queryId: qid, field: 'gold', reason: 'gold must be an object' });
}
// Temporal as-of-date rule (eng pass 2).
if (q.text && TEMPORAL_VERBS.test(q.text)) {
if (q.as_of_date === undefined || q.as_of_date === null || q.as_of_date === '') {
issues.push({
queryId: qid,
field: 'as_of_date',
reason:
'temporal verb detected; as_of_date required. Set to "corpus-end", "per-source", or an ISO-8601 date.',
});
} else if (
q.as_of_date !== 'corpus-end' &&
q.as_of_date !== 'per-source' &&
!ISO_DATE_RE.test(q.as_of_date)
) {
issues.push({
queryId: qid,
field: 'as_of_date',
reason: 'as_of_date must be "corpus-end", "per-source", or ISO-8601 (YYYY-MM-DD)',
});
}
}
// If expected_output_type is cited-source-pages, gold.relevant should exist
// and be a non-empty array of slug-like strings.
if (q.expected_output_type === 'cited-source-pages') {
const rel = (q.gold as Record<string, unknown>)?.relevant;
if (!Array.isArray(rel) || rel.length === 0) {
issues.push({
queryId: qid,
field: 'gold.relevant',
reason: 'cited-source-pages queries require gold.relevant[] with at least one slug',
});
} else {
for (const s of rel) {
if (typeof s !== 'string' || !/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(s)) {
issues.push({
queryId: qid,
field: 'gold.relevant',
reason: `slug "${s}" does not match "dir/slug" format (e.g. "people/alice-chen")`,
});
break; // one message per query is enough
}
}
}
}
// Abstention queries MUST set expected_abstention to true.
if (q.expected_output_type === 'abstention') {
const expAb = (q.gold as Record<string, unknown>)?.expected_abstention;
if (expAb !== true) {
issues.push({
queryId: qid,
field: 'gold.expected_abstention',
reason: 'abstention queries require gold.expected_abstention === true',
});
}
}
// Tier 5.5 externally-authored queries must carry an author field.
if (q.tier === 'externally-authored') {
if (!q.author || typeof q.author !== 'string' || q.author.trim().length === 0) {
issues.push({
queryId: qid,
field: 'author',
reason: 'externally-authored queries require an author field (e.g. "@alice-researcher" or "synthetic-outsider-v1")',
});
}
}
return { ok: issues.length === 0, issues, total: 1 };
}
// ─── Batch validation ───────────────────────────────────────────────
export function validateQuerySet(queries: Query[]): ValidationResult {
const issues: ValidationIssue[] = [];
const seenIds = new Set<string>();
for (const q of queries) {
const r = validateQuery(q);
issues.push(...r.issues);
// Duplicate ID check (batch-level).
if (q.id) {
if (seenIds.has(q.id)) {
issues.push({ queryId: q.id, field: 'id', reason: 'duplicate id in batch' });
}
seenIds.add(q.id);
}
}
return { ok: issues.length === 0, issues, total: queries.length };
}
// ─── Formatting helpers (for CLI output) ───────────────────────────
export function formatIssues(result: ValidationResult): string {
if (result.ok) {
return `\u2713 All ${result.total} queries valid.`;
}
const lines: string[] = [];
lines.push(`\u2717 ${result.issues.length} issue(s) across ${result.total} query/queries:`);
for (const issue of result.issues) {
lines.push(` [${issue.queryId}] ${issue.field}: ${issue.reason}`);
}
return lines.join('\n');
}