Files
DietrichGebertandClaude Opus 4.8 caf138df56 benchmarks: fix correctness gate + robustness audit (#65) (#83)
* fix(benchmarks): correctness gate scores unfenced code; fix debounce task

The `correct` gate under-reported correctness for terse models, the likely
source of "Ponytail degrades models" reports (issue #65):

- extractBlocks() only matched fenced code blocks, so bare/unfenced code
  scored an automatic fail even when correct. Now falls back to the whole
  response as one block (and tolerates CRLF). Debounce detection also accepts
  unfenced arrow functions.
- The debounce task asked to "add debounce to a search input" but the check
  expected a reusable debounce(fn, delay) util, failing correct inline answers.
  Task reworded to the deliverable the check verifies.

Adds correctness.test.js (regression guard) and a GPT-mini repro config plus
results writeup: on a clean n=20 run, the reported gpt-4.1-mini drop (10/15)
does not reproduce (100/100). The LOC win (~halved) holds.

README repro fixed: promptfoo needs --env-file ../.env (reads cwd, not root).

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

* test(benchmarks): add robustness audit — ponytail vs baseline on edge cases

Answers the real question behind #65: does ponytail's push for the shortest
solution make weak models produce wrong code on edge cases?

robustness-audit.js: 16 self-verifying tasks (12 algorithmic edge-case traps +
4 validators). Each check ships a known-good and known-lazy-wrong reference that
must pass/fail before any model output is scored (--selftest, 16/16).

Findings (gpt-4.1-mini + gpt-5.4-mini, baseline vs ponytail): parity on every
edge-case trap on both models. The one measured soft spot is gpt-5.4-mini email
(~4-5%, reaches for parseaddr). A sharpened SKILL.md validation rule had no
reliable effect in an n=100 A/B (96% vs 95%), so it was not shipped — the
tendency is model-level, not skill-level. Full writeup in results/.

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

* test(benchmarks): email slip is provider-specific — 100% on Claude

High-n cross-provider follow-up to the robustness audit. The one ponytail
soft spot (email validation via parseaddr) splits by provider, not model size:

- Claude (haiku/sonnet/opus): 100% under ponytail, n=40 each — and ponytail
  beats baseline (unconstrained Sonnet over-engineers into an always-truthy
  dict, 0/40; ponytail writes a clean validator).
- OpenAI (gpt-4.1-mini..gpt-5.5): slips at every size under ponytail
  (~79-98%), baseline ~100%. The parseaddr reflex lives in OpenAI training.

Not fixable by skill text: 8 distinct SKILL.md edits (incl. an n=100 A/B,
96% vs 95%) all scored <= current, several worse, all bloated LOC. Nothing
shipped. SKILL.md unchanged.

Conclusion: on ponytail's target platform (Claude) email is 100%; the GPT
slip is a documented cross-provider transfer quirk. Adds model-email.js /
claude-email.js to reproduce the tables. Writeup updated.

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

* docs(benchmarks): correct misleading Sonnet baseline 0 percent

The Sonnet baseline 0/40 on email is a return-type artifact, not a logic
failure: unconstrained Sonnet returns a dict {is_valid, message} instead of a
bool, so the bool-contract gate scores every case as accepted. Read dict-aware
via is_valid, its logic is ~75% correct (9/12). Reframed honestly so we are not
presenting 0 vs 100 as a clean win; ponytail still wins (clean 100% bool) but
the point is over-engineered return type, not total failure.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:17:10 +02:00

282 lines
9.8 KiB
JavaScript

// Functional correctness assertion: runs generated code against lightweight test
// cases per task. Proves "less code" is not "broken code". Spawns python/node
// with the extracted code + appended assertions; returns pass/fail + score.
//
// Metric: `correct` (1 = all checks pass, 0 = at least one fails).
// Unlike loc.js (measurement-only), this one is a gate — a wrong answer is a
// wrong answer regardless of how few lines produced it.
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
// Extract fenced code blocks, tagged by language.
function extractBlocks(text) {
text = String(text || '');
const matches = [...text.matchAll(/```(\w*)\r?\n([\s\S]*?)```/g)];
// ponytail: terse models often answer with bare, unfenced code. Treat the whole
// response as one block so the gate scores the code instead of reporting "no block".
if (matches.length === 0 && text.trim()) return [{ lang: '', code: text }];
return matches.map((m) => ({ lang: (m[1] || '').toLowerCase(), code: m[2] }));
}
// Identify which task we're evaluating from vars.task.
function identifyTask(task) {
const t = task.toLowerCase();
if (t.includes('email') && t.includes('valid')) return 'email';
if (t.includes('debounce')) return 'debounce';
if (t.includes('csv') && t.includes('sum')) return 'csv';
if (t.includes('countdown') && t.includes('react')) return 'countdown';
if (t.includes('rate limit') || t.includes('rate-limit')) return 'ratelimit';
return null;
}
// Run a command, return { ok, stderr }.
function exec(cmd, opts = {}) {
try {
execSync(cmd, { timeout: 10_000, encoding: 'utf8', stdio: 'pipe', ...opts });
return { ok: true, stderr: '' };
} catch (e) {
return { ok: false, stderr: (e.stderr || e.message || '').slice(0, 500) };
}
}
// ponytail: probe once at load; macOS and many Linux images ship python3 only.
let pythonCmd;
function python() {
if (pythonCmd) return pythonCmd;
for (const cmd of ['python3', 'python']) {
if (exec(`${cmd} -c "import sys"`).ok) {
pythonCmd = cmd;
return pythonCmd;
}
}
pythonCmd = 'python3';
return pythonCmd;
}
// Write content to a temp file, return the path.
function tmpFile(ext, content) {
const p = path.join(os.tmpdir(), `ponytail-bench-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
fs.writeFileSync(p, content);
return p;
}
// --- Per-task test harnesses ---
const CHECKS = {
email(blocks) {
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && b.code.includes('def ')));
if (!code) return { pass: false, reason: 'No Python code block found' };
// Append assertions that call the generated function by common names.
const harness = `
${code.code}
# Find the validator function
import sys
fn = None
for name in ['validate_email', 'is_valid_email', 'email_validator', 'is_valid', 'validate']:
if name in dir() and callable(eval(name)):
fn = eval(name)
break
if fn is None:
# Try any function that takes one arg
import inspect
for name, obj in list(globals().items()):
if callable(obj) and not name.startswith('_'):
try:
sig = inspect.signature(obj)
if len(sig.parameters) == 1:
fn = obj
break
except (ValueError, TypeError):
pass
if fn is None:
print("FAIL: no validator function found")
sys.exit(1)
# Test cases
failures = []
if not fn("user@example.com"):
failures.append("rejected valid: user@example.com")
if not fn("a@b.co"):
failures.append("rejected valid: a@b.co")
if fn("no-at-sign"):
failures.append("accepted invalid: no-at-sign")
if fn(""):
failures.append("accepted invalid: empty string")
if fn("@missing-local.com"):
failures.append("accepted invalid: @missing-local.com")
if failures:
print("FAIL: " + "; ".join(failures))
sys.exit(1)
print("PASS")
`;
const f = tmpFile('.py', harness);
const result = exec(`${python()} "${f}"`);
fs.unlinkSync(f);
if (result.ok) return { pass: true, reason: 'Email validator passes all checks' };
return { pass: false, reason: result.stderr || 'Email validator failed' };
},
debounce(blocks) {
const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && (b.code.includes('function') || b.code.includes('=>'))));
if (!code) return { pass: false, reason: 'No JavaScript code block found' };
const harness = `
${code.code}
// Find the debounce function
const fn = typeof debounce === 'function' ? debounce
: typeof module !== 'undefined' && typeof module.exports === 'function' ? module.exports
: null;
if (!fn) {
console.error("FAIL: no debounce function found");
process.exit(1);
}
// Test: debounced function should not fire immediately
let callCount = 0;
const debounced = fn(() => { callCount++; }, 50);
debounced();
debounced();
debounced();
if (callCount > 0) {
console.error("FAIL: debounce fired immediately (should wait)");
process.exit(1);
}
// Test: should fire after the delay
setTimeout(() => {
if (callCount !== 1) {
console.error("FAIL: expected 1 call after delay, got " + callCount);
process.exit(1);
}
console.log("PASS");
}, 120);
`;
const f = tmpFile('.mjs', harness);
const result = exec(`node "${f}"`);
fs.unlinkSync(f);
if (result.ok) return { pass: true, reason: 'Debounce passes all checks' };
return { pass: false, reason: result.stderr || 'Debounce failed' };
},
csv(blocks) {
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && b.code.includes('csv') && b.code.includes('sum')));
if (!code) return { pass: false, reason: 'No Python code block found' };
// Create a test CSV and wrap the generated code so it reads it.
const csvContent = 'name,amount\nAlice,100.5\nBob,200.0\nCharlie,50.5\n';
const csvPath = tmpFile('.csv', csvContent).replace(/\\/g, '/');
// The generated code likely reads 'sales.csv'; patch the filename.
let patched = code.code.replace(/['"]sales\.csv['"]/g, `'${csvPath}'`);
// Also try open() calls
patched = patched.replace(/open\(\s*['"]sales\.csv['"]/g, `open('${csvPath}'`);
const harness = `
import sys, os
os.chdir(r"${path.dirname(csvPath)}")
# Capture print output
import io
_stdout = sys.stdout
sys.stdout = io.StringIO()
try:
${patched.split('\n').map((l) => ' ' + l).join('\n')}
except Exception as e:
sys.stdout = _stdout
# If it needs sales.csv in cwd, write it there and retry
pass
output = sys.stdout.getvalue()
sys.stdout = _stdout
# Check output contains the number 351 (100.5 + 200.0 + 50.5)
# Match as a standalone number (not as substring of e.g. 13510)
import re
if re.search(r'(?<![\\d])351(?:\\.0)?(?![\\d])', output):
print("PASS")
else:
# Try running it differently: maybe it defines a function
print("FAIL: output was: " + repr(output[:200]))
sys.exit(1)
`;
const f = tmpFile('.py', harness);
const result = exec(`${python()} "${f}"`);
try { fs.unlinkSync(f); } catch (e) {}
try { fs.unlinkSync(csvPath); } catch (e) {}
if (result.ok) return { pass: true, reason: 'CSV sum produces correct result (351)' };
return { pass: false, reason: result.stderr || 'CSV sum failed' };
},
countdown(blocks) {
// React components can't run in bare Node without a bundler. Structural check:
// the code must contain timer/countdown logic (useState/useEffect/setInterval/setTimeout).
const code = blocks.find((b) => b.code.includes('ount') || b.code.includes('timer') || b.code.includes('Timer'));
if (!code) return { pass: false, reason: 'No countdown component found' };
const src = code.code;
const hasState = /useState|useReducer|this\.state/.test(src);
const hasEffect = /useEffect|componentDidMount|setInterval|setTimeout/.test(src);
const hasDecrement = /- 1|-= 1|prev - 1|count - 1|seconds - 1|time - 1/.test(src);
const failures = [];
if (!hasState) failures.push('no state management (useState/useReducer)');
if (!hasEffect) failures.push('no timer setup (useEffect/setInterval/setTimeout)');
if (!hasDecrement) failures.push('no countdown decrement logic');
if (failures.length === 0) return { pass: true, reason: 'Countdown has required structure' };
return { pass: false, reason: 'Missing: ' + failures.join(', ') };
},
ratelimit(blocks) {
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && (b.code.includes('rate') || b.code.includes('limit'))));
if (!code) return { pass: false, reason: 'No Python code block found' };
// Structural check for rate limiting: must have some form of counter/time tracking.
const src = code.code;
const hasTimeTracking = /time\.|datetime|asyncio/.test(src);
const hasLimitLogic = /limit|max_requests|rate|429|Too Many|HTTPException|RateLimiter/.test(src);
const hasFastAPI = /fastapi|FastAPI|app\s*=|@app\./.test(src);
const failures = [];
if (!hasLimitLogic) failures.push('no rate limit logic');
if (!hasFastAPI) failures.push('no FastAPI usage');
if (failures.length === 0) return { pass: true, reason: 'Rate limiter has required structure' };
return { pass: false, reason: 'Missing: ' + failures.join(', ') };
},
};
// --- Main assertion entry point ---
module.exports = (output, context) => {
const task = identifyTask(context.vars.task || '');
if (!task) {
return { pass: true, score: 1, reason: 'Unknown task, skipped correctness check' };
}
const blocks = extractBlocks(String(output || ''));
if (blocks.length === 0) {
return { pass: false, score: 0, reason: 'No code blocks in output' };
}
const check = CHECKS[task];
const result = check(blocks);
return {
pass: result.pass,
score: result.pass ? 1 : 0,
reason: result.reason,
};
};