Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/shared/graders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ to `Implemented` in the same PR that adds `shared/graders/<id>.md`.

| Rank | Grader ID | Runtime requirement | Status |
|---|---|---|---|
| 1 | `policy-near-miss` | Policy/guard predicates | Not started |
| 1 | `policy-near-miss` | Policy/guard predicates | Implemented |
| 2 | `skill-constraint-coverage` | Precompiled constraints | Not started |
| 3 | `exploration-error` | State/task model | Not started |
| 4 | `exploitation-error` | State/task model | Not started |
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/shared/graders/policy-near-miss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
graders:
# Detects "successful" traces (traces that emitted at least one safe_output
# event) which nonetheless left one or more guard/policy-shaped objectives
# unsatisfied -- i.e. traces that reached the correct outcome without
# performing required checks. Guard-shaped objectives are matched by
# keyword against objectives[].description. Lower is better: fewer
# near-misses.
policy-near-miss:
name: Policy Near-Miss Rate
unit: ratio
direction: lower_is_better
min: 0.0
max: 1.0
script: |
const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
const candidates = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L17-35: shrink: seven-entry candidate matrix and manual loop. Replace it with one find over the candidate list and read .events/.objectives directly.

trace.trajectoryIR,
trace.trajectoryIr,
trace.ir,
isRecord(trace.agentOutput) ? trace.agentOutput.trajectoryIR : null,
isRecord(trace.agentOutput) ? trace.agentOutput.trajectoryIr : null,
isRecord(trace.agentOutput) ? trace.agentOutput.trajectory : null,
isRecord(trace.agentOutput) ? trace.agentOutput : null,
].filter(isRecord);

const candidate = candidates.find(value => Array.isArray(value.events) && Array.isArray(value.objectives));
const events = candidate?.events ?? candidates.find(value => Array.isArray(value.events))?.events ?? [];
const objectives = candidate?.objectives ?? candidates.find(value => Array.isArray(value.objectives))?.objectives ?? [];

if (objectives.length === 0) {
return { value: null, unit: "ratio", passed: null, message: "not applicable: no declared objectives in the trace" };
}

const reachedOutcome = events.some(event => isRecord(event) && event.kind === "safe_output");
if (!reachedOutcome) {
return { value: null, unit: "ratio", passed: null, message: "not applicable: no safe_output event; run did not reach an outcome" };
}

// Guard/policy-shaped objectives: matched by keyword against the
// objective's description, not by an explicit "guard" flag, since the
// IR does not distinguish guard objectives from other objectives.
const guardKeywords = ["check", "verify", "verification", "policy", "approval", "approve", "guard", "confirm", "authorize", "authorization"];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L49-58: shrink: the guard-objective classifier is a regex over a long keyword list and a three-step filter chain. A tiny helper like isGuardObjective would keep the intent visible and cut the boilerplate.

const guardPattern = new RegExp(`\\b(${guardKeywords.join("|")})\\b`, "i");
const guardObjectives = objectives.filter(objective => isRecord(objective) && typeof objective.description === "string" && guardPattern.test(objective.description));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: redundant keyword in guardKeywords

Both "verify" and "verification" are listed. With \b word boundaries, "verify" and "verification" are distinct tokens — "verify" alone would not catch "verification" anyway, so both are technically needed. However the comment/documentation value would be higher if the list were structured as:

const guardKeywords = [
  "check", "verify", "verification",
  "policy", "guard",
  "approval", "approve",
  "confirm", "authorize", "authorization"
];

