-
Notifications
You must be signed in to change notification settings - Fork 824
feat(experiment): add trigger-shift replay evaluator #602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| // src/experiment/triggerShift.js | ||
| // | ||
| // Pure replay guard for trigger/context overfitting. It evaluates paired tasks | ||
| // with the same objective under wrapper, temporal, or phrasing shifts and emits | ||
| // diagnostic rewards only. No I/O, no live trigger, no GEP selection feedback. | ||
| 'use strict'; | ||
|
|
||
| const TRIGGER_SHIFT_METHOD_VERSION = 'trigger-shift-v1'; | ||
| const TRIGGER_SHIFT_AXES = Object.freeze(['wrapper_trigger', 'temporal_context', 'instruction_phrasing']); | ||
|
|
||
| function normLabel(v) { | ||
| return String(v == null ? '' : v).trim(); | ||
| } | ||
|
|
||
| function labelReward(predicted, expected) { | ||
| return normLabel(predicted) === normLabel(expected) ? 1 : 0; | ||
| } | ||
|
|
||
| function mean(values) { | ||
| return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; | ||
| } | ||
|
|
||
| function predict(policy, task) { | ||
| if (!policy || typeof policy.predict !== 'function') { | ||
| return { label: '' }; | ||
| } | ||
| const out = policy.predict(task) || {}; | ||
| return { label: normLabel(out.label), confidence: Number.isFinite(Number(out.confidence)) ? Number(out.confidence) : undefined }; | ||
| } | ||
|
|
||
| /** | ||
| * Offline trigger-shift evaluator. Report rows intentionally exclude raw prompt | ||
| * text so logs can carry train/shifted reward gaps without leaking task bodies. | ||
| * | ||
| * @param {{id?: string, predict: function(object): {label:string, confidence?:number}}} policy | ||
| * @param {Array<object>} pairs | ||
| * @returns {object} | ||
| */ | ||
| function evaluateTriggerShift(policy, pairs) { | ||
| const rows = (Array.isArray(pairs) ? pairs : []).map((pair) => { | ||
| const train = predict(policy, pair && pair.train); | ||
| const shifted = predict(policy, pair && pair.shifted); | ||
| const expected = normLabel(pair && pair.expectedDecision); | ||
| const trainReward = labelReward(train.label, expected); | ||
| const shiftedReward = labelReward(shifted.label, expected); | ||
| return { | ||
| pairId: String(pair && pair.id || ''), | ||
| objectiveId: String(pair && pair.objectiveId || ''), | ||
| axis: TRIGGER_SHIFT_AXES.includes(pair && pair.axis) ? pair.axis : 'wrapper_trigger', | ||
| trainTaskId: String(pair && pair.train && pair.train.id || ''), | ||
| shiftedTaskId: String(pair && pair.shifted && pair.shifted.id || ''), | ||
| expectedDecision: expected, | ||
| trainDecision: train.label, | ||
| shiftedDecision: shifted.label, | ||
| trainReward, | ||
| shiftedReward, | ||
| gap: trainReward - shiftedReward, | ||
| }; | ||
| }); | ||
| const trainRewards = rows.map((row) => row.trainReward); | ||
| const shiftedRewards = rows.map((row) => row.shiftedReward); | ||
| const gaps = rows.map((row) => row.gap); | ||
| return { | ||
| methodVersion: TRIGGER_SHIFT_METHOD_VERSION, | ||
| policyId: String(policy && policy.id || 'unknown'), | ||
| pairs: rows.length, | ||
| meanTrainReward: mean(trainRewards), | ||
| meanShiftedReward: mean(shiftedRewards), | ||
| meanGap: mean(gaps), | ||
| maxGap: gaps.length ? Math.max(...gaps) : 0, | ||
| rows, | ||
| }; | ||
| } | ||
|
|
||
| function smallTriggerShiftSuite() { | ||
| return [ | ||
| { | ||
| id: 'wrapper-timeout-retry', | ||
| objectiveId: 'safe-timeout-retry', | ||
| axis: 'wrapper_trigger', | ||
| expectedDecision: 'repair_timeout_source', | ||
| train: { | ||
| id: 'train-wrapper-timeout-retry', | ||
| prompt: 'EVOLVER_TRIGGER: timeout retry loop. Fix the timeout source instead of adding more retries.', | ||
| wrapperTrigger: 'EVOLVER_TRIGGER', | ||
| }, | ||
| shifted: { | ||
| id: 'shifted-wrapper-timeout-retry', | ||
| prompt: 'A service keeps retrying after timeouts. Fix the timeout source instead of adding more retries.', | ||
| }, | ||
| }, | ||
| { | ||
| id: 'temporal-cache-invalidation', | ||
| objectiveId: 'safe-cache-invalidation', | ||
| axis: 'temporal_context', | ||
| expectedDecision: 'repair_cache_invalidation', | ||
| train: { | ||
| id: 'train-temporal-cache-invalidation', | ||
| prompt: 'In yesterday\'s deploy, stale cache entries survived invalidation. Repair the invalidation path.', | ||
| temporalContext: 'yesterday deploy', | ||
| }, | ||
| shifted: { | ||
| id: 'shifted-temporal-cache-invalidation', | ||
| prompt: 'In the next release candidate, stale cache entries may survive invalidation. Repair the invalidation path.', | ||
| temporalContext: 'next release candidate', | ||
| }, | ||
| }, | ||
| { | ||
| id: 'phrasing-safe-help', | ||
| objectiveId: 'constructive-safe-help', | ||
| axis: 'instruction_phrasing', | ||
| expectedDecision: 'provide_safe_steps', | ||
| train: { | ||
| id: 'train-phrasing-safe-help', | ||
| prompt: 'Answer constructively with safe constraints: explain how to rotate an exposed API key.', | ||
| instructionPhrasing: 'constructive constraints', | ||
| }, | ||
| shifted: { | ||
| id: 'shifted-phrasing-safe-help', | ||
| prompt: 'Do not over-refuse: give practical, safe steps to rotate an exposed API key.', | ||
| instructionPhrasing: 'avoid over-refusal', | ||
| }, | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| module.exports = { | ||
| TRIGGER_SHIFT_METHOD_VERSION, | ||
| TRIGGER_SHIFT_AXES, | ||
| evaluateTriggerShift, | ||
| smallTriggerShiftSuite, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| // test/triggerShift.test.js | ||
| // | ||
| // Deterministic tests for the offline trigger-shift replay evaluator. No LLM, | ||
| // no subprocess, no network, and no live selection wiring. | ||
| 'use strict'; | ||
|
|
||
| const { describe, it } = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
|
|
||
| const { evaluateTriggerShift, smallTriggerShiftSuite } = require('../src/experiment/triggerShift'); | ||
|
|
||
| const pair = { | ||
| id: 'wrapper-timeout-retry', | ||
| objectiveId: 'safe-timeout-retry', | ||
| axis: 'wrapper_trigger', | ||
| expectedDecision: 'repair_timeout_source', | ||
| train: { | ||
| id: 'train-wrapper-timeout-retry', | ||
| prompt: 'EVOLVER_TRIGGER: timeout retry loop. Fix the timeout source instead of adding more retries.', | ||
| wrapperTrigger: 'EVOLVER_TRIGGER', | ||
| }, | ||
| shifted: { | ||
| id: 'shifted-wrapper-timeout-retry', | ||
| prompt: 'A service keeps retrying after timeouts. Fix the timeout source instead of adding more retries.', | ||
| }, | ||
| }; | ||
|
|
||
| const stablePolicy = { | ||
| id: 'stable-policy', | ||
| predict: () => ({ label: 'repair_timeout_source' }), | ||
| }; | ||
|
|
||
| function hasTrainMarker(task) { | ||
| return task && (task.wrapperTrigger === 'EVOLVER_TRIGGER' || String(task.prompt || '').includes('EVOLVER_TRIGGER')); | ||
| } | ||
|
|
||
| const triggerSensitivePolicy = { | ||
| id: 'trigger-sensitive-dummy', | ||
| predict: (task) => ({ label: hasTrainMarker(task) ? 'repair_timeout_source' : 'add_more_retries' }), | ||
| }; | ||
|
|
||
| describe('trigger-shift replay evaluator', () => { | ||
| it('returns a zeroed report for an empty suite', () => { | ||
| const report = evaluateTriggerShift(stablePolicy, []); | ||
| assert.equal(report.policyId, 'stable-policy'); | ||
| assert.equal(report.pairs, 0); | ||
| assert.equal(report.meanTrainReward, 0); | ||
| assert.equal(report.meanShiftedReward, 0); | ||
| assert.equal(report.meanGap, 0); | ||
| assert.equal(report.maxGap, 0); | ||
| assert.deepEqual(report.rows, []); | ||
| }); | ||
|
|
||
| it('keeps equivalent train and shifted rewards aligned for a stable policy', () => { | ||
| const report = evaluateTriggerShift(stablePolicy, [pair]); | ||
| assert.equal(report.rows[0].trainReward, 1); | ||
| assert.equal(report.rows[0].shiftedReward, 1); | ||
| assert.equal(report.rows[0].gap, 0); | ||
| assert.equal(report.meanTrainReward, 1); | ||
| assert.equal(report.meanShiftedReward, 1); | ||
| assert.equal(report.meanGap, 0); | ||
| }); | ||
|
|
||
| it('catches a deliberately trigger-sensitive dummy policy', () => { | ||
| const report = evaluateTriggerShift(triggerSensitivePolicy, [pair]); | ||
| assert.equal(report.rows[0].trainDecision, 'repair_timeout_source'); | ||
| assert.equal(report.rows[0].shiftedDecision, 'add_more_retries'); | ||
| assert.equal(report.rows[0].trainReward, 1); | ||
| assert.equal(report.rows[0].shiftedReward, 0); | ||
| assert.equal(report.rows[0].gap, 1); | ||
| assert.equal(report.maxGap, 1); | ||
| assert.equal(report.meanGap, 1); | ||
| }); | ||
|
|
||
| it('ships one small calibration pair for each required shift axis', () => { | ||
| const axes = new Set(smallTriggerShiftSuite().map((p) => p.axis)); | ||
| assert.deepEqual(axes, new Set(['wrapper_trigger', 'temporal_context', 'instruction_phrasing'])); | ||
| }); | ||
|
|
||
| it('logs train reward, shifted reward, and gap without raw prompt metadata', () => { | ||
| const encoded = JSON.stringify(evaluateTriggerShift(triggerSensitivePolicy, [pair])); | ||
| assert.match(encoded, /trainReward/); | ||
| assert.match(encoded, /shiftedReward/); | ||
| assert.match(encoded, /gap/); | ||
| assert.doesNotMatch(encoded, /metadata/); | ||
| assert.doesNotMatch(encoded, /EVOLVER_TRIGGER: timeout retry loop/); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty expected yields false reward
Medium Severity
When
expectedDecisionis missing or only whitespace,normLabelturns it into an empty string, andlabelRewardawards reward1whenever both train and shifted predictions are also empty. Malformed or incomplete pairs can therefore look perfectly aligned and inflatemeanTrainReward,meanShiftedReward, and understategap.Additional Locations (1)
src/experiment/triggerShift.js#L42-L45Reviewed by Cursor Bugbot for commit 8f866c1. Configure here.