import { describe, expect, it } from 'vitest' import type { ExecutionReport, Proposal, ReviewFinding } from '@vpp/domain' import { isStaleDenial } from '../src/authority.js' import { EnvelopeService } from '../src/envelope.js' import { GatewayRejected, SimulationGateway } from '../src/gateway.js' import { LineageRecorder, assembleDispatchProposal, withLineage } from '../src/lineage.js' import { createResourceRegistry } from '../src/relational.js' import { ReviewService } from '../src/review.js' import { PowerBalanceSimulator } from '../src/simulation.js' import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, world } from './helpers.js' /** Dispatch world: an AWARD in the ledger, a recorded dispatch-optimization output, a DISPATCH_PLAN proposal from refs. */ function dispatchWorld(opts: { total?: string; alloc?: string[] } = {}) { const w = world() w.ledger.append({ id: 'award', timescale: 'DAY_AHEAD', period: DATE, kind: 'AWARD', energy_mwh: '1152.000', curve: curve('12.0'), source_ref: 'award-1', expected_version: 1 }) const lineage = new LineageRecorder() let n = 0 const total = opts.total ?? '48.000' const allocs = opts.alloc ?? ['30.000', '18.000'] const dispatch = withLineage(lineage, w.snapshots, { tool: 'dispatch-optimization', version: '1.0.0', idGen: () => `tcd-${++n}` }, (_i: unknown) => ({ market_date: DATE, total_target_mw: curve(total), allocations: allocs.map((v, i) => ({ unit_id: `u${i + 1}`, target_mw: curve(v) })), shortfall_mwh: '0.000', })) const proposal: Proposal = lineage.run(() => { const { call } = dispatch({}) return assembleDispatchProposal( { market_date: DATE, total_target_ref: { tool_call_id: call.tool_call_id, path: 'total_target_mw' }, allocations_ref: { tool_call_id: call.tool_call_id, path: 'allocations' }, rationale: 'test' }, { id: 'prop-d1', award_ref: 'award-1', originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: 'case-d' }, lineage: lineage.current(), ledger_version: w.ledger.read().version, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW }, ) }) return { ...w, proposal } } describe('dispatch plans in the policy pack', () => { it('a balanced, anchored, traceable plan passes', () => { const d = dispatchWorld() const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) expect(r.ok, JSON.stringify(r.violations)).toBe(true) expect(r.rules_evaluated).toContain('dispatch-balance') }) it('dispatch-balance rejects over-allocation (shortfall is left to the simulation alert)', () => { const short = dispatchWorld({ alloc: ['30.000', '17.000'] }) expect(short.engine.check(short.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, short.ruleCtx()).ok).toBe(true) const d = dispatchWorld({ alloc: ['30.000', '19.000'] }) const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) expect(r.violations.map((v) => v.rule_id)).toContain('dispatch-balance') }) it('dispatch-award-anchor rejects a plan without an AWARD in the ledger', () => { const w = world() const d = dispatchWorld() const r = w.engine.check({ ...d.proposal, lineage: { ...d.proposal.lineage, ledger_version: 1 } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, { ...w.ruleCtx(), snapshots: d.snapshots }) expect(r.violations.some((v) => v.rule_id === 'dispatch-award-anchor')).toBe(true) }) it('originator-permission: the trading agent may not originate a dispatch plan', () => { const d = dispatchWorld() const r = d.engine.check({ ...d.proposal, originator: { ...d.proposal.originator, agent: 'trading-agent' } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission') }) }) describe('power-balance simulation', () => { it('passes a covered plan and alerts on shortfall or overload', () => { const d = dispatchWorld() const sim = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('30.000'), u2: Array(96).fill('30.000') })) const ok = sim.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }) expect(ok.alert).toBe(false) const short = dispatchWorld({ alloc: ['30.000', '10.000'] }) expect(sim.simulate(short.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/short/) const over = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('20.000') })) expect(over.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/beyond available/) }) }) describe('envelope: dispatch bounds and deviation-streak suspension', () => { it('max_total_mw / max_unit_mw bound dispatch plans', () => { const d = dispatchWorld() const svc = new EnvelopeService() svc.register(envelope({ max_total_mw: '50.0', max_unit_mw: '35.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } })) expect(svc.match(d.proposal, { now: NOW, priceBaseline: null }).within).toBe(true) const tight = new EnvelopeService() tight.register(envelope({ max_unit_mw: '25.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } })) expect(tight.match(d.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_unit_mw/) }) it('N consecutive over-threshold executions suspend the envelope; a compliant day resets the streak', () => { const svc = new EnvelopeService() svc.register(envelope({ max_energy_mwh: '1500.0' })) // N=3, threshold 10% expect(svc.recordExecution('env-bid-001', '12.0', NOW)).toEqual({ streak: 1, suspended: false }) expect(svc.recordExecution('env-bid-001', '5.0', NOW)).toEqual({ streak: 0, suspended: false }) svc.recordExecution('env-bid-001', '15.0', NOW) svc.recordExecution('env-bid-001', '15.0', NOW) expect(svc.recordExecution('env-bid-001', '15.0', NOW)).toEqual({ streak: 3, suspended: true }) expect(svc.get('env-bid-001')?.status).toBe('SUSPENDED') const w = world() expect(svc.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false) }) it('apply() is the only way bounds change, and it records who approved', () => { const svc = new EnvelopeService() svc.register(envelope({ price_deviation_pct: '5.0' }, { status: 'SUSPENDED' })) const e = svc.apply({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.0' } }, ['user-ops-lead', 'user-risk-officer']) expect(e.status).toBe('ACTIVE') expect(e.bounds['price_deviation_pct']).toBe('6.0') expect(e.approval.approved_by).toEqual(['user-ops-lead', 'user-risk-officer']) }) }) describe('simulation gateway (dispatch release channel)', () => { it('accepts only permitted dispatch plans, idempotently, and can execute them with seeded fulfilment', () => { const d = dispatchWorld() const gw = new SimulationGateway(d.authority) const permit = d.authority.authorize(d.proposal, { approvals: [approvalFor(d.proposal, 2, { scope: { effect_type: 'DISPATCH_PLAN', limits: {} } })], envelopeMatch: null }, { now: NOW, ledger: d.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000 }) if (isStaleDenial(permit)) throw new Error(permit.reasons.join()) const r1 = gw.dispatch(d.proposal, permit, NOW) expect(gw.dispatch(d.proposal, permit, NOW)).toEqual(r1) expect(r1.channel).toBe('SIMULATION_GATEWAY') const w = world() expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(GatewayRejected) const reports = gw.execute(d.proposal.digest, { u1: 0.8 }, '2026-03-16T01:00:00Z') expect(reports.map((r) => r.fulfillment_ratio)).toEqual(['0.800', '1.000']) expect(reports[0]!.deviation_mwh).toBe((30 * 0.2 * 96 * 0.25).toFixed(3)) }) }) describe('review service: attribution and writebacks', () => { const report = (unit: string, planned: string, actual: string): ExecutionReport => ({ id: `exec-${unit}`, market_date: DATE, unit_id: unit, dispatch_proposal_digest: 'c'.repeat(64), planned_mw: curve(planned), actual_mw: curve(actual), deviation_mwh: ((Number(planned) - Number(actual)) * 96 * 0.25).toFixed(3), fulfillment_ratio: (Number(actual) / Number(planned)).toFixed(3), reported_at: '2026-03-16T01:00:00Z', }) function setup(cfg = { reliabilityAlpha: '0.2', widenAfterCompliantDays: 2, widenStepPct: '20' }) { const resources = createResourceRegistry(() => NOW) resources.put({ resource_id: 'res-a', name: 'a', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '30.0', confidence: '0.9', reliability_score: '0.900', constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW }) const envelopes = new EnvelopeService() envelopes.register(envelope({ price_deviation_pct: '5.0' })) const svc = new ReviewService(resources, envelopes, undefined, cfg) const d = dispatchWorld() const input = (reports: ExecutionReport[], deviationPct: string) => ({ market_date: DATE, ledger: d.ledger.read(), bidProposal: d.proposal.type === 'BID' ? d.proposal : world().proposal, dispatchProposal: d.proposal, executionReports: reports, metering: null, actualLoad: null, forecasts: { load: null, price: null }, unitResources: { u1: ['res-a'], u2: [] }, envelopeUsage: [{ envelope_id: 'env-bid-001', deviation_pct: deviationPct }], now: '2026-03-16T03:00:00Z', idGen: (p: string) => `${p}-1`, }) return { resources, envelopes, svc, input } } it('attributes deviation per timescale and writes back a measurably updated reliability score', () => { const { resources, svc, input } = setup() const [finding] = svc.review(input([report('u1', '30.000', '24.000')], '20.0')) as [ReviewFinding] expect(finding.kind).toBe('RESPONSE') expect(finding.attribution.trading_mwh).toBe('48.000') // bid 1200 vs award 1152 expect(finding.attribution.response_mwh).toBe('144.000') const wb = finding.writebacks.find((w) => w.target === 'RESOURCE_PROFILE') expect(wb).toMatchObject({ resource_id: 'res-a', previous_reliability_score: '0.900', new_reliability_score: '0.880', fulfillment_ratio: '0.800' }) expect(finding.writebacks.find((w) => w.target === 'SEMANTIC_MEMORY')?.target).toBe('SEMANTIC_MEMORY') const applied = svc.apply(finding, '2026-03-16T03:00:00Z') expect(resources.get('res-a')?.value.reliability_score).toBe('0.880') expect(applied.memory).toHaveLength(1) expect(svc.memory.list()).toHaveLength(1) expect(applied.envelopeRequests).toHaveLength(0) // deviation above threshold → no widening }) it('recommends widening after the configured run of compliant days, as a request (not applied)', () => { const { svc, input, envelopes } = setup() svc.review(input([report('u1', '30.000', '29.000')], '3.0')) const [f2] = svc.review(input([report('u1', '30.000', '29.000')], '3.0')) as [ReviewFinding] const rec = f2.writebacks.find((w) => w.target === 'ENVELOPE_RECOMMENDATION') expect(rec).toMatchObject({ action: 'WIDEN', bound: 'price_deviation_pct', current_value: '5.0', recommended_value: '6.00' }) const applied = svc.apply(f2, '2026-03-16T03:00:00Z') expect(applied.envelopeRequests[0]).toMatchObject({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.00' } }) expect(envelopes.get('env-bid-001')?.bounds['price_deviation_pct']).toBe('5.0') // untouched until a human approves }) })