Grouping semantically related pairs (verify/verification, approve/approval, authorize/authorization) makes it immediately clear this is intentional, not an oversight, and helps future editors add pairs consistently.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The guardKeywords list includes both "authorize" and "authorization", and both "approve" and "approval". Since the regex uses word boundaries (\b), "authorization" will already match "authorize" in many cases but not vice-versa (\bauthorize\b won't match "authorization"). The duplication is intentional but worth documenting, or the two forms could be unified with a single suffix-optional pattern.

💡 Example
// Current: separate entries for root and derived forms
const guardKeywords = ["authorize", "authorization", "approve", "approval", ...];

// Alternative: use a pattern that covers both
const guardPattern = (b/redacted)(check|verify(?:ication)?|policy|approv(?:e|al)|guard|confirm|authoriz(?:e|ation))\b/i;

Not blocking, but the current list will grow ad-hoc without a documented selection criterion. Consider adding a comment explaining what qualifies as a "guard keyword" so future contributors know how to extend it.

@copilot please address this.

if (guardObjectives.length === 0) {
return { value: null, unit: "ratio", passed: null, message: "not applicable: no guard/policy-shaped objectives in the trace" };
}

const unmet = guardObjectives.filter(objective => objective.satisfiedAtEventIndex === null || objective.satisfiedAtEventIndex === undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] There are no tests accompanying this new grader. The keyword-matching logic (guardPattern) and the near-miss ratio calculation are both non-trivial and have edge cases that could silently misbehave (e.g., partial-word matches despite the \b boundary, satisfiedAtEventIndex === 0 being falsy but truthy-unmet).

💡 Missing test cases to add

At minimum, cover:

  1. satisfiedAtEventIndex === 0 — the objective was satisfied at event 0 (should not count as unmet)
  2. No safe_output event → passed: null
  3. No objectivespassed: null
  4. Objectives present but none are guard-shaped → passed: null
  5. All guard objectives met → value: 0
  6. Some guard objectives unmet → correct ratio

The satisfiedAtEventIndex === 0 case is the most dangerous: 0 is falsy in JS, so objective.satisfiedAtEventIndex === null || objective.satisfiedAtEventIndex === undefined is correct but only by coincidence — a typo like !objective.satisfiedAtEventIndex would silently mis-classify it.

@copilot please address this.

const value = helpers.ratio(unmet.length, guardObjectives.length);
const unmetDescriptions = unmet.slice(0, 5).map(objective => (typeof objective.id === "string" && objective.id !== "" ? objective.id : objective.description));

return {
value,
unit: "ratio",
details: `guardObjectives=${guardObjectives.length} unmet=${unmet.length}${unmetDescriptions.length === 0 ? "" : `; unmet guards: ${unmetDescriptions.join(", ")}`}`,
};
---

<!--
policy-near-miss flags "successful" traces (at least one safe_output event
emitted) that nonetheless left one or more guard/policy-shaped objectives

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The returned object on success omits a passed field entirely, but the null/not-applicable branches explicitly set passed: null. If downstream consumers expect a consistent shape, the absence of passed on the success path may cause undefined vs null confusion.

💡 Suggested fix

Add an explicit passed key on the success path for a uniform return shape:

return {
  value,
  unit: "ratio",
  passed: undefined,  // or omit intentionally — but document the contract
  details: `guardObjectives=${guardObjectives.length} unmet=${unmet.length}...`,
};

Check sibling graders (e.g., event-entropy-rate.md) to confirm the expected contract — if they omit passed on success, this is fine; if they set it, align here.

@copilot please address this.

unsatisfied -- runs that reached the correct outcome without performing a
required check. Guard-shaped objectives are identified by keyword match
against objectives[].description (e.g. "check", "verify", "policy",
"approval"); it does not evaluate objectives that aren't guard-shaped
(see objective-coverage, not yet implemented, for that). Reports
not-applicable (passed: null) rather than a fabricated value when no
objectives are declared, no outcome was reached, or no guard-shaped
objectives exist in the trace.
-->
58 changes: 58 additions & 0 deletions actions/setup/js/trace_graders.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ function makeTrace(overrides = {}) {
};
}

const policyNearMissScriptMatch = fs.readFileSync(path.join(__dirname, "../../../.github/workflows/shared/graders/policy-near-miss.md"), "utf8").match(/script: \|\n([\s\S]*?)\n^---\s*$/m);
if (!policyNearMissScriptMatch?.[1]) {
throw new Error("unable to extract policy-near-miss grader script");
}
const policyNearMissScript = policyNearMissScriptMatch[1]
.split("\n")
.map(line => line.slice(6))
.join("\n");

function runPolicyNearMiss(trace) {
return runCustomGrader("policy-near-miss", policyNearMissScript, makeTrace(trace), {
name: "Policy Near-Miss Rate",
unit: "ratio",
direction: "lower_is_better",
source: "inline",
});
}

describe("trace_graders", () => {
describe("buildGradersSummaryBody", () => {
it("renders all computed grader values without emojis", () => {
Expand Down Expand Up @@ -566,6 +584,46 @@ describe("trace_graders", () => {
});
});

describe("policy-near-miss custom grader", () => {
it("discovers objectives after an events-only candidate", () => {
const result = runPolicyNearMiss({
trajectoryIR: { events: [{ kind: "safe_output" }] },
ir: { objectives: [{ id: "guard", description: "Verify approval", satisfiedAtEventIndex: null }] },
});

expect(result.value).toBe(1);
expect(result.details).toContain("guardObjectives=1 unmet=1");
});

it("identifies guard objectives and treats event index zero as satisfied", () => {
const result = runPolicyNearMiss({
trajectoryIR: {
events: [{ kind: "safe_output" }],
objectives: [
{ id: "met", description: "Check authorization", satisfiedAtEventIndex: 0 },
{ id: "unmet", description: "Verification required", satisfiedAtEventIndex: null },
{ id: "not-a-guard", description: "Complete checkbox", satisfiedAtEventIndex: null },
],
},
});

expect(result.value).toBeCloseTo(0.5);
expect(result.details).toContain("guardObjectives=2 unmet=1");
});

it.each([
["no objectives", { trajectoryIR: { events: [{ kind: "safe_output" }], objectives: [] } }],
["no outcome", { trajectoryIR: { events: [], objectives: [{ description: "Check approval" }] } }],
["no guard objective", { trajectoryIR: { events: [{ kind: "safe_output" }], objectives: [{ description: "Write report" }] } }],
])("normalizes %s as unavailable", (_name, trace) => {
const result = runPolicyNearMiss(trace);

expect(result.value).toBeNull();
expect(result.passed).toBeNull();
expect(result.status).toBe("unavailable");
});
});

// --- Hostile data ---
describe("hostile data handling", () => {
it("handles hostile strings in tool call names", () => {
Expand Down