Skip to content

Commit 7d94d29

Browse files
committed
feat(sdlbench): enforce fair SDL behavior runs
1 parent 70fb89d commit 7d94d29

5 files changed

Lines changed: 224 additions & 32 deletions

File tree

sdlbench/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ SDLBench enforces truth in savings claims:
3939
of the same repo; `tokens.indexCost` is non-zero only on the first warm task.
4040
- **Coverage**: tasks with `contextTargets` produce `record.coverage` with
4141
file/symbol coverage, precision, and recall.
42+
- **Prompt specificity**: tasks declare `sparse`, `normal`, or `explicit`; records persist the tier and `analyze` reports `byPromptSpecificity` so sparse-task savings remain visible.
4243

4344
`setup all` creates `sdlbench/.work/tiktoken-venv` and installs OpenAI `tiktoken` from the pinned GitHub tag `0.13.0`. Benchmark runs fail if tiktoken cannot count tokens; they do not fall back to estimates.
4445

@@ -65,13 +66,13 @@ Each task copies `sdlbench/tests/fixtures/repo` into `sdlbench/.work/repos/<task
6566

6667
## SDL Evidence
6768

68-
For `--variant sdl`, the runner prepares a normal SDL-MCP HTTP server and indexes the copied fixture repo before the task starts. By default it starts a temporary `serve --http` process, waits until `/health` is reachable, then runs `POST /api/repo/:repoId/reindex-stream` with `mode: "full"`. It does not pre-run task-specific searches or paste fixture SDL context; behavior agents discover context through live tools. Tests can pass `sdlHttpBaseUrl` to use an existing server.
69+
For `--variant sdl`, the runner prepares a normal SDL-MCP HTTP server and indexes the copied fixture repo before the task starts. By default it starts a temporary `serve --http` process, waits until `/health` is reachable, then runs `POST /api/repo/:repoId/reindex-stream` with `mode: "full"`. It does not pre-run task-specific searches or paste fixture SDL context; behavior agents discover context through live tools. Tests can pass `sdlHttpBaseUrl` to use an existing server. Codex behavior runs using an external server must also pass `sdlConfigPath` so the production hook targets that server's pidfile.
6970

7071
The temporary config starts from `config/sdlmcp.config.example.json` and keeps provider-first indexing, Rust indexing, SCIP, semantic retrieval/enrichment, policy, prefetch, and exclusive Code Mode. SDLBench disables file watching because each copied repository is indexed explicitly before the measured run, and overrides only the copied root, graph DB path, local HTTP/auth settings, benchmark ignores, and repo languages. Provider-first counts as evidence only when the indexing response reports it.
7172

7273

7374

74-
SDL token counts use the rendered prompt plus measured agent session data when available. `context.sdl` and `context.sdlQueries` are fixture metadata, not privileged prompt input. If HTTP indexing fails, the SDL run fails instead of writing savings evidence.
75+
SDL token counts use the rendered prompt plus measured agent session data when available. `context.raw`, `context.sdl`, and `context.sdlQueries` are fixture metadata, not privileged behavior-mode prompt input. If HTTP indexing fails, or if both Codex attribution and server observability report zero SDL tool activity, the SDL run fails instead of writing savings evidence.
7576

7677
## Metrics
7778

@@ -98,7 +99,7 @@ Every executed non-baseline product uses the same session, analysis, scaling, ca
9899
Default runs stay in fixture mode: they apply task-local `solution.files`, then run the verifier. Use this for harness and token plumbing checks.
99100

100101
Pass `--behavior` to test model behavior. In behavior mode, SDLBench writes `.sdlbench-prompt.md` into the copied repo, runs the configured agent command template from `config/agents/<agent>.json`, then verifies the files the command changed. The checked-in Codex config defaults to `gpt-5.5` with `model_reasoning_effort="xhigh"`. The command template can use `{repo}`, `{prompt}`, `{taskId}`, `{variant}`, `{model}`, `{sdlMcpConfig}`, and `{sdlMcpUrl}` placeholders. Override it directly with `--agent-command "cmd {repo} {prompt}"` for local smoke tests.
101-
All non-baseline products receive the same neutral task prompt. SDLBench supplies the normal live MCP server; SDL workflow guidance is discovered from the server tool surface when tools are first loaded, not from prompt text, skills, repository files, or hooks supplied by SDLBench.
102+
Every variant receives the same neutral task prompt. SDLBench supplies the normal live MCP server, and the SDL Codex variant installs the production enforcement assets (`SDL.md`, `AGENTS.md`, `CODEX.md`, and `.codex/hooks/`) in the copied run root. These measured product assets provide workflow guidance and enforce SDL use without adding task-specific hints to the prompt.
102103

103104
Codex behavior runs are isolated from the developer environment. SDLBench uses an OS-temp worktree and temporary `CODEX_HOME`, copies only `auth.json`, and disables plugin, app, memory, personality, browser, computer-use, and discovered skill paths. A run fails if no matching Codex session token counts exist or if captured context contains Ponytail, generic plugin/app/skill instructions, or memory context.
104105

sdlbench/src/sdlbench.mjs

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ export async function runBenchmark(options = {}) {
131131
if (agent === "codex") {
132132
assertCodexWorktreeIsSterile(root, runRoot);
133133
codexRuntime = await prepareCodexSterileRuntime({ root, workDir, taskRunId });
134+
if (variant === "sdl") {
135+
await installCodexEnforcementAssets({
136+
runRoot,
137+
repoId: sdlSession.repoId,
138+
configPath: sdlSession.configPath ?? options.sdlConfigPath,
139+
});
140+
}
134141
agentRuntime = codexRuntime;
135142
} else if (agent === "opencode") {
136143
agentRuntime = await prepareOpencodeSterileRuntime({ root, workDir, taskRunId, sdlSession });
@@ -152,7 +159,7 @@ export async function runBenchmark(options = {}) {
152159
const durationMs = Math.round(performance.now() - activeStart);
153160
const wallMs = Math.round(performance.now() - started);
154161
const passed = verify.exitCode === 0 && (!agentResult || agentResult.exitCode === 0);
155-
const estimatedTokens = countSessionTokens(task, variant, tokenizerCommand, promptContextForVariant(task, variant), outputText, {
162+
const estimatedTokens = countSessionTokens(task, variant, tokenizerCommand, "", outputText, {
156163
model,
157164
encoding: modelPricing.encoding,
158165
});
@@ -221,6 +228,7 @@ export async function runBenchmark(options = {}) {
221228
repo: repoMeta,
222229
taskId: task.taskId,
223230
category: task.category,
231+
promptSpecificity: task.promptSpecificity,
224232
status: passed ? "pass" : "fail",
225233
durationMs,
226234
wallMs,
@@ -269,7 +277,14 @@ export async function runBenchmark(options = {}) {
269277
};
270278

271279
records.push(record);
272-
await appendFile(resultsPath, `${JSON.stringify(record)}\n`, "utf8");
280+
assertSdlBehaviorIntegrity({
281+
variant,
282+
executionMode,
283+
attribution: record.attribution,
284+
observability: record.artifacts?.sdl?.observability,
285+
claimGrade: record.claimGrade,
286+
});
287+
await appendFile(resultsPath, `${JSON.stringify(record)}\n`, "utf8");
273288
} finally {
274289
if (ownsSdlSession) await sdlSession?.stop?.();
275290
}
@@ -360,7 +375,7 @@ function finalizeCacheAggregate(aggregate) {
360375
};
361376
}
362377

363-
export function analyzeSessions(records) {
378+
function analyzeSessionsCore(records) {
364379
const byVariant = {};
365380
for (const record of records) {
366381
const executionMode = record.workflow?.executionMode ?? "unknown";
@@ -604,8 +619,12 @@ function validateTask(root, task) {
604619
if (!task.repo.sourcePath) throw new Error(`Task ${task.taskId} missing repo.sourcePath`);
605620
if (!task.verify.command) throw new Error(`Task ${task.taskId} missing verify.command`);
606621
if (!task.context?.raw || !task.context?.sdl) throw new Error(`Task ${task.taskId} missing context.raw/context.sdl`);
622+
const promptSpecificity = task.promptSpecificity ?? "normal";
623+
if (!["sparse", "normal", "explicit"].includes(promptSpecificity)) {
624+
throw new Error(`${source}: invalid promptSpecificity '${promptSpecificity}'`);
625+
}
607626
abs(root, task.repo.sourcePath);
608-
return task;
627+
return { ...task, promptSpecificity };
609628
}
610629

611630
async function applySolution(runRoot, task) {
@@ -641,20 +660,14 @@ async function loadAgentConfig(root, agent, options, { requireCommand = false }
641660
}
642661
}
643662

644-
export function renderAgentPrompt(task, variant) {
645-
const context = promptContextForVariant(task, variant);
663+
export function renderAgentPrompt(task, _variant) {
646664
return [
647665
`Task: ${task.taskId}`,
648666
task.prompt,
649-
...(context ? ["Context:", context] : []),
650667
"Edit this repository in place. Keep changes limited to the task."
651668
].join("\n\n");
652669
}
653670

654-
function promptContextForVariant(task, variant) {
655-
return variant === "baseline" ? task.context.raw : "";
656-
}
657-
658671
function runAgentCommand(config, { runRoot, promptPath, task, variant, model, sdlSession, agentRuntime }) {
659672
const command = renderCommandTemplate(config.commandTemplate, {
660673
repo: runRoot,
@@ -862,6 +875,7 @@ async function startSdlHttpSession({ root, workDir, runRoot, task, taskRunId, op
862875
const stop = observability.stop;
863876
return {
864877
baseUrl,
878+
configPath: options.sdlConfigPath,
865879
mcpUrl: baseUrl + "/mcp",
866880
repoId: task.repoId,
867881
evidence,
@@ -1689,3 +1703,89 @@ function isPathInside(parent, child) {
16891703
const normalizedChild = normalizeSessionPath(child);
16901704
return normalizedChild === normalizedParent || normalizedChild.startsWith(normalizedParent + "/");
16911705
}
1706+
1707+
1708+
export function assertSdlBehaviorIntegrity({
1709+
variant,
1710+
executionMode,
1711+
attribution,
1712+
observability,
1713+
claimGrade,
1714+
}) {
1715+
if (variant !== "sdl" || executionMode !== "behavior" || claimGrade !== "primary") return;
1716+
1717+
const attributed = (attribution?.toolCalls ?? []).some((call) =>
1718+
/(?:^|[._])sdl(?:[._]|$)|sdl_mcp/i.test(call?.name ?? call?.toolName ?? ""),
1719+
);
1720+
const observed = [
1721+
observability?.toolVolume_totalCalls,
1722+
observability?.retrieval_totalRetrievals,
1723+
].some((value) => Number(value) > 0);
1724+
1725+
if (!attributed && !observed) {
1726+
throw new Error(
1727+
"SDL behavior run recorded zero SDL tool activity; benchmark evidence is invalid.",
1728+
);
1729+
}
1730+
}
1731+
1732+
1733+
export async function installCodexEnforcementAssets({
1734+
runRoot,
1735+
repoId,
1736+
configPath,
1737+
}) {
1738+
if (!configPath) {
1739+
throw new Error("SDL Codex behavior runs require the SDL server config path.");
1740+
}
1741+
1742+
const { buildEnforcementAssets } = await import(
1743+
new URL("../../dist/cli/commands/init.js", import.meta.url)
1744+
);
1745+
const { dirname } = await import("node:path");
1746+
const { chmod } = await import("node:fs/promises");
1747+
const assets = buildEnforcementAssets(runRoot, repoId, configPath, "codex");
1748+
1749+
for (const asset of assets) {
1750+
if (existsSync(asset.path)) continue;
1751+
await mkdir(dirname(asset.path), { recursive: true });
1752+
await writeFile(
1753+
asset.path,
1754+
asset.content.endsWith("\n") ? asset.content : `${asset.content}\n`,
1755+
"utf8",
1756+
);
1757+
if (asset.executable) {
1758+
await chmod(asset.path, 0o755);
1759+
}
1760+
}
1761+
}
1762+
1763+
1764+
export function analyzeSessions(records) {
1765+
const summary = analyzeSessionsCore(records);
1766+
const byPromptSpecificity = {};
1767+
1768+
for (const promptSpecificity of [
1769+
"sparse",
1770+
"normal",
1771+
"explicit",
1772+
"unspecified",
1773+
]) {
1774+
const matching = records.filter(
1775+
(record) =>
1776+
(record.promptSpecificity ?? "unspecified") === promptSpecificity,
1777+
);
1778+
if (matching.length === 0) continue;
1779+
1780+
const tierSummary = analyzeSessionsCore(matching);
1781+
byPromptSpecificity[promptSpecificity] = {
1782+
sessions: tierSummary.totals.sessions,
1783+
paired: tierSummary.totals.paired,
1784+
pairedMedianDeltaPct: tierSummary.pairedMedianDeltaPct,
1785+
byVariant: tierSummary.byVariant,
1786+
deltas: tierSummary.deltas,
1787+
};
1788+
}
1789+
1790+
return { ...summary, byPromptSpecificity };
1791+
}

sdlbench/tasks/fixture.tasks.json

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@
1919
]
2020
},
2121
"contextTargets": {
22-
"files": ["src/cart.js"],
23-
"symbols": ["buildCart", "resolvePromo"]
22+
"files": [
23+
"src/cart.js"
24+
],
25+
"symbols": [
26+
"buildCart",
27+
"resolvePromo"
28+
]
2429
},
2530
"verify": {
2631
"command": "node tests/discount-tax.test.mjs",
@@ -33,7 +38,8 @@
3338
"files": {
3439
"src/cart.js": "import { getProduct } from \"./catalog.js\";\nimport { customerAdjustment, resolvePromo } from \"./discounts.js\";\nimport { formatMoney } from \"./money.js\";\n\nconst TAX_RATE = 0.0825;\n\nexport function buildCart(lines, options = {}) {\n const entries = lines.map((line) => {\n const product = getProduct(line.sku);\n return {\n sku: product.sku,\n name: product.name,\n quantity: line.quantity,\n unitPriceCents: product.priceCents,\n lineTotalCents: product.priceCents * line.quantity\n };\n });\n const subtotalCents = entries.reduce((sum, entry) => sum + entry.lineTotalCents, 0);\n const promo = resolvePromo(options.promoCode, subtotalCents);\n const customerDiscount = customerAdjustment(options.customer, subtotalCents);\n const discounts = [promo, customerDiscount].filter((discount) => discount.amountCents > 0);\n const discountCents = discounts.reduce((sum, discount) => sum + discount.amountCents, 0);\n const taxableCents = Math.max(0, subtotalCents - discountCents);\n const taxCents = Math.round(taxableCents * TAX_RATE);\n const totalCents = subtotalCents - discountCents + taxCents;\n\n return { entries, subtotalCents, discounts, discountCents, taxCents, totalCents };\n}\n\nexport function summarizeCart(cart) {\n return {\n itemCount: cart.entries.reduce((sum, entry) => sum + entry.quantity, 0),\n subtotal: formatMoney(cart.subtotalCents),\n discounts: formatMoney(cart.discountCents),\n tax: formatMoney(cart.taxCents),\n total: formatMoney(cart.totalCents)\n };\n}\n"
3540
}
36-
}
41+
},
42+
"promptSpecificity": "sparse"
3743
},
3844
{
3945
"schemaVersion": 1,
@@ -66,7 +72,8 @@
6672
"src/shipping.js": "export function estimateShipping(cart, customer = {}) {\n const priority = customer.tier === \"gold\" && cart.subtotalCents >= 9000;\n const free = priority || cart.subtotalCents >= 6500;\n return {\n method: priority ? \"priority\" : customer.region === \"EU\" ? \"international\" : \"standard\",\n etaDays: priority ? 2 : customer.region === \"EU\" ? 8 : 5,\n shippingCents: free ? 0 : 799\n };\n}\n",
6773
"src/cart.js": "import { getProduct } from \"./catalog.js\";\nimport { customerAdjustment, resolvePromo } from \"./discounts.js\";\nimport { formatMoney } from \"./money.js\";\nimport { estimateShipping } from \"./shipping.js\";\n\nconst TAX_RATE = 0.0825;\n\nexport function buildCart(lines, options = {}) {\n const entries = lines.map((line) => {\n const product = getProduct(line.sku);\n return {\n sku: product.sku,\n name: product.name,\n quantity: line.quantity,\n unitPriceCents: product.priceCents,\n lineTotalCents: product.priceCents * line.quantity\n };\n });\n const subtotalCents = entries.reduce((sum, entry) => sum + entry.lineTotalCents, 0);\n const promo = resolvePromo(options.promoCode, subtotalCents);\n const customerDiscount = customerAdjustment(options.customer, subtotalCents);\n const discounts = [promo, customerDiscount].filter((discount) => discount.amountCents > 0);\n const discountCents = discounts.reduce((sum, discount) => sum + discount.amountCents, 0);\n const taxableCents = Math.max(0, subtotalCents - discountCents);\n const taxCents = Math.round(taxableCents * TAX_RATE);\n const totalCents = subtotalCents - discountCents + taxCents;\n\n return { entries, subtotalCents, discounts, discountCents, taxCents, totalCents };\n}\n\nexport function summarizeCart(cart) {\n return {\n itemCount: cart.entries.reduce((sum, entry) => sum + entry.quantity, 0),\n subtotal: formatMoney(cart.subtotalCents),\n discounts: formatMoney(cart.discountCents),\n tax: formatMoney(cart.taxCents),\n total: formatMoney(cart.totalCents)\n };\n}\n\nexport function createCheckoutSummary(cart, options = {}) {\n const shipping = estimateShipping(cart, options.customer);\n const grandTotalCents = cart.totalCents + shipping.shippingCents;\n const flags = [];\n if (cart.discounts.some((discount) => discount.code === \"GOLD5\")) flags.push(\"gold-loyalty\");\n if (shipping.method === \"priority\") flags.push(\"priority-shipping\");\n return {\n ...summarizeCart(cart),\n shipping,\n grandTotalCents,\n displayTotal: formatMoney(grandTotalCents),\n flags\n };\n}\n"
6874
}
69-
}
75+
},
76+
"promptSpecificity": "normal"
7077
},
7178
{
7279
"schemaVersion": 1,
@@ -98,7 +105,8 @@
98105
"src/orders.js": "import { createHash, randomBytes } from \"node:crypto\";\n\nconst orders = [];\n\nexport function placeOrder(cart, payment) {\n if (!payment?.token) throw new Error(\"payment token required\");\n const order = {\n id: `ord_${randomBytes(6).toString(\"hex\")}`,\n lines: cart.entries.map((entry) => ({ ...entry })),\n totalCents: cart.totalCents,\n paymentFingerprint: fingerprint(payment.token),\n status: \"paid\"\n };\n orders.push(order);\n return cloneOrder(order);\n}\n\nexport function listOrders() {\n return orders.map(cloneOrder);\n}\n\nfunction fingerprint(token) {\n return createHash(\"sha256\").update(token).digest(\"hex\").slice(0, 12);\n}\n\nfunction cloneOrder(order) {\n return { ...order, lines: order.lines.map((line) => ({ ...line })) };\n}\n",
99106
"src/audit.js": "export function auditOrder(order) {\n return `${order.id}:paymentFingerprint=${order.paymentFingerprint}:${order.totalCents}:${order.status}`;\n}\n"
100107
}
101-
}
108+
},
109+
"promptSpecificity": "explicit"
102110
},
103111
{
104112
"schemaVersion": 1,
@@ -131,7 +139,8 @@
131139
"files": {
132140
"review-report.md": "# Checkout Risk Review\n\n## Priority Findings\n\n1. Raw payment token is stored on orders and then emitted by auditOrder. Replace the payment token with a one-way payment fingerprint and never include the token in audit output.\n2. Order ids use Date.now, which is predictable and can collide under concurrent order placement. Use random or monotonic ids with enough entropy.\n3. Catalog products expose stock, but placeOrder never checks inventory or reserves stock before marking an order paid. Add an inventory validation/reservation boundary before payment capture is treated as final.\n4. listOrders returns mutable internal order objects, so callers can change stored status. Return defensive copies or immutable records.\n5. Cart tax calculation must be checked around discounts; tax should be calculated on the discounted taxable subtotal, not the pre-discount subtotal.\n6. Shipping rules have no priority path for high-value loyalty customers, so priority support purchases do not affect fulfillment. Make priority shipping rules explicit and covered by tests.\n"
133141
}
134-
}
142+
},
143+
"promptSpecificity": "sparse"
135144
}
136145
]
137146
}

0 commit comments

Comments
 (0)