M2: skill contracts, Python skill service, L2 eval harness with baseline
- packages/domain: ForecastRequest, BidOptimizationRequest/Result,
ReportRequest, SkillReport (+ golden and invalid fixtures, exported to
contracts/ and regenerated as pydantic models).
- skills-py/vpp_skills: FastAPI service with versioned registry; load/PV/
price forecasts (same-day-type EWM point forecast, conformal residual
quantiles — coverage test as acceptance gate); bid-optimization MILP on
HiGHS (binary block participation, hard ledger energy bounds, exact
Decimal fit of the rounded curve inside the bounds, revenue distribution
over quantile paths); report generator whose every figure is a
{tool_call_id, path} reference, with a verifier. 48 tests incl. hypothesis
property test that bids respect ledger constraints.
- packages/services: LedgerService.dayAheadBounds (the P7 cascade band
handed to the optimizer); Decimal resolved once for CJS/ESM interop.
- packages/evals: L2 metrics (MAPE, nRMSE, coverage, direction accuracy,
naive/hindsight revenue baselines), HTTP skill client, rolling-origin
harness that pushes each bid through the real ledger, CLI with
--check/--write-baseline; committed baseline on the SYNTHETIC dataset
(no historical Hubei data yet — baselines measure the harness, not KPI).
- CI: evals job boots the skill service and fails on baseline digest drift.
- docs/open-questions: A6 (flexibility marginal cost = offer floor); A4/B6
wired as placeholders. README/CLAUDE.md status → M2 done, M3 next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:29:08 -04:00
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain'
|
|
|
|
|
import { Decimal, LedgerService } from '@vpp/services'
|
M3: Mastra runtime, safety chain, two agents, Case Desk v1
- packages/domain: safety-chain objects (ValidationResult, SimulationResult,
EnvelopeMatch, StaleDenial, ExecutionReceipt, LineageRef/BidProposalDraft,
RouterDecision, BidExportFile) + fixtures on both sides.
- packages/services: proposal digest; PolicyEngine + hubei-spot-bidding pack
(digest-valid, bid-format, price-limits, quantity-non-negative,
ledger-consistency, lineage-integrity, originator-permission — each with
pass/fail tests); EnvelopeService; AuthorityService (fresh check, permits,
revoke, gateway validate); FileExportGateway (idempotent receipts);
Memory/File EventBus; LineageRecorder + P2 assembler; RevenueScenario
simulator; CaseDeskService; FsRepository; skill HTTP client moved here.
- packages/runtime: createRuntime (LibSQL storage, per-runtime workflow
factories), proposal-lifecycle (rule check → simulation → envelope gate
with suspend/resume → fresh check + permit → release), day-ahead-situation,
day-ahead-bid, TriggerService (scheduled/event/manual), LlmPort
(Mastra/Scripted/Null), Case Desk HTTP API, dev entry point.
- Tests: all eight docs/01 invariants, docs/07 06:00→08:30 end to end with
LLM down, restart survival of a suspended approval, permit expiry and
revocation, replay of a released proposal, trigger scheduling. 141 TS +
60 Python tests.
- Known gaps: ledger not yet persisted (replayed on restart); STALE ends the
run instead of looping to rule check; synthetic data stands in for
historical replay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:55:55 -04:00
|
|
|
import type { SkillClient } from '@vpp/services'
|
M2: skill contracts, Python skill service, L2 eval harness with baseline
- packages/domain: ForecastRequest, BidOptimizationRequest/Result,
ReportRequest, SkillReport (+ golden and invalid fixtures, exported to
contracts/ and regenerated as pydantic models).
- skills-py/vpp_skills: FastAPI service with versioned registry; load/PV/
price forecasts (same-day-type EWM point forecast, conformal residual
quantiles — coverage test as acceptance gate); bid-optimization MILP on
HiGHS (binary block participation, hard ledger energy bounds, exact
Decimal fit of the rounded curve inside the bounds, revenue distribution
over quantile paths); report generator whose every figure is a
{tool_call_id, path} reference, with a verifier. 48 tests incl. hypothesis
property test that bids respect ledger constraints.
- packages/services: LedgerService.dayAheadBounds (the P7 cascade band
handed to the optimizer); Decimal resolved once for CJS/ESM interop.
- packages/evals: L2 metrics (MAPE, nRMSE, coverage, direction accuracy,
naive/hindsight revenue baselines), HTTP skill client, rolling-origin
harness that pushes each bid through the real ledger, CLI with
--check/--write-baseline; committed baseline on the SYNTHETIC dataset
(no historical Hubei data yet — baselines measure the harness, not KPI).
- CI: evals job boots the skill service and fails on baseline digest drift.
- docs/open-questions: A6 (flexibility marginal cost = offer floor); A4/B6
wired as placeholders. README/CLAUDE.md status → M2 done, M3 next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:29:08 -04:00
|
|
|
import type { Dataset, DatasetDay } from './dataset.js'
|
|
|
|
|
import { nums, toCurve } from './dataset.js'
|
|
|
|
|
import {
|
|
|
|
|
coverage,
|
|
|
|
|
directionAccuracy,
|
|
|
|
|
hindsightBid,
|
|
|
|
|
mape,
|
|
|
|
|
mean,
|
|
|
|
|
naiveBid,
|
|
|
|
|
nrmse,
|
|
|
|
|
realisedRevenue,
|
|
|
|
|
} from './metrics.js'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* L2 harness (docs/12 §1 L2, §5 harness/): rolling-origin backtest over a
|
|
|
|
|
* replay dataset. For each held-out day it asks the skill service for the three
|
|
|
|
|
* forecasts and a bid, scores them against actuals, and pushes the bid through
|
|
|
|
|
* the real LedgerService so "MILP output respects ledger constraints" is
|
|
|
|
|
* checked by the component that enforces it — not by a re-implementation.
|
|
|
|
|
*
|
|
|
|
|
* Every run produces an EvalRun whose `digest` covers config, dataset hash,
|
|
|
|
|
* skill versions and metrics. Two runs on the same inputs must digest equal
|
|
|
|
|
* (reproducibility acceptance); the CLI's --check compares against the
|
|
|
|
|
* committed baseline.
|
|
|
|
|
*/
|
|
|
|
|
export interface HarnessConfig {
|
|
|
|
|
/** History days handed to each forecast call. */
|
|
|
|
|
window: number
|
|
|
|
|
/** Index of the first held-out day (needs ≥ window history before it). */
|
|
|
|
|
holdoutFrom: number
|
|
|
|
|
/** Number of held-out days; 0 = to the end of the dataset. */
|
|
|
|
|
holdoutDays: number
|
|
|
|
|
risk: BidRiskParams
|
|
|
|
|
/**
|
|
|
|
|
* Share of sellable energy (k·cap·0.25·96) used as the daily contract
|
|
|
|
|
* position when seeding the ledger. Synthetic-set convenience, not a market
|
|
|
|
|
* parameter.
|
|
|
|
|
*/
|
|
|
|
|
contractShareOfSellable: string
|
|
|
|
|
/** Passed to LedgerService; OPEN-QUESTION A5 — placeholder until confirmed. */
|
|
|
|
|
daMonthlyDeviationBand: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
|
|
|
window: 28,
|
|
|
|
|
holdoutFrom: 60,
|
|
|
|
|
holdoutDays: 0,
|
|
|
|
|
// OPEN-QUESTION B6 (commitment_buffer_k) — placeholder for harness use only.
|
|
|
|
|
// OPEN-QUESTION A6 (marginal_cost) — placeholder 0: offer as a price-taker.
|
|
|
|
|
risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '0' },
|
|
|
|
|
contractShareOfSellable: '0.6',
|
|
|
|
|
// OPEN-QUESTION A5 — placeholder, same value the services ledger tests use.
|
|
|
|
|
daMonthlyDeviationBand: '0.05',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForecastMetrics {
|
|
|
|
|
days: number
|
|
|
|
|
mape: number | null
|
|
|
|
|
nrmse: number | null
|
|
|
|
|
coverage_p10_p90: number
|
|
|
|
|
direction_accuracy: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BidMetrics {
|
|
|
|
|
days: number
|
|
|
|
|
optimal_days: number
|
|
|
|
|
ledger_accepted_days: number
|
|
|
|
|
revenue_skill_yuan: number
|
|
|
|
|
revenue_naive_yuan: number
|
|
|
|
|
revenue_hindsight_yuan: number
|
|
|
|
|
/** skill / hindsight — 1.0 would be perfect foresight. */
|
|
|
|
|
capture_ratio: number
|
|
|
|
|
/** skill / naive — > 1.0 means the optimiser beats a price-taker. */
|
|
|
|
|
uplift_vs_naive: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DayResult {
|
|
|
|
|
date: string
|
|
|
|
|
load: { mape: number; coverage: number }
|
|
|
|
|
pv: { nrmse: number; coverage: number }
|
|
|
|
|
price: { mape: number; coverage: number; direction: number }
|
|
|
|
|
bid: {
|
|
|
|
|
status: string
|
|
|
|
|
ledger_accepted: boolean
|
|
|
|
|
energy_mwh: string
|
|
|
|
|
revenue_skill: number
|
|
|
|
|
revenue_naive: number
|
|
|
|
|
revenue_hindsight: number
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface EvalRun {
|
|
|
|
|
id: string
|
|
|
|
|
layer: 'L2'
|
|
|
|
|
run_at: string
|
|
|
|
|
dataset: { name: string; sha256: string; synthetic: boolean }
|
|
|
|
|
skill_versions: Record<string, string>
|
|
|
|
|
config: HarnessConfig
|
|
|
|
|
metrics: { load: ForecastMetrics; pv: ForecastMetrics; price: ForecastMetrics; bid: BidMetrics }
|
|
|
|
|
per_day: DayResult[]
|
|
|
|
|
/** sha256 over everything above except id/run_at — the reproducibility key. */
|
|
|
|
|
digest: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const REF = 'f'.repeat(64)
|
|
|
|
|
|
|
|
|
|
/** Key-sorted JSON. (services' canonicalJson rejects floats by design; metrics are floats.) */
|
|
|
|
|
export const stableJson = (v: unknown): string =>
|
|
|
|
|
JSON.stringify(v, (_k, val) =>
|
|
|
|
|
val !== null && typeof val === 'object' && !Array.isArray(val)
|
|
|
|
|
? Object.fromEntries(Object.entries(val as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
|
|
|
: val,
|
|
|
|
|
)
|
|
|
|
|
const FIELD: Record<ForecastKind, keyof DatasetDay> = {
|
|
|
|
|
LOAD: 'load_mw',
|
|
|
|
|
PV: 'pv_mw',
|
|
|
|
|
PRICE: 'price_yuan_per_mwh',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const q = (x: number, dp: number) => Number(x.toFixed(dp))
|
|
|
|
|
|
|
|
|
|
function bandOf(bundle: ForecastBundle) {
|
|
|
|
|
return {
|
|
|
|
|
p10: nums(bundle.quantiles.p10.values),
|
|
|
|
|
p50: nums(bundle.quantiles.p50.values),
|
|
|
|
|
p90: nums(bundle.quantiles.p90.values),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function seedLedger(dataset: Dataset, cfg: HarnessConfig, days: DatasetDay[]): LedgerService {
|
|
|
|
|
const ledger = new LedgerService({ daMonthlyDeviationBand: cfg.daMonthlyDeviationBand, clock: () => '2026-01-01T00:00:00Z' })
|
|
|
|
|
const k = new Decimal(cfg.risk.commitment_buffer_k)
|
|
|
|
|
const share = new Decimal(cfg.contractShareOfSellable)
|
|
|
|
|
const months = [...new Set(days.map((d) => d.date.slice(0, 7)))]
|
|
|
|
|
let version = 0
|
|
|
|
|
for (const month of months) {
|
|
|
|
|
const sample = days.find((d) => d.date.startsWith(month))!
|
|
|
|
|
const sellable = sample.adjustable_capacity_mw
|
|
|
|
|
.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
|
|
|
|
.mul(k)
|
|
|
|
|
.mul('0.25')
|
|
|
|
|
const daysInMonth = new Date(Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0)).getUTCDate()
|
|
|
|
|
ledger.append({
|
|
|
|
|
id: `contract-${month}`,
|
|
|
|
|
timescale: 'MONTHLY',
|
|
|
|
|
period: month,
|
|
|
|
|
kind: 'CONTRACT',
|
|
|
|
|
energy_mwh: sellable.mul(share).mul(daysInMonth).toFixed(3),
|
|
|
|
|
curve: null,
|
|
|
|
|
source_ref: `synthetic-contract-${month}`,
|
|
|
|
|
expected_version: version++,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
void dataset
|
|
|
|
|
return ledger
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runL2(
|
|
|
|
|
dataset: Dataset,
|
|
|
|
|
datasetSha256: string,
|
|
|
|
|
client: SkillClient,
|
|
|
|
|
cfg: HarnessConfig = DEFAULT_CONFIG,
|
|
|
|
|
clock: () => string = () => new Date().toISOString(),
|
|
|
|
|
): Promise<EvalRun> {
|
|
|
|
|
if (cfg.holdoutFrom < cfg.window) throw new Error('holdoutFrom must be ≥ window')
|
|
|
|
|
const end = cfg.holdoutDays > 0 ? Math.min(dataset.days.length, cfg.holdoutFrom + cfg.holdoutDays) : dataset.days.length
|
|
|
|
|
const holdout = dataset.days.slice(cfg.holdoutFrom, end)
|
|
|
|
|
const ledger = seedLedger(dataset, cfg, holdout)
|
|
|
|
|
const skillVersions = Object.fromEntries((await client.skills()).map((s) => [s.id, s.version]))
|
|
|
|
|
|
|
|
|
|
const perDay: DayResult[] = []
|
|
|
|
|
for (let idx = cfg.holdoutFrom; idx < end; idx++) {
|
|
|
|
|
const day = dataset.days[idx]!
|
|
|
|
|
const history = dataset.days.slice(idx - cfg.window, idx)
|
|
|
|
|
|
|
|
|
|
const forecasts = {} as Record<ForecastKind, ForecastBundle>
|
|
|
|
|
for (const kind of ['LOAD', 'PV', 'PRICE'] as const) {
|
|
|
|
|
forecasts[kind] = await client.forecast(kind, {
|
|
|
|
|
kind,
|
|
|
|
|
market_date: day.date,
|
|
|
|
|
unit: kind === 'PRICE' ? 'yuan_per_mwh' : 'mw',
|
|
|
|
|
history: history.map((h) => toCurve(h[FIELD[kind]] as string[], h.date)),
|
|
|
|
|
exogenous: {},
|
|
|
|
|
features_snapshot_ref: REF,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bounds = ledger.dayAheadBounds(day.date)
|
|
|
|
|
const bid: BidOptimizationResult = await client.optimizeBid({
|
|
|
|
|
market_date: day.date,
|
|
|
|
|
price_forecast: forecasts.PRICE,
|
|
|
|
|
adjustable_capacity_mw: toCurve(day.adjustable_capacity_mw, day.date),
|
|
|
|
|
position_bounds: bounds,
|
|
|
|
|
risk: cfg.risk,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let ledgerAccepted = false
|
|
|
|
|
if (bid.solver.status === 'OPTIMAL') {
|
|
|
|
|
try {
|
|
|
|
|
ledger.append({
|
|
|
|
|
id: `bid-${day.date}`,
|
|
|
|
|
timescale: 'DAY_AHEAD',
|
|
|
|
|
period: day.date,
|
|
|
|
|
kind: 'BID_SUBMITTED',
|
|
|
|
|
energy_mwh: bid.daily_energy_mwh,
|
|
|
|
|
curve: bid.quantities_mwh,
|
|
|
|
|
source_ref: `eval-${day.date}`,
|
|
|
|
|
expected_version: ledger.read().version,
|
|
|
|
|
})
|
|
|
|
|
ledgerAccepted = true
|
|
|
|
|
} catch {
|
|
|
|
|
ledgerAccepted = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actualLoad = nums(day.load_mw)
|
|
|
|
|
const actualPv = nums(day.pv_mw)
|
|
|
|
|
const actualPrice = nums(day.price_yuan_per_mwh)
|
|
|
|
|
const load = bandOf(forecasts.LOAD)
|
|
|
|
|
const pv = bandOf(forecasts.PV)
|
|
|
|
|
const price = bandOf(forecasts.PRICE)
|
|
|
|
|
const capMwh = nums(day.adjustable_capacity_mw).map((c) => c * Number(cfg.risk.commitment_buffer_k) * 0.25)
|
|
|
|
|
const eMax = Number(bounds.daily_energy_max_mwh)
|
|
|
|
|
const daylight = actualPv.map((v, i) => [v, i] as const).filter(([v]) => v > 0).map(([, i]) => i)
|
|
|
|
|
|
|
|
|
|
perDay.push({
|
|
|
|
|
date: day.date,
|
|
|
|
|
load: { mape: q(mape(actualLoad, load.p50), 6), coverage: q(coverage(actualLoad, load.p10, load.p90), 6) },
|
|
|
|
|
pv: {
|
|
|
|
|
nrmse: q(nrmse(actualPv, pv.p50, Number(dataset.meta.pv_capacity_mw)), 6),
|
|
|
|
|
coverage: q(
|
|
|
|
|
coverage(
|
|
|
|
|
daylight.map((i) => actualPv[i]!),
|
|
|
|
|
daylight.map((i) => pv.p10[i]!),
|
|
|
|
|
daylight.map((i) => pv.p90[i]!),
|
|
|
|
|
),
|
|
|
|
|
6,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
price: {
|
|
|
|
|
mape: q(mape(actualPrice, price.p50), 6),
|
|
|
|
|
coverage: q(coverage(actualPrice, price.p10, price.p90), 6),
|
|
|
|
|
direction: q(directionAccuracy(actualPrice, price.p50), 6),
|
|
|
|
|
},
|
|
|
|
|
bid: {
|
|
|
|
|
status: bid.solver.status,
|
|
|
|
|
ledger_accepted: ledgerAccepted,
|
|
|
|
|
energy_mwh: bid.daily_energy_mwh,
|
|
|
|
|
revenue_skill: q(
|
|
|
|
|
realisedRevenue({ offers: nums(bid.prices_yuan_per_mwh.values), quantities: nums(bid.quantities_mwh.values) }, actualPrice),
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
revenue_naive: q(realisedRevenue(naiveBid(capMwh, eMax), actualPrice), 2),
|
|
|
|
|
revenue_hindsight: q(
|
|
|
|
|
realisedRevenue(hindsightBid(actualPrice, capMwh, eMax, Number(cfg.risk.min_block_mwh)), actualPrice),
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fm = (pick: (d: DayResult) => { mape?: number; nrmse?: number; coverage: number; direction?: number }): ForecastMetrics => {
|
|
|
|
|
const rows = perDay.map(pick)
|
|
|
|
|
const has = (k: 'mape' | 'nrmse' | 'direction') => rows.every((r) => r[k] !== undefined)
|
|
|
|
|
return {
|
|
|
|
|
days: rows.length,
|
|
|
|
|
mape: has('mape') ? q(mean(rows.map((r) => r.mape!)), 6) : null,
|
|
|
|
|
nrmse: has('nrmse') ? q(mean(rows.map((r) => r.nrmse!)), 6) : null,
|
|
|
|
|
coverage_p10_p90: q(mean(rows.map((r) => r.coverage)), 6),
|
|
|
|
|
direction_accuracy: has('direction') ? q(mean(rows.map((r) => r.direction!)), 6) : null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const sum = (f: (d: DayResult) => number) => q(perDay.reduce((s, d) => s + f(d), 0), 2)
|
|
|
|
|
const skill = sum((d) => d.bid.revenue_skill)
|
|
|
|
|
const naive = sum((d) => d.bid.revenue_naive)
|
|
|
|
|
const hindsight = sum((d) => d.bid.revenue_hindsight)
|
|
|
|
|
|
|
|
|
|
const body = {
|
|
|
|
|
layer: 'L2' as const,
|
|
|
|
|
dataset: { name: dataset.meta.name, sha256: datasetSha256, synthetic: dataset.meta.synthetic },
|
|
|
|
|
skill_versions: skillVersions,
|
|
|
|
|
config: cfg,
|
|
|
|
|
metrics: {
|
|
|
|
|
load: fm((d) => d.load),
|
|
|
|
|
pv: fm((d) => d.pv),
|
|
|
|
|
price: fm((d) => d.price),
|
|
|
|
|
bid: {
|
|
|
|
|
days: perDay.length,
|
|
|
|
|
optimal_days: perDay.filter((d) => d.bid.status === 'OPTIMAL').length,
|
|
|
|
|
ledger_accepted_days: perDay.filter((d) => d.bid.ledger_accepted).length,
|
|
|
|
|
revenue_skill_yuan: skill,
|
|
|
|
|
revenue_naive_yuan: naive,
|
|
|
|
|
revenue_hindsight_yuan: hindsight,
|
|
|
|
|
capture_ratio: hindsight === 0 ? 0 : q(skill / hindsight, 6),
|
|
|
|
|
uplift_vs_naive: naive === 0 ? 0 : q(skill / naive, 6),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
per_day: perDay,
|
|
|
|
|
}
|
|
|
|
|
const digest = createHash('sha256').update(stableJson(body)).digest('hex')
|
|
|
|
|
const runAt = clock()
|
|
|
|
|
return { id: `l2-${runAt.replace(/[:.]/g, '-')}-${digest.slice(0, 8)}`, run_at: runAt, ...body, digest }
|
|
|
|
|
}
|