diff --git a/gitnexus/src/core/evolver/index.ts b/gitnexus/src/core/evolver/index.ts index 7d76650f5..4343f58d7 100644 --- a/gitnexus/src/core/evolver/index.ts +++ b/gitnexus/src/core/evolver/index.ts @@ -3,3 +3,4 @@ export * from './metric-evaluator.js'; export * from './resource-manager.js'; export * from './variant-generator.js'; export * from './sandbox-runner.js'; +export * from './safety-gate.js'; diff --git a/gitnexus/src/core/evolver/safety-gate.ts b/gitnexus/src/core/evolver/safety-gate.ts new file mode 100644 index 000000000..76b10a4fe --- /dev/null +++ b/gitnexus/src/core/evolver/safety-gate.ts @@ -0,0 +1,80 @@ +import type { EvaluationReport, GateDecision, VariantSpec } from './types.js'; + +const decidedAt = '2026-05-25T00:00:00.000Z'; + +export function decidePromotion(variant: VariantSpec, report: EvaluationReport): GateDecision { + const gateId = `gate-${variant.id}-${report.id}`; + + if (report.safetyFindings.length > 0) { + return reject(gateId, variant, report, ['evaluation report contains safety findings']); + } + + if (variant.provenance.length === 0) { + return reject(gateId, variant, report, ['variant provenance is required']); + } + + if (variant.rollbackPlan.operations.length === 0 && variant.mutationType === 'parameter') { + return reject(gateId, variant, report, ['rollback operations are required for automatic promotion']); + } + + if (report.verdict === 'reject') { + return reject(gateId, variant, report, ['evaluation report rejected variant']); + } + + if (variant.mutationType === 'algorithm') { + return needsReview(gateId, variant, report, ['algorithm variants require review before promotion']); + } + + if (variant.mutationType === 'structure') { + return needsReview(gateId, variant, report, ['structure variants require review before promotion']); + } + + if (variant.riskLevel !== 'low') { + return needsReview(gateId, variant, report, ['non-low-risk variants require review before promotion']); + } + + if (report.verdict !== 'promote') { + return needsReview(gateId, variant, report, ['evaluation report does not recommend promotion']); + } + + return { + id: gateId, + variantId: variant.id, + reportId: report.id, + decision: 'allow', + reasons: ['parameter variant passed promotion gate'], + decidedAt, + }; +} + +function reject( + id: string, + variant: VariantSpec, + report: EvaluationReport, + reasons: string[], +): GateDecision { + return { + id, + variantId: variant.id, + reportId: report.id, + decision: 'reject', + reasons, + decidedAt, + }; +} + +function needsReview( + id: string, + variant: VariantSpec, + report: EvaluationReport, + reasons: string[], +): GateDecision { + return { + id, + variantId: variant.id, + reportId: report.id, + decision: 'needs-review', + reasons, + decidedAt, + }; +} diff --git a/gitnexus/test/unit/evolver/safety-gate.test.ts b/gitnexus/test/unit/evolver/safety-gate.test.ts new file mode 100644 index 000000000..5ab5e2756 --- /dev/null +++ b/gitnexus/test/unit/evolver/safety-gate.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { decidePromotion } from '../../../src/core/evolver/index.js'; +import type { EvaluationReport, VariantSpec } from '../../../src/core/evolver/index.js'; + +const capturedAt = '2026-05-25T00:00:00.000Z'; + +function parameterVariant(overrides: Partial = {}): VariantSpec { + return { + id: 'variant-parameter-1', + planId: 'plan-1', + mutationType: 'parameter', + description: 'Adjust topK', + patch: { + operations: [ + { + op: 'setParameter', + path: 'retrieval.topK', + previousValue: 5, + nextValue: 8, + }, + ], + }, + expectedGain: [ + { + metricId: 'task_success_rate', + expectedDelta: 0.05, + direction: 'increase', + }, + ], + riskLevel: 'low', + rollbackPlan: { + strategy: 'restore-previous-parameters', + operations: [ + { + op: 'setParameter', + path: 'retrieval.topK', + previousValue: 8, + nextValue: 5, + }, + ], + }, + provenance: [ + { + source: 'synthetic-benchmark', + reference: 'run-1', + capturedAt, + }, + ], + ...overrides, + }; +} + +function promoteReport(overrides: Partial = {}): EvaluationReport { + return { + id: 'report-1', + variantId: 'variant-parameter-1', + baselineId: 'baseline-1', + metricDeltas: [ + { + metricId: 'task_success_rate', + baselineValue: 0.72, + candidateValue: 0.81, + delta: 0.09, + direction: 'increase', + passed: true, + }, + ], + regressions: [], + resourceCost: { + durationMs: 120, + computeUnits: 1, + apiCostUsd: 0, + storageBytes: 256, + }, + safetyFindings: [], + verdict: 'promote', + ...overrides, + }; +} + +describe('decidePromotion', () => { + it('allows a low-risk parameter variant with valid evaluation, provenance, rollback, and safety checks', () => { + const decision = decidePromotion(parameterVariant(), promoteReport()); + + expect(decision).toEqual({ + id: 'gate-variant-parameter-1-report-1', + variantId: 'variant-parameter-1', + reportId: 'report-1', + decision: 'allow', + reasons: ['parameter variant passed promotion gate'], + decidedAt: capturedAt, + }); + }); + + it('marks an algorithm variant as needs-review', () => { + const variant = parameterVariant({ + id: 'variant-algorithm-1', + mutationType: 'algorithm', + riskLevel: 'medium', + patch: { + operations: [ + { + op: 'selectAlgorithm', + policyId: 'ranking-policy', + previousAlgorithm: 'baseline-ranker', + nextAlgorithm: 'candidate-ranker', + }, + ], + }, + rollbackPlan: { + strategy: 'disable-candidate', + operations: [ + { + op: 'selectAlgorithm', + policyId: 'ranking-policy', + previousAlgorithm: 'candidate-ranker', + nextAlgorithm: 'baseline-ranker', + }, + ], + }, + }); + + const decision = decidePromotion( + variant, + promoteReport({ id: 'report-algorithm', variantId: variant.id, verdict: 'needs-review' }), + ); + + expect(decision.decision).toBe('needs-review'); + expect(decision.reasons).toEqual(['algorithm variants require review before promotion']); + }); + + it('marks a structure variant as needs-review', () => { + const variant = parameterVariant({ + id: 'variant-structure-1', + mutationType: 'structure', + riskLevel: 'high', + patch: { + operations: [ + { + op: 'proposeStructureChange', + planPath: 'docs/aegis/plans/structure-change.md', + summary: 'Split evaluator into a plugin pipeline', + }, + ], + }, + rollbackPlan: { + strategy: 'manual-review-required', + operations: [], + }, + }); + + const decision = decidePromotion( + variant, + promoteReport({ id: 'report-structure', variantId: variant.id, verdict: 'needs-review' }), + ); + + expect(decision.decision).toBe('needs-review'); + expect(decision.reasons).toEqual(['structure variants require review before promotion']); + }); + + it('rejects a variant without provenance', () => { + const decision = decidePromotion(parameterVariant({ provenance: [] }), promoteReport()); + + expect(decision.decision).toBe('reject'); + expect(decision.reasons).toEqual(['variant provenance is required']); + }); + + it('rejects a variant without rollback operations', () => { + const decision = decidePromotion( + parameterVariant({ + rollbackPlan: { + strategy: 'restore-previous-parameters', + operations: [], + }, + }), + promoteReport(), + ); + + expect(decision.decision).toBe('reject'); + expect(decision.reasons).toEqual(['rollback operations are required for automatic promotion']); + }); + + it('rejects a variant when the evaluation report contains safety findings', () => { + const decision = decidePromotion( + parameterVariant(), + promoteReport({ + safetyFindings: [ + { + id: 'safety-1', + severity: 'high', + message: 'unsafe action attempted', + }, + ], + verdict: 'reject', + }), + ); + + expect(decision.decision).toBe('reject'); + expect(decision.reasons).toEqual(['evaluation report contains safety findings']); + }); +});