vpp-ai-platform/packages/services/test/m4.test.ts
Thomas Bayes 23fdf49ff2
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions
M4: resource agent, envelopes live, review loop, insight cards
- packages/domain: AwardNotice, DISPATCH_PLAN proposal payload, ExecutionReport,
  MeteringRecord, potential-assessment and dispatch-optimization contracts,
  ReviewFinding (+ typed writebacks), SemanticMemoryEntry,
  EnvelopeChangeRequest, InsightCard — exported with fixtures on both sides.
- skills-py: potential-assessment (certified × rolling fulfilment, evidence
  days) and dispatch-optimization (per-interval LP on HiGHS, shortfall
  reported) skills + routes + tests.
- packages/services: dispatch rules in the policy pack (over-allocation,
  award anchor, lineage integrity for allocations); PowerBalanceSimulator;
  SimulationGateway (permit-only, idempotent, seeded execute → ExecutionReports);
  envelope deviation-streak suspension + apply(); ReviewService (attribution,
  reliability EWMA writeback, semantic memory, envelope recommendations as
  change requests); dispatch assembler; skill client methods.
- packages/runtime: resource agent; award-decomposition, review and
  envelope-review workflows; lifecycle selects simulator/gateway by proposal
  type; trigger hooks for awards, execution reports, metering; decide()
  resumes either lifecycle or envelope-review runs; insight cards API.
- Tests: docs/07 D-1 16:00 and D+1 end to end; reliability score 0.9 → 0.880
  and the next assessment de-rates capacity; envelope suspension on a seeded
  3-day streak; WIDEN request applied only by a human. 184 TS + 80 Python.
- docs/open-questions: B10 (reliability/potential parameters). README and
  CLAUDE.md status → M4 done, M5 next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 19:31:12 -04:00

177 lines
12 KiB
TypeScript

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
})
})