Compare commits
No commits in common. "1cc21e0dc66346d09ce058bb6ed91f394bf514c6" and "f681e134cc6c9a30513558ea8b495c4fc93b580f" have entirely different histories.
1cc21e0dc6
...
f681e134cc
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
@ -1,73 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# Domain schemas + deterministic services (TS side of the contract pipeline).
|
||||
typescript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
# ADR-0002: contracts/ is generated from packages/domain and committed.
|
||||
# A dirty tree here means someone changed a zod schema without
|
||||
# re-exporting, or hand-edited a generated file.
|
||||
- run: npm run export:schemas && npm run make:fixtures
|
||||
- run: git diff --exit-code -- contracts/
|
||||
- run: npm test
|
||||
|
||||
# Generated pydantic models must accept/reject the same golden fixtures the
|
||||
# TS side does (docs/11 §3.2 dual-side contract test).
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: skills-py
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: skills-py/.python-version
|
||||
cache: pip
|
||||
cache-dependency-path: skills-py/requirements.txt
|
||||
- run: pip install -r requirements.txt
|
||||
# Generated models are committed but never hand-edited: regenerate and
|
||||
# require a clean tree.
|
||||
- run: bash scripts/generate_models.sh
|
||||
- run: git diff --exit-code -- vpp_contracts/
|
||||
- run: python -m pytest -q
|
||||
|
||||
# L2 eval harness end-to-end (docs/12): TS harness ↔ live Python skill
|
||||
# service on the committed synthetic dataset. The run must reproduce the
|
||||
# committed baseline digest exactly — a skill change that moves a metric
|
||||
# must come with a reviewed baseline update (docs/12 §3 gate).
|
||||
evals:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [typescript, python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: skills-py/.python-version
|
||||
cache: pip
|
||||
cache-dependency-path: skills-py/requirements.txt
|
||||
- run: npm ci
|
||||
- run: pip install -r skills-py/requirements.txt
|
||||
- name: start skill service
|
||||
working-directory: skills-py
|
||||
run: |
|
||||
python -m uvicorn vpp_skills.app:app --port 8000 --log-level warning &
|
||||
for i in $(seq 1 40); do curl -sf http://127.0.0.1:8000/health && break; sleep 0.5; done
|
||||
- run: npm run eval -w @vpp/evals -- --check
|
||||
12
CLAUDE.md
12
CLAUDE.md
@ -6,17 +6,7 @@ docs win — or write an ADR changing the doc first.
|
||||
|
||||
## Current phase
|
||||
|
||||
Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
|
||||
services (ledger, stores, policy engine, envelope, authority/permits, gateway,
|
||||
events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval
|
||||
harness with committed baseline (synthetic data); Mastra runtime with the
|
||||
proposal-lifecycle safety chain (durable suspend/resume), situation and bid
|
||||
workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage
|
||||
is file-backed reference semantics — Postgres/Timescale adapters later. Next is
|
||||
M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
|
||||
must update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`).
|
||||
Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never
|
||||
module singletons, or a second Mastra instance steals their context.
|
||||
Docs complete, implementation not started. Build order is ROADMAP.md (M1→M5).
|
||||
Do not start a milestone's work before its predecessor's acceptance criteria are
|
||||
testable, and do not build phase-2 items (edge control links, federation,
|
||||
interaction/load-control agents) unless explicitly asked.
|
||||
|
||||
47
README.md
47
README.md
@ -6,50 +6,9 @@ a deterministic safety chain (rule check → simulation → envelope/human appro
|
||||
execution permit) governs everything before any external effect. **The LLM never
|
||||
computes numbers and never touches the second-level control loop.**
|
||||
|
||||
**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation
|
||||
follows [ROADMAP.md](ROADMAP.md). Present today:
|
||||
|
||||
- `packages/domain` — zod schemas for every business and safety-chain object, exported to
|
||||
`contracts/` and regenerated as pydantic models (`skills-py/vpp_contracts`).
|
||||
- `packages/services` — deterministic services: ledger (with the P7 cascade), snapshot,
|
||||
time-series, relational and ingestion stores; policy engine + `hubei-spot-bidding` pack;
|
||||
envelope gate; authority (fresh check, permits, revocation); file-export gateway;
|
||||
event store/bus; lineage recorder + P2 assembler; revenue-scenario simulation; Case Desk.
|
||||
- `skills-py/vpp_skills` — Python skill service: load/PV/price forecasts with calibrated
|
||||
quantiles, bid-optimization MILP, report generator.
|
||||
- `packages/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset).
|
||||
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
|
||||
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
|
||||
trigger service (scheduled / event / manual via router agent); provider-abstracted LLM
|
||||
port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual
|
||||
upload (degraded channel by design).
|
||||
|
||||
All eight docs/01 invariants have automated tests (`packages/services/test/chain.test.ts`,
|
||||
`packages/runtime/test/lifecycle.test.ts`). Storage is file-backed reference semantics;
|
||||
Postgres/Timescale adapters and the programmatic trading-platform channel are later work.
|
||||
M4 (resource agent, envelopes live, review loop) is next.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Node 24 and Python 3.11 (`skills-py/.python-version`; the generated
|
||||
pydantic models use `StrEnum` and PEP 604 unions).
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check # typecheck, re-export contracts, run TS tests
|
||||
npm run eval -w @vpp/evals -- --check # L2 harness vs baseline (needs the skill service below)
|
||||
npm run start -w @vpp/runtime # runtime + Case Desk API on :4100 (VPP_LLM_MODEL unset = LLM-down mode)
|
||||
|
||||
cd skills-py
|
||||
uv venv --python 3.11 .venv && uv pip install -r requirements.txt # or python3.11 -m venv
|
||||
source .venv/bin/activate
|
||||
bash scripts/generate_models.sh # regenerate pydantic models (committed, never hand-edited)
|
||||
python -m pytest -q
|
||||
python -m uvicorn vpp_skills.app:app --port 8000 # skill service for the eval harness
|
||||
```
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs both sides and fails if `contracts/` or
|
||||
`skills-py/vpp_contracts` are not regenerated after a schema change.
|
||||
**Status: architecture/design phase.** No implementation code yet. The design is
|
||||
complete and internally consistent (docs 00–13); implementation follows
|
||||
[ROADMAP.md](ROADMAP.md), starting with M1.
|
||||
|
||||
## Orientation
|
||||
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00"
|
||||
]
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5"
|
||||
]
|
||||
},
|
||||
"daily_energy_mwh": "1200.0",
|
||||
"expected_revenue_yuan": "510600.00",
|
||||
"revenue_distribution_yuan": {
|
||||
"p10": "432000.00",
|
||||
"p50": "510600.00",
|
||||
"p90": "588000.00"
|
||||
},
|
||||
"position_bounds": {
|
||||
"ledger_version": 42,
|
||||
"daily_energy_min_mwh": "1140.0",
|
||||
"daily_energy_max_mwh": "1260.0"
|
||||
},
|
||||
"solver": {
|
||||
"name": "highs",
|
||||
"version": "1.7.0",
|
||||
"status": "SOLVED",
|
||||
"objective_value": "487020.00",
|
||||
"wall_time_ms": 12
|
||||
},
|
||||
"binding_constraints": [
|
||||
"daily_energy_max"
|
||||
],
|
||||
"skill_version": "1.0.0"
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "prices_yuan_per_mwh"
|
||||
},
|
||||
"quantities_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "quantities_mwh"
|
||||
},
|
||||
"expected_revenue_ref": "510600.00",
|
||||
"rationale": "x"
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"workflow_id": "transfer-funds",
|
||||
"params": {
|
||||
"market_date": "2026-03-15"
|
||||
},
|
||||
"confidence": "HIGH"
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"id": "rep-001",
|
||||
"kind": "DAY_AHEAD_BID_SUMMARY",
|
||||
"market_date": "2026-03-15",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Bid summary",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "expected_revenue",
|
||||
"value": "510600.00",
|
||||
"unit": "yuan"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"all figures reference solver output tc-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"skill_version": "1.0.0",
|
||||
"generated_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,213 +0,0 @@
|
||||
{
|
||||
"format_version": "1.0.0",
|
||||
"proposal_id": "prop-001",
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"permit_id": "permit-001",
|
||||
"proposal_type": "BID",
|
||||
"market_date": "2026-03-15",
|
||||
"prices_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00"
|
||||
]
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5"
|
||||
]
|
||||
},
|
||||
"exported_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,436 +0,0 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"price_forecast": {
|
||||
"id": "fc-price-001",
|
||||
"kind": "PRICE",
|
||||
"market_date": "2026-03-15",
|
||||
"unit": "yuan_per_mwh",
|
||||
"quantiles": {
|
||||
"p10": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00",
|
||||
"360.00"
|
||||
]
|
||||
},
|
||||
"p50": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50",
|
||||
"425.50"
|
||||
]
|
||||
},
|
||||
"p90": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00",
|
||||
"490.00"
|
||||
]
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "price-forecast",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"features_snapshot_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"generated_at": "2026-03-14T06:00:00Z"
|
||||
},
|
||||
"adjustable_capacity_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0",
|
||||
"60.0"
|
||||
]
|
||||
},
|
||||
"position_bounds": {
|
||||
"ledger_version": 42,
|
||||
"daily_energy_min_mwh": "1140.0",
|
||||
"daily_energy_max_mwh": "1260.0"
|
||||
},
|
||||
"risk": {
|
||||
"risk_aversion": "0.3",
|
||||
"commitment_buffer_k": "0.9",
|
||||
"min_block_mwh": "1.0",
|
||||
"marginal_cost_yuan_per_mwh": "0"
|
||||
}
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00"
|
||||
]
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5"
|
||||
]
|
||||
},
|
||||
"daily_energy_mwh": "1200.0",
|
||||
"expected_revenue_yuan": "510600.00",
|
||||
"revenue_distribution_yuan": {
|
||||
"p10": "432000.00",
|
||||
"p50": "510600.00",
|
||||
"p90": "588000.00"
|
||||
},
|
||||
"position_bounds": {
|
||||
"ledger_version": 42,
|
||||
"daily_energy_min_mwh": "1140.0",
|
||||
"daily_energy_max_mwh": "1260.0"
|
||||
},
|
||||
"solver": {
|
||||
"name": "highs",
|
||||
"version": "1.7.0",
|
||||
"status": "OPTIMAL",
|
||||
"objective_value": "487020.00",
|
||||
"wall_time_ms": 12
|
||||
},
|
||||
"binding_constraints": [
|
||||
"daily_energy_max"
|
||||
],
|
||||
"skill_version": "1.0.0"
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "prices_yuan_per_mwh"
|
||||
},
|
||||
"quantities_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "quantities_mwh"
|
||||
},
|
||||
"expected_revenue_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "expected_revenue_yuan"
|
||||
},
|
||||
"rationale": "Evening peak priced above P50; allocate bounded energy to 18:00–20:00."
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"within": false,
|
||||
"envelope_id": null,
|
||||
"required_level": "L2",
|
||||
"reasons": [
|
||||
"no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"within": true,
|
||||
"envelope_id": "env-bid-001",
|
||||
"required_level": "L1",
|
||||
"reasons": [],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"receipt_id": "rcpt-001",
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"permit_id": "permit-001",
|
||||
"channel": "FILE_EXPORT",
|
||||
"idempotency_key": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:permit-001",
|
||||
"artifact_ref": "exports/bid-2026-03-15-prop-001.json",
|
||||
"accepted_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,213 +0,0 @@
|
||||
{
|
||||
"kind": "PRICE",
|
||||
"market_date": "2026-03-15",
|
||||
"unit": "yuan_per_mwh",
|
||||
"history": [
|
||||
{
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-13",
|
||||
"values": [
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20",
|
||||
"398.20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-14",
|
||||
"values": [
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75",
|
||||
"410.75"
|
||||
]
|
||||
}
|
||||
],
|
||||
"exogenous": {},
|
||||
"features_snapshot_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"kind": "DAY_AHEAD_BID_SUMMARY",
|
||||
"market_date": "2026-03-15",
|
||||
"sources": [
|
||||
{
|
||||
"tool_call_id": "tc-001",
|
||||
"tool": "bid-optimization-milp",
|
||||
"version": "1.0.0",
|
||||
"output": {
|
||||
"expected_revenue_yuan": "510600.00",
|
||||
"daily_energy_mwh": "1200.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"workflow_id": "day-ahead-bid",
|
||||
"params": {
|
||||
"market_date": "2026-03-15"
|
||||
},
|
||||
"confidence": "HIGH"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"kind": "REVENUE_SCENARIOS",
|
||||
"alert": false,
|
||||
"alerts": [],
|
||||
"metrics": {
|
||||
"revenue_p05_yuan": "410200.00",
|
||||
"revenue_p50_yuan": "510600.00",
|
||||
"worst_loss_yuan": "0.00"
|
||||
},
|
||||
"scenario_count": 1000,
|
||||
"simulator": {
|
||||
"name": "revenue-scenarios",
|
||||
"version": "1.0.0",
|
||||
"seed": 20260315
|
||||
},
|
||||
"simulated_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"id": "rep-001",
|
||||
"kind": "DAY_AHEAD_BID_SUMMARY",
|
||||
"market_date": "2026-03-15",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Bid summary",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "expected_revenue",
|
||||
"value": "510600.00",
|
||||
"unit": "yuan",
|
||||
"ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "expected_revenue_yuan"
|
||||
}
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"all figures reference solver output tc-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"skill_version": "1.0.0",
|
||||
"generated_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"reasons": [
|
||||
"ledger version 42 at approval, now 43"
|
||||
],
|
||||
"checked_at": "2026-03-15T23:59:59Z"
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"policy_pack": {
|
||||
"id": "hubei-spot-bidding",
|
||||
"version": "2026.03"
|
||||
},
|
||||
"ok": true,
|
||||
"violations": [],
|
||||
"rules_evaluated": [
|
||||
"bid-format",
|
||||
"price-limits",
|
||||
"ledger-consistency",
|
||||
"lineage-integrity"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"policy_pack": {
|
||||
"id": "hubei-spot-bidding",
|
||||
"version": "2026.03"
|
||||
},
|
||||
"ok": false,
|
||||
"violations": [
|
||||
{
|
||||
"rule_id": "price-limits",
|
||||
"severity": "REJECT",
|
||||
"message": "price 1500.00 above cap"
|
||||
}
|
||||
],
|
||||
"rules_evaluated": [
|
||||
"bid-format",
|
||||
"price-limits"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"type": "string",
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"proposal_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"permit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"BID",
|
||||
"INVITATION",
|
||||
"CONTROL_PLAN",
|
||||
"DISPATCH_PLAN"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"prices_yuan_per_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"format_version",
|
||||
"proposal_id",
|
||||
"proposal_digest",
|
||||
"permit_id",
|
||||
"proposal_type",
|
||||
"market_date",
|
||||
"prices_yuan_per_mwh",
|
||||
"quantities_mwh",
|
||||
"exported_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_export_file.json",
|
||||
"title": "BidExportFile"
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"price_forecast": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"LOAD",
|
||||
"PV",
|
||||
"PRICE"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mw",
|
||||
"yuan_per_mwh"
|
||||
]
|
||||
},
|
||||
"quantiles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p10": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"p50": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"p90": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"p10",
|
||||
"p50",
|
||||
"p90"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"features_snapshot_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"market_date",
|
||||
"unit",
|
||||
"quantiles",
|
||||
"model",
|
||||
"features_snapshot_ref",
|
||||
"generated_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"adjustable_capacity_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"position_bounds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ledger_version": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"daily_energy_min_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"daily_energy_max_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ledger_version",
|
||||
"daily_energy_min_mwh",
|
||||
"daily_energy_max_mwh"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"risk_aversion": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"commitment_buffer_k": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"min_block_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"marginal_cost_yuan_per_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"risk_aversion",
|
||||
"commitment_buffer_k",
|
||||
"min_block_mwh",
|
||||
"marginal_cost_yuan_per_mwh"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"price_forecast",
|
||||
"adjustable_capacity_mw",
|
||||
"position_bounds",
|
||||
"risk"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_optimization_request.json",
|
||||
"title": "BidOptimizationRequest"
|
||||
}
|
||||
@ -1,192 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"prices_yuan_per_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"daily_energy_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"expected_revenue_yuan": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"revenue_distribution_yuan": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p10": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"p50": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"p90": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"p10",
|
||||
"p50",
|
||||
"p90"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"position_bounds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ledger_version": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"daily_energy_min_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"daily_energy_max_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ledger_version",
|
||||
"daily_energy_min_mwh",
|
||||
"daily_energy_max_mwh"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"solver": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"OPTIMAL",
|
||||
"INFEASIBLE",
|
||||
"TIME_LIMIT",
|
||||
"ERROR"
|
||||
]
|
||||
},
|
||||
"objective_value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"wall_time_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version",
|
||||
"status",
|
||||
"objective_value",
|
||||
"wall_time_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"binding_constraints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skill_version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"prices_yuan_per_mwh",
|
||||
"quantities_mwh",
|
||||
"daily_energy_mwh",
|
||||
"expected_revenue_yuan",
|
||||
"revenue_distribution_yuan",
|
||||
"position_bounds",
|
||||
"solver",
|
||||
"binding_constraints",
|
||||
"skill_version"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_optimization_result.json",
|
||||
"title": "BidOptimizationResult"
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"prices_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"quantities_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"expected_revenue_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"prices_ref",
|
||||
"quantities_ref",
|
||||
"expected_revenue_ref",
|
||||
"rationale"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_proposal_draft.json",
|
||||
"title": "BidProposalDraft"
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"within": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"envelope_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required_level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"L1",
|
||||
"L2",
|
||||
"L3"
|
||||
]
|
||||
},
|
||||
"reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"within",
|
||||
"envelope_id",
|
||||
"required_level",
|
||||
"reasons",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/envelope_match.json",
|
||||
"title": "EnvelopeMatch"
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"receipt_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"permit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FILE_EXPORT",
|
||||
"TRADING_PLATFORM_API",
|
||||
"SIMULATION_GATEWAY"
|
||||
]
|
||||
},
|
||||
"idempotency_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"artifact_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"accepted_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"receipt_id",
|
||||
"proposal_digest",
|
||||
"permit_id",
|
||||
"channel",
|
||||
"idempotency_key",
|
||||
"artifact_ref",
|
||||
"accepted_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/execution_receipt.json",
|
||||
"title": "ExecutionReceipt"
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"LOAD",
|
||||
"PV",
|
||||
"PRICE"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mw",
|
||||
"yuan_per_mwh"
|
||||
]
|
||||
},
|
||||
"history": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"exogenous": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"features_snapshot_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"market_date",
|
||||
"unit",
|
||||
"history",
|
||||
"exogenous",
|
||||
"features_snapshot_ref"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/forecast_request.json",
|
||||
"title": "ForecastRequest"
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"DAY_AHEAD_BID_SUMMARY",
|
||||
"FORECAST_EVAL",
|
||||
"BID_BACKTEST"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"sources": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"output": {}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"tool",
|
||||
"version",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"market_date",
|
||||
"sources"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/report_request.json",
|
||||
"title": "ReportRequest"
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"day-ahead-situation",
|
||||
"day-ahead-bid",
|
||||
"adhoc-analysis"
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"HIGH",
|
||||
"MEDIUM",
|
||||
"LOW"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workflow_id",
|
||||
"params",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/router_decision.json",
|
||||
"title": "RouterDecision"
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REVENUE_SCENARIOS",
|
||||
"POWER_BALANCE"
|
||||
]
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"alerts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"scenario_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"simulator": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version",
|
||||
"seed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"simulated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"kind",
|
||||
"alert",
|
||||
"alerts",
|
||||
"metrics",
|
||||
"scenario_count",
|
||||
"simulator",
|
||||
"simulated_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/simulation_result.json",
|
||||
"title": "SimulationResult"
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"DAY_AHEAD_BID_SUMMARY",
|
||||
"FORECAST_EVAL",
|
||||
"BID_BACKTEST"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"value",
|
||||
"unit",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"metrics",
|
||||
"notes"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"skill_version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"market_date",
|
||||
"sections",
|
||||
"skill_version",
|
||||
"generated_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/skill_report.json",
|
||||
"title": "SkillReport"
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"reasons": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"reasons",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/stale_denial.json",
|
||||
"title": "StaleDenial"
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"policy_pack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"violations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REJECT",
|
||||
"WARN"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rule_id",
|
||||
"severity",
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"rules_evaluated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"policy_pack",
|
||||
"ok",
|
||||
"violations",
|
||||
"rules_evaluated",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/validation_result.json",
|
||||
"title": "ValidationResult"
|
||||
}
|
||||
@ -14,7 +14,6 @@ values for items on this list** — wire named config with placeholder + an
|
||||
| A3 | 日内市场机制(是否开、频次、截止) | `market.intraday` |
|
||||
| A4 | 偏差考核规则:偏差带、考核价格机制(→ MILP 目标函数与风险口径) | `market.deviation` |
|
||||
| A5 | 中长期持仓对日前申报的约束形式(分解曲线偏差带) | `ledger.da_bounds` |
|
||||
| A6 | 灵活性资源边际成本口径(用户补偿、设备损耗)——报价优化的报价下限;未定前按 0(价格接受者)申报 | `bidding.marginal_cost` |
|
||||
|
||||
## B. 包络与风控参数(owner: 运营团队 · blocks M4 envelopes + M5 breakers)
|
||||
|
||||
|
||||
2428
package-lock.json
generated
2428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,10 +6,9 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"export:schemas": "npm run export:schemas -w @vpp/domain",
|
||||
"make:fixtures": "npm run make:fixtures -w @vpp/domain",
|
||||
"check": "npm run typecheck && npm run export:schemas && npm test"
|
||||
"check": "npm run export:schemas && npm test"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"export:schemas": "tsx scripts/export-schemas.ts",
|
||||
"make:fixtures": "tsx scripts/make-fixtures.ts"
|
||||
|
||||
@ -150,199 +150,6 @@ const valid: Record<string, Record<string, unknown>> = {
|
||||
generated_at: T0,
|
||||
},
|
||||
},
|
||||
forecast_request: {
|
||||
'price-da': {
|
||||
kind: 'PRICE',
|
||||
market_date: DATE,
|
||||
unit: 'yuan_per_mwh',
|
||||
history: [curve('398.20', '2026-03-13'), curve('410.75', '2026-03-14')],
|
||||
exogenous: {},
|
||||
features_snapshot_ref: REF_A,
|
||||
},
|
||||
},
|
||||
bid_optimization_request: {
|
||||
'da-basic': {
|
||||
market_date: DATE,
|
||||
price_forecast: {
|
||||
id: 'fc-price-001',
|
||||
kind: 'PRICE',
|
||||
market_date: DATE,
|
||||
unit: 'yuan_per_mwh',
|
||||
quantiles: { p10: curve('360.00'), p50: curve('425.50'), p90: curve('490.00') },
|
||||
model: { name: 'price-forecast', version: '1.0.0' },
|
||||
features_snapshot_ref: REF_A,
|
||||
generated_at: T0,
|
||||
},
|
||||
adjustable_capacity_mw: curve('60.0'),
|
||||
position_bounds: {
|
||||
ledger_version: 42,
|
||||
daily_energy_min_mwh: '1140.0',
|
||||
daily_energy_max_mwh: '1260.0',
|
||||
},
|
||||
risk: {
|
||||
risk_aversion: '0.3',
|
||||
commitment_buffer_k: '0.9',
|
||||
min_block_mwh: '1.0',
|
||||
marginal_cost_yuan_per_mwh: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
bid_optimization_result: {
|
||||
'da-basic': {
|
||||
market_date: DATE,
|
||||
prices_yuan_per_mwh: curve('0.00'),
|
||||
quantities_mwh: curve('12.5'),
|
||||
daily_energy_mwh: '1200.0',
|
||||
expected_revenue_yuan: '510600.00',
|
||||
revenue_distribution_yuan: { p10: '432000.00', p50: '510600.00', p90: '588000.00' },
|
||||
position_bounds: {
|
||||
ledger_version: 42,
|
||||
daily_energy_min_mwh: '1140.0',
|
||||
daily_energy_max_mwh: '1260.0',
|
||||
},
|
||||
solver: {
|
||||
name: 'highs',
|
||||
version: '1.7.0',
|
||||
status: 'OPTIMAL',
|
||||
objective_value: '487020.00',
|
||||
wall_time_ms: 12,
|
||||
},
|
||||
binding_constraints: ['daily_energy_max'],
|
||||
skill_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
report_request: {
|
||||
'da-bid-summary': {
|
||||
kind: 'DAY_AHEAD_BID_SUMMARY',
|
||||
market_date: DATE,
|
||||
sources: [
|
||||
{
|
||||
tool_call_id: 'tc-001',
|
||||
tool: 'bid-optimization-milp',
|
||||
version: '1.0.0',
|
||||
output: { expected_revenue_yuan: '510600.00', daily_energy_mwh: '1200.0' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
skill_report: {
|
||||
'da-bid-summary': {
|
||||
id: 'rep-001',
|
||||
kind: 'DAY_AHEAD_BID_SUMMARY',
|
||||
market_date: DATE,
|
||||
sections: [
|
||||
{
|
||||
title: 'Bid summary',
|
||||
metrics: [
|
||||
{
|
||||
name: 'expected_revenue',
|
||||
value: '510600.00',
|
||||
unit: 'yuan',
|
||||
ref: { tool_call_id: 'tc-001', path: 'expected_revenue_yuan' },
|
||||
},
|
||||
],
|
||||
notes: ['all figures reference solver output tc-001'],
|
||||
},
|
||||
],
|
||||
skill_version: '1.0.0',
|
||||
generated_at: T1,
|
||||
},
|
||||
},
|
||||
validation_result: {
|
||||
pass: {
|
||||
proposal_digest: REF_B,
|
||||
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
|
||||
ok: true,
|
||||
violations: [],
|
||||
rules_evaluated: ['bid-format', 'price-limits', 'ledger-consistency', 'lineage-integrity'],
|
||||
checked_at: T1,
|
||||
},
|
||||
'reject-price': {
|
||||
proposal_digest: REF_B,
|
||||
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
|
||||
ok: false,
|
||||
violations: [{ rule_id: 'price-limits', severity: 'REJECT', message: 'price 1500.00 above cap' }],
|
||||
rules_evaluated: ['bid-format', 'price-limits'],
|
||||
checked_at: T1,
|
||||
},
|
||||
},
|
||||
simulation_result: {
|
||||
'revenue-ok': {
|
||||
proposal_digest: REF_B,
|
||||
kind: 'REVENUE_SCENARIOS',
|
||||
alert: false,
|
||||
alerts: [],
|
||||
metrics: { revenue_p05_yuan: '410200.00', revenue_p50_yuan: '510600.00', worst_loss_yuan: '0.00' },
|
||||
scenario_count: 1000,
|
||||
simulator: { name: 'revenue-scenarios', version: '1.0.0', seed: 20260315 },
|
||||
simulated_at: T1,
|
||||
},
|
||||
},
|
||||
envelope_match: {
|
||||
within: {
|
||||
proposal_digest: REF_B,
|
||||
within: true,
|
||||
envelope_id: 'env-bid-001',
|
||||
required_level: 'L1',
|
||||
reasons: [],
|
||||
checked_at: T1,
|
||||
},
|
||||
outside: {
|
||||
proposal_digest: REF_B,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: 'L2',
|
||||
reasons: ['no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01'],
|
||||
checked_at: T1,
|
||||
},
|
||||
},
|
||||
stale_denial: {
|
||||
'ledger-moved': {
|
||||
proposal_digest: REF_B,
|
||||
reasons: ['ledger version 42 at approval, now 43'],
|
||||
checked_at: T2,
|
||||
},
|
||||
},
|
||||
execution_receipt: {
|
||||
'file-export': {
|
||||
receipt_id: 'rcpt-001',
|
||||
proposal_digest: REF_B,
|
||||
permit_id: 'permit-001',
|
||||
channel: 'FILE_EXPORT',
|
||||
idempotency_key: `${REF_B}:permit-001`,
|
||||
artifact_ref: 'exports/bid-2026-03-15-prop-001.json',
|
||||
accepted_at: T1,
|
||||
},
|
||||
},
|
||||
bid_proposal_draft: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: 'tc-001', path: 'expected_revenue_yuan' },
|
||||
rationale: 'Evening peak priced above P50; allocate bounded energy to 18:00–20:00.',
|
||||
},
|
||||
},
|
||||
router_decision: {
|
||||
'day-ahead-bid': {
|
||||
workflow_id: 'day-ahead-bid',
|
||||
params: { market_date: DATE },
|
||||
confidence: 'HIGH',
|
||||
},
|
||||
},
|
||||
bid_export_file: {
|
||||
basic: {
|
||||
format_version: '1.0.0',
|
||||
proposal_id: 'prop-001',
|
||||
proposal_digest: REF_B,
|
||||
permit_id: 'permit-001',
|
||||
proposal_type: 'BID',
|
||||
market_date: DATE,
|
||||
prices_yuan_per_mwh: curve('0.00'),
|
||||
quantities_mwh: curve('12.5'),
|
||||
exported_at: T1,
|
||||
},
|
||||
},
|
||||
situation_report: {
|
||||
'normal-day': {
|
||||
id: 'sit-001',
|
||||
@ -412,36 +219,6 @@ const invalid: Record<string, Record<string, unknown>> = {
|
||||
// digest not sha256 hex
|
||||
'bad-digest': { ...(valid['approval']!['approve'] as object), proposal_digest: 'not-a-digest' },
|
||||
},
|
||||
skill_report: {
|
||||
// a metric without a lineage reference — the one thing a report may never contain (I1)
|
||||
'metric-without-ref': (() => {
|
||||
const o = structuredClone(valid['skill_report']!['da-bid-summary']) as any
|
||||
delete o.sections[0].metrics[0].ref
|
||||
return o
|
||||
})(),
|
||||
},
|
||||
bid_optimization_result: {
|
||||
// solver status outside the enum
|
||||
'bad-solver-status': (() => {
|
||||
const o = structuredClone(valid['bid_optimization_result']!['da-basic']) as any
|
||||
o.solver.status = 'SOLVED'
|
||||
return o
|
||||
})(),
|
||||
},
|
||||
bid_proposal_draft: {
|
||||
// a literal number where only a lineage reference is allowed (I1/P2)
|
||||
'literal-number': {
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
|
||||
expected_revenue_ref: '510600.00',
|
||||
rationale: 'x',
|
||||
},
|
||||
},
|
||||
router_decision: {
|
||||
// a workflow id that is not a registered template
|
||||
'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' },
|
||||
},
|
||||
position_update: {
|
||||
// missing optimistic-concurrency field
|
||||
'missing-version': (() => {
|
||||
|
||||
@ -3,7 +3,6 @@ import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
|
||||
import { ProposalType } from './proposal.js'
|
||||
|
||||
export const ApprovalLevel = z.enum(['L1', 'L2', 'L3'])
|
||||
export type ApprovalLevel = z.infer<typeof ApprovalLevel>
|
||||
|
||||
/** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */
|
||||
export const Approval = z.object({
|
||||
|
||||
@ -2,7 +2,6 @@ import { z } from 'zod'
|
||||
import { Id, IsoUtc } from './common.js'
|
||||
|
||||
export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS'])
|
||||
export type CaseKind = z.infer<typeof CaseKind>
|
||||
|
||||
/**
|
||||
* Operator's unit of accountability (docs/02 §5, ADR-0008): one objective,
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
import { ApprovalLevel } from './approval.js'
|
||||
import { ProposalType } from './proposal.js'
|
||||
|
||||
/**
|
||||
* Safety-chain objects (docs/03, docs/11 §2): the outputs of rule check,
|
||||
* simulation, envelope gate and gateway. All are snapshotted into lineage so a
|
||||
* decision can be replayed (I7).
|
||||
*/
|
||||
|
||||
export const PolicyPackRef = z.object({
|
||||
id: Id,
|
||||
version: z.string().min(1),
|
||||
})
|
||||
export type PolicyPackRef = z.infer<typeof PolicyPackRef>
|
||||
|
||||
export const RuleViolation = z.object({
|
||||
rule_id: Id,
|
||||
severity: z.enum(['REJECT', 'WARN']),
|
||||
message: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ValidationResult = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
policy_pack: PolicyPackRef,
|
||||
ok: z.boolean(),
|
||||
violations: z.array(RuleViolation),
|
||||
rules_evaluated: z.array(Id),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type ValidationResult = z.infer<typeof ValidationResult>
|
||||
|
||||
/** Revenue scenario backtest (bids) or power-balance sim (control) — docs/03 §2. */
|
||||
export const SimulationResult = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
kind: z.enum(['REVENUE_SCENARIOS', 'POWER_BALANCE']),
|
||||
/** Any alert escalates to a human — one vote, no exceptions (docs/03 §2). */
|
||||
alert: z.boolean(),
|
||||
alerts: z.array(z.string()),
|
||||
metrics: z.record(z.string(), DecimalString),
|
||||
scenario_count: z.int().nonnegative(),
|
||||
simulator: z.object({ name: z.string().min(1), version: z.string().min(1), seed: z.int() }),
|
||||
simulated_at: IsoUtc,
|
||||
})
|
||||
export type SimulationResult = z.infer<typeof SimulationResult>
|
||||
|
||||
export const EnvelopeMatch = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
within: z.boolean(),
|
||||
envelope_id: Id.nullable(),
|
||||
required_level: ApprovalLevel,
|
||||
reasons: z.array(z.string()),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type EnvelopeMatch = z.infer<typeof EnvelopeMatch>
|
||||
|
||||
export const StaleDenial = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
reasons: z.array(z.string().min(1)).min(1),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type StaleDenial = z.infer<typeof StaleDenial>
|
||||
|
||||
/** Idempotent effect receipt (I5). Same (digest, permit) → same receipt. */
|
||||
export const ExecutionReceipt = z.object({
|
||||
receipt_id: Id,
|
||||
proposal_digest: SnapshotRef,
|
||||
permit_id: Id,
|
||||
channel: z.enum(['FILE_EXPORT', 'TRADING_PLATFORM_API', 'SIMULATION_GATEWAY']),
|
||||
idempotency_key: z.string().min(1),
|
||||
artifact_ref: z.string().min(1),
|
||||
accepted_at: IsoUtc,
|
||||
})
|
||||
export type ExecutionReceipt = z.infer<typeof ExecutionReceipt>
|
||||
|
||||
/** Reference into recorded tool-call lineage (I1/P2): the only way a number enters a payload. */
|
||||
export const LineageRef = z.object({
|
||||
tool_call_id: Id,
|
||||
path: z.string().min(1),
|
||||
})
|
||||
export type LineageRef = z.infer<typeof LineageRef>
|
||||
|
||||
/**
|
||||
* What the trading agent's LLM step is allowed to emit (docs/09 §3): refs and
|
||||
* prose, never numbers. The assembler dereferences refs into a BidPayload.
|
||||
*/
|
||||
export const BidProposalDraft = z.object({
|
||||
market_date: MarketDate,
|
||||
prices_ref: LineageRef,
|
||||
quantities_ref: LineageRef,
|
||||
expected_revenue_ref: LineageRef,
|
||||
rationale: z.string().min(1),
|
||||
})
|
||||
export type BidProposalDraft = z.infer<typeof BidProposalDraft>
|
||||
|
||||
/** Router agent output (docs/09 §1): choose a registered template, never invent one. */
|
||||
export const WorkflowTemplateId = z.enum(['day-ahead-situation', 'day-ahead-bid', 'adhoc-analysis'])
|
||||
|
||||
export const RouterDecision = z.object({
|
||||
workflow_id: WorkflowTemplateId,
|
||||
params: z.object({ market_date: MarketDate }),
|
||||
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
|
||||
})
|
||||
export type RouterDecision = z.infer<typeof RouterDecision>
|
||||
|
||||
/** Bid file handed to the manual-upload channel (docs/06 degraded channel; M3 release path). */
|
||||
export const BidExportFile = z.object({
|
||||
format_version: z.literal('1.0.0'),
|
||||
proposal_id: Id,
|
||||
proposal_digest: SnapshotRef,
|
||||
permit_id: Id,
|
||||
proposal_type: ProposalType,
|
||||
market_date: MarketDate,
|
||||
prices_yuan_per_mwh: Curve96,
|
||||
quantities_mwh: Curve96,
|
||||
exported_at: IsoUtc,
|
||||
})
|
||||
export type BidExportFile = z.infer<typeof BidExportFile>
|
||||
@ -5,12 +5,9 @@ import { Curve96, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
* Forecast output contract (docs/05 §2.1): quantile intervals are mandatory —
|
||||
* bid risk assessment depends on calibrated bands, not point values.
|
||||
*/
|
||||
export const ForecastKind = z.enum(['LOAD', 'PV', 'PRICE'])
|
||||
export type ForecastKind = z.infer<typeof ForecastKind>
|
||||
|
||||
export const ForecastBundle = z.object({
|
||||
id: Id,
|
||||
kind: ForecastKind,
|
||||
kind: z.enum(['LOAD', 'PV', 'PRICE']),
|
||||
market_date: MarketDate,
|
||||
unit: z.enum(['mw', 'yuan_per_mwh']),
|
||||
quantiles: z.object({
|
||||
|
||||
@ -8,23 +8,6 @@ import { LedgerView, PositionUpdate } from './ledger.js'
|
||||
import { Proposal } from './proposal.js'
|
||||
import { ResourceProfile } from './resource.js'
|
||||
import { SituationReport } from './situation.js'
|
||||
import {
|
||||
BidOptimizationRequest,
|
||||
BidOptimizationResult,
|
||||
ForecastRequest,
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from './skill.js'
|
||||
import {
|
||||
BidExportFile,
|
||||
BidProposalDraft,
|
||||
EnvelopeMatch,
|
||||
ExecutionReceipt,
|
||||
RouterDecision,
|
||||
SimulationResult,
|
||||
StaleDenial,
|
||||
ValidationResult,
|
||||
} from './chain.js'
|
||||
|
||||
export * from './common.js'
|
||||
export * from './proposal.js'
|
||||
@ -36,8 +19,6 @@ export * from './situation.js'
|
||||
export * from './resource.js'
|
||||
export * from './case.js'
|
||||
export * from './event.js'
|
||||
export * from './skill.js'
|
||||
export * from './chain.js'
|
||||
|
||||
/**
|
||||
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
||||
@ -56,17 +37,4 @@ export const schemaRegistry: Record<string, z.ZodType> = {
|
||||
resource_profile: ResourceProfile,
|
||||
decision_case: DecisionCase,
|
||||
event_envelope: EventEnvelope,
|
||||
forecast_request: ForecastRequest,
|
||||
bid_optimization_request: BidOptimizationRequest,
|
||||
bid_optimization_result: BidOptimizationResult,
|
||||
report_request: ReportRequest,
|
||||
skill_report: SkillReport,
|
||||
validation_result: ValidationResult,
|
||||
simulation_result: SimulationResult,
|
||||
envelope_match: EnvelopeMatch,
|
||||
stale_denial: StaleDenial,
|
||||
execution_receipt: ExecutionReceipt,
|
||||
bid_proposal_draft: BidProposalDraft,
|
||||
router_decision: RouterDecision,
|
||||
bid_export_file: BidExportFile,
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@ export const ProposalStatus = z.enum([
|
||||
'ROLLED_BACK',
|
||||
'REJECTED',
|
||||
])
|
||||
export type ProposalStatus = z.infer<typeof ProposalStatus>
|
||||
|
||||
export const ToolCallRef = z.object({
|
||||
tool_call_id: Id,
|
||||
@ -33,7 +32,6 @@ export const ToolCallRef = z.object({
|
||||
inputs_ref: SnapshotRef,
|
||||
outputs_ref: SnapshotRef,
|
||||
})
|
||||
export type ToolCallRef = z.infer<typeof ToolCallRef>
|
||||
|
||||
/**
|
||||
* Provenance attached to every proposal (docs/03 §1). Rule check enforces that
|
||||
@ -45,7 +43,6 @@ export const Lineage = z.object({
|
||||
ledger_version: z.int().nonnegative(),
|
||||
policy_pack_version: z.string().min(1),
|
||||
})
|
||||
export type Lineage = z.infer<typeof Lineage>
|
||||
|
||||
/** Day-ahead bid: per-interval price/quantity pairs. */
|
||||
export const BidPayload = z.object({
|
||||
@ -55,7 +52,6 @@ export const BidPayload = z.object({
|
||||
quantities_mwh: Curve96,
|
||||
expected_revenue_yuan: DecimalString,
|
||||
})
|
||||
export type BidPayload = z.infer<typeof BidPayload>
|
||||
|
||||
const ProposalBase = z.object({
|
||||
id: Id,
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
import { ForecastBundle, ForecastKind } from './forecast.js'
|
||||
|
||||
/**
|
||||
* Skill I/O contracts (docs/05 §2, docs/11 §3). These cross the TS↔Python
|
||||
* boundary: the runtime's registerSkill wrapper validates them outbound, the
|
||||
* FastAPI service validates them inbound with the generated pydantic models.
|
||||
* Skills are stateless — everything they need is in the request; everything
|
||||
* lineage needs is in the response.
|
||||
*/
|
||||
|
||||
export const SkillVersion = z.string().regex(/^\d+\.\d+\.\d+$/)
|
||||
|
||||
/** Forecast skills (load / PV / price): history in, quantile bundle out. */
|
||||
export const ForecastRequest = z.object({
|
||||
kind: ForecastKind,
|
||||
market_date: MarketDate,
|
||||
unit: z.enum(['mw', 'yuan_per_mwh']),
|
||||
/** Actuals for past market dates, ascending, all strictly before market_date. */
|
||||
history: z.array(Curve96).min(1),
|
||||
/** Exogenous forecasts for market_date keyed by name (e.g. irradiance_w_per_m2). */
|
||||
exogenous: z.record(z.string(), Curve96),
|
||||
/** Caller's snapshot of the assembled feature set; echoed into ForecastBundle. */
|
||||
features_snapshot_ref: SnapshotRef,
|
||||
})
|
||||
export type ForecastRequest = z.infer<typeof ForecastRequest>
|
||||
|
||||
/**
|
||||
* Daily energy bounds derived from the position ledger by the TS side
|
||||
* (LedgerService.dayAheadBounds — the P7 cascade lives in one place). The
|
||||
* optimizer treats them as hard constraints and echoes them so the rule check
|
||||
* can verify the bid against the same ledger version.
|
||||
*/
|
||||
export const PositionBounds = z.object({
|
||||
ledger_version: z.int().nonnegative(),
|
||||
daily_energy_min_mwh: DecimalString,
|
||||
daily_energy_max_mwh: DecimalString,
|
||||
})
|
||||
export type PositionBounds = z.infer<typeof PositionBounds>
|
||||
|
||||
export const BidRiskParams = z.object({
|
||||
/** 0 = maximise P50 revenue, 1 = maximise P10 (floor) revenue. */
|
||||
risk_aversion: DecimalString,
|
||||
/** Commitment buffer k: sellable share of certified capacity. OPEN-QUESTION B6. */
|
||||
commitment_buffer_k: DecimalString,
|
||||
/** Smallest non-zero interval quantity the market accepts. */
|
||||
min_block_mwh: DecimalString,
|
||||
/**
|
||||
* Marginal cost of delivering flexibility (user compensation, degradation),
|
||||
* used as the offer-price floor: a seller in a uniform-price market offers
|
||||
* at cost and lets the forecast allocate quantity. OPEN-QUESTION A6.
|
||||
*/
|
||||
marginal_cost_yuan_per_mwh: DecimalString,
|
||||
})
|
||||
export type BidRiskParams = z.infer<typeof BidRiskParams>
|
||||
|
||||
export const BidOptimizationRequest = z.object({
|
||||
market_date: MarketDate,
|
||||
price_forecast: ForecastBundle,
|
||||
/** Certified adjustable (sellable) capacity per interval, MW. */
|
||||
adjustable_capacity_mw: Curve96,
|
||||
position_bounds: PositionBounds,
|
||||
risk: BidRiskParams,
|
||||
})
|
||||
export type BidOptimizationRequest = z.infer<typeof BidOptimizationRequest>
|
||||
|
||||
export const SolverStatus = z.enum(['OPTIMAL', 'INFEASIBLE', 'TIME_LIMIT', 'ERROR'])
|
||||
|
||||
export const BidOptimizationResult = z.object({
|
||||
market_date: MarketDate,
|
||||
prices_yuan_per_mwh: Curve96,
|
||||
quantities_mwh: Curve96,
|
||||
daily_energy_mwh: DecimalString,
|
||||
/** Revenue at the P50 price path, given the bid clears where offer ≤ price. */
|
||||
expected_revenue_yuan: DecimalString,
|
||||
revenue_distribution_yuan: z.object({
|
||||
p10: DecimalString,
|
||||
p50: DecimalString,
|
||||
p90: DecimalString,
|
||||
}),
|
||||
position_bounds: PositionBounds,
|
||||
solver: z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
status: SolverStatus,
|
||||
objective_value: DecimalString.nullable(),
|
||||
wall_time_ms: z.int().nonnegative(),
|
||||
}),
|
||||
binding_constraints: z.array(z.string()),
|
||||
skill_version: SkillVersion,
|
||||
})
|
||||
export type BidOptimizationResult = z.infer<typeof BidOptimizationResult>
|
||||
|
||||
/** A number in a report is never typed in: it is a {tool_call_id, path} reference (I1/P2). */
|
||||
export const MetricRef = z.object({
|
||||
tool_call_id: Id,
|
||||
/** JSON-pointer-like dotted path into that tool call's output. */
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ReportSource = z.object({
|
||||
tool_call_id: Id,
|
||||
tool: Id,
|
||||
version: SkillVersion,
|
||||
output: z.unknown(),
|
||||
})
|
||||
|
||||
export const ReportKind = z.enum(['DAY_AHEAD_BID_SUMMARY', 'FORECAST_EVAL', 'BID_BACKTEST'])
|
||||
|
||||
export const ReportRequest = z.object({
|
||||
kind: ReportKind,
|
||||
market_date: MarketDate,
|
||||
sources: z.array(ReportSource).min(1),
|
||||
})
|
||||
export type ReportRequest = z.infer<typeof ReportRequest>
|
||||
|
||||
export const SkillReport = z.object({
|
||||
id: Id,
|
||||
kind: ReportKind,
|
||||
market_date: MarketDate,
|
||||
sections: z.array(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
metrics: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
value: DecimalString,
|
||||
unit: z.string().min(1),
|
||||
ref: MetricRef,
|
||||
}),
|
||||
),
|
||||
notes: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
skill_version: SkillVersion,
|
||||
generated_at: IsoUtc,
|
||||
})
|
||||
export type SkillReport = z.infer<typeof SkillReport>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@vpp/evals",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"eval": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vpp/domain": "*",
|
||||
"@vpp/services": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
{
|
||||
"layer": "L2",
|
||||
"dataset": {
|
||||
"name": "synthetic-hubei-v0-2026-01-01-120d",
|
||||
"sha256": "3aa36eb46906233b1b311ff03a33e7d663f30ae921fc9a1f3f813ab5205c270a",
|
||||
"synthetic": true
|
||||
},
|
||||
"skill_versions": {
|
||||
"load-forecast": "1.0.0",
|
||||
"pv-forecast": "1.0.0",
|
||||
"price-forecast": "1.0.0",
|
||||
"bid-optimization-milp": "1.0.0",
|
||||
"report-generator": "1.0.0"
|
||||
},
|
||||
"config": {
|
||||
"window": 28,
|
||||
"holdoutFrom": 60,
|
||||
"holdoutDays": 0,
|
||||
"risk": {
|
||||
"risk_aversion": "0.3",
|
||||
"commitment_buffer_k": "0.9",
|
||||
"min_block_mwh": "0.5",
|
||||
"marginal_cost_yuan_per_mwh": "0"
|
||||
},
|
||||
"contractShareOfSellable": "0.6",
|
||||
"daMonthlyDeviationBand": "0.05"
|
||||
},
|
||||
"metrics": {
|
||||
"load": {
|
||||
"days": 60,
|
||||
"mape": 0.041,
|
||||
"nrmse": null,
|
||||
"coverage_p10_p90": 0.821007,
|
||||
"direction_accuracy": null
|
||||
},
|
||||
"pv": {
|
||||
"days": 60,
|
||||
"mape": null,
|
||||
"nrmse": 0.040938,
|
||||
"coverage_p10_p90": 0.772109,
|
||||
"direction_accuracy": null
|
||||
},
|
||||
"price": {
|
||||
"days": 60,
|
||||
"mape": 0.044013,
|
||||
"nrmse": null,
|
||||
"coverage_p10_p90": 0.81059,
|
||||
"direction_accuracy": 0.962142
|
||||
},
|
||||
"bid": {
|
||||
"days": 60,
|
||||
"optimal_days": 60,
|
||||
"ledger_accepted_days": 60,
|
||||
"revenue_skill_yuan": 7146184.79,
|
||||
"revenue_naive_yuan": 6012053.56,
|
||||
"revenue_hindsight_yuan": 7154899.25,
|
||||
"capture_ratio": 0.998782,
|
||||
"uplift_vs_naive": 1.188643
|
||||
}
|
||||
},
|
||||
"digest": "4018e32ab695422c613e211aba1392164f3bb9c30076986fdfda220fc9bba047"
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
/**
|
||||
* `npm run eval -w @vpp/evals -- [--base-url URL] [--dataset PATH] [--check] [--write-baseline]`
|
||||
*
|
||||
* Runs the L2 harness against a live skill service, archives the run under
|
||||
* reports/runs/ (gitignored — docs/12 §5 says archived runs live in object
|
||||
* storage), and either compares it to the committed baseline (--check: exit 1
|
||||
* on any metric change) or rewrites the baseline (--write-baseline, a
|
||||
* reviewed change — docs/12 §3 gates a Skill upgrade on its L2 metrics).
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { HttpSkillClient } from '@vpp/services'
|
||||
import { loadDataset } from './dataset.js'
|
||||
import { DEFAULT_CONFIG, runL2, stableJson } from './harness.js'
|
||||
import type { EvalRun } from './harness.js'
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url))
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string) => args.includes(name)
|
||||
const opt = (name: string, dflt: string) => {
|
||||
const i = args.indexOf(name)
|
||||
return i >= 0 && args[i + 1] ? args[i + 1]! : dflt
|
||||
}
|
||||
|
||||
const baseUrl = opt('--base-url', process.env['SKILLS_BASE_URL'] ?? 'http://127.0.0.1:8000')
|
||||
const datasetPath = opt('--dataset', `${here}../datasets/synthetic-hubei-v0.json`)
|
||||
const baselinePath = `${here}../reports/baselines/l2-synthetic-hubei-v0.json`
|
||||
const runsDir = `${here}../reports/runs/`
|
||||
|
||||
const { dataset, sha256 } = loadDataset(datasetPath)
|
||||
const run = await runL2(dataset, sha256, new HttpSkillClient(baseUrl), DEFAULT_CONFIG)
|
||||
|
||||
mkdirSync(runsDir, { recursive: true })
|
||||
writeFileSync(`${runsDir}${run.id}.json`, JSON.stringify(run, null, 2) + '\n')
|
||||
|
||||
const m = run.metrics
|
||||
console.log(`L2 eval ${run.id} on ${run.dataset.name}${run.dataset.synthetic ? ' (SYNTHETIC)' : ''}`)
|
||||
console.log(` load MAPE ${m.load.mape} coverage ${m.load.coverage_p10_p90}`)
|
||||
console.log(` pv nRMSE ${m.pv.nrmse} coverage(daylight) ${m.pv.coverage_p10_p90}`)
|
||||
console.log(` price MAPE ${m.price.mape} coverage ${m.price.coverage_p10_p90} direction ${m.price.direction_accuracy}`)
|
||||
console.log(
|
||||
` bid optimal ${m.bid.optimal_days}/${m.bid.days} ledger-accepted ${m.bid.ledger_accepted_days}/${m.bid.days}` +
|
||||
` capture ${m.bid.capture_ratio} uplift-vs-naive ${m.bid.uplift_vs_naive}`,
|
||||
)
|
||||
console.log(` digest ${run.digest}`)
|
||||
|
||||
/** Baseline = the run minus its per-run identity. */
|
||||
const baselineOf = (r: EvalRun) => {
|
||||
const { id: _id, run_at: _at, per_day: _pd, ...rest } = r
|
||||
return rest
|
||||
}
|
||||
|
||||
if (flag('--write-baseline')) {
|
||||
mkdirSync(`${here}../reports/baselines/`, { recursive: true })
|
||||
writeFileSync(baselinePath, JSON.stringify(baselineOf(run), null, 2) + '\n')
|
||||
console.log(`baseline written: ${baselinePath}`)
|
||||
}
|
||||
|
||||
if (flag('--check')) {
|
||||
if (!existsSync(baselinePath)) {
|
||||
console.error(`no baseline at ${baselinePath}; run with --write-baseline first`)
|
||||
process.exit(2)
|
||||
}
|
||||
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')) as ReturnType<typeof baselineOf>
|
||||
if (baseline.digest === run.digest) {
|
||||
console.log('baseline check: identical digest — reproducible')
|
||||
} else {
|
||||
console.error('baseline check FAILED: run differs from committed baseline')
|
||||
const a = stableJson(baseline.metrics)
|
||||
const b = stableJson(run.metrics)
|
||||
console.error(` baseline metrics: ${a}`)
|
||||
console.error(` this run metrics: ${b}`)
|
||||
for (const key of ['dataset', 'skill_versions', 'config'] as const) {
|
||||
if (stableJson(baseline[key]) !== stableJson(run[key])) console.error(` ${key} differs`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Curve96 } from '@vpp/domain'
|
||||
|
||||
/**
|
||||
* Replay dataset shape (docs/12 §2 历史重放集). Today the only instance is the
|
||||
* SYNTHETIC placeholder written by `python -m vpp_skills.synthetic`; a real
|
||||
* replay set produced from the event log will have the same shape and a
|
||||
* `meta.synthetic: false`.
|
||||
*/
|
||||
export interface DatasetDay {
|
||||
date: string
|
||||
weekend: boolean
|
||||
load_mw: string[]
|
||||
pv_mw: string[]
|
||||
price_yuan_per_mwh: string[]
|
||||
adjustable_capacity_mw: string[]
|
||||
}
|
||||
|
||||
export interface Dataset {
|
||||
meta: {
|
||||
name: string
|
||||
synthetic: boolean
|
||||
note: string
|
||||
generator: string
|
||||
generator_version: string
|
||||
seed: number
|
||||
interval_minutes: 15
|
||||
peak_load_mw: string
|
||||
pv_capacity_mw: string
|
||||
adjustable_capacity_mw: string
|
||||
}
|
||||
days: DatasetDay[]
|
||||
}
|
||||
|
||||
export interface LoadedDataset {
|
||||
dataset: Dataset
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export function loadDataset(path: string): LoadedDataset {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
return { dataset: JSON.parse(raw) as Dataset, sha256: createHash('sha256').update(raw).digest('hex') }
|
||||
}
|
||||
|
||||
export const toCurve = (values: string[], date: string): Curve96 => ({
|
||||
interval_minutes: 15,
|
||||
date,
|
||||
values,
|
||||
})
|
||||
|
||||
export const nums = (values: string[]): number[] => values.map(Number)
|
||||
@ -1,307 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain'
|
||||
import { Decimal, LedgerService } from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
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 }
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './metrics.js'
|
||||
export * from './dataset.js'
|
||||
export { HttpSkillClient, SkillHttpError } from '@vpp/services'
|
||||
export type { SkillClient } from '@vpp/services'
|
||||
export * from './harness.js'
|
||||
@ -1,103 +0,0 @@
|
||||
/**
|
||||
* L2 skill metrics (docs/12 §1 L2, §4 口径). Pure functions over plain numbers;
|
||||
* the harness converts decimal strings at the boundary and quantizes results
|
||||
* when it writes a report. KPI thresholds (≤ 8% etc.) are judged on real data
|
||||
* and are OPEN-QUESTION C2 in their exact statistical level — nothing here
|
||||
* hardcodes a pass/fail number.
|
||||
*/
|
||||
|
||||
export const mean = (xs: number[]): number =>
|
||||
xs.length === 0 ? Number.NaN : xs.reduce((a, b) => a + b, 0) / xs.length
|
||||
|
||||
/** Mean absolute percentage error over intervals where actual ≠ 0. */
|
||||
export function mape(actual: number[], pred: number[]): number {
|
||||
const terms: number[] = []
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
const a = actual[i]!
|
||||
if (a !== 0) terms.push(Math.abs((pred[i]! - a) / a))
|
||||
}
|
||||
return mean(terms)
|
||||
}
|
||||
|
||||
/** RMSE normalised by installed capacity — the PV metric docs/12 §4 suggests. */
|
||||
export function nrmse(actual: number[], pred: number[], capacity: number): number {
|
||||
const se = actual.map((a, i) => (pred[i]! - a) ** 2)
|
||||
return Math.sqrt(mean(se)) / capacity
|
||||
}
|
||||
|
||||
/** Share of actuals inside [lower, upper]; nominal for a P10–P90 band is 0.80. */
|
||||
export function coverage(actual: number[], lower: number[], upper: number[]): number {
|
||||
let hits = 0
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
if (actual[i]! >= lower[i]! && actual[i]! <= upper[i]!) hits++
|
||||
}
|
||||
return hits / actual.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction accuracy (docs/12 L2 电价 方向准确率): share of interval pairs whose
|
||||
* high/low ordering the forecast gets right. Bid optimisation depends on the
|
||||
* ranking of intervals far more than on absolute price level.
|
||||
*/
|
||||
export function directionAccuracy(actual: number[], pred: number[]): number {
|
||||
let agree = 0
|
||||
let pairs = 0
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
for (let j = i + 1; j < actual.length; j++) {
|
||||
const da = actual[j]! - actual[i]!
|
||||
const dp = pred[j]! - pred[i]!
|
||||
if (da === 0 && dp === 0) continue
|
||||
pairs++
|
||||
if (Math.sign(da) === Math.sign(dp)) agree++
|
||||
}
|
||||
}
|
||||
return pairs === 0 ? Number.NaN : agree / pairs
|
||||
}
|
||||
|
||||
export interface Bid {
|
||||
offers: number[]
|
||||
quantities: number[]
|
||||
}
|
||||
|
||||
/** Uniform-price clearing: an offer clears where it does not exceed the realised price. */
|
||||
export function realisedRevenue(bid: Bid, actualPrice: number[]): number {
|
||||
let total = 0
|
||||
for (let t = 0; t < actualPrice.length; t++) {
|
||||
if (bid.offers[t]! <= actualPrice[t]!) total += actualPrice[t]! * bid.quantities[t]!
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-bound baseline: a price-taker offering a flat profile that meets the
|
||||
* upper energy bound (or as much as capacity allows), pro-rata to capacity.
|
||||
*/
|
||||
export function naiveBid(capMwh: number[], energyMax: number): Bid {
|
||||
const sellable = capMwh.reduce((a, b) => a + b, 0)
|
||||
const scale = sellable === 0 ? 0 : Math.min(1, energyMax / sellable)
|
||||
return { offers: capMwh.map(() => 0), quantities: capMwh.map((c) => c * scale) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper-bound baseline: perfect hindsight — fill the highest-priced intervals
|
||||
* first up to the energy bound, honouring per-interval capacity and block
|
||||
* size. Offers at zero so everything clears.
|
||||
*/
|
||||
export function hindsightBid(
|
||||
actualPrice: number[],
|
||||
capMwh: number[],
|
||||
energyMax: number,
|
||||
minBlock: number,
|
||||
): Bid {
|
||||
const order = actualPrice.map((_, i) => i).sort((a, b) => actualPrice[b]! - actualPrice[a]!)
|
||||
const quantities = capMwh.map(() => 0)
|
||||
let room = energyMax
|
||||
for (const t of order) {
|
||||
const q = Math.min(capMwh[t]!, room)
|
||||
if (q < minBlock) continue
|
||||
quantities[t] = q
|
||||
room -= q
|
||||
if (room <= 0) break
|
||||
}
|
||||
return { offers: capMwh.map(() => 0), quantities }
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
BidOptimizationRequest,
|
||||
BidOptimizationResult,
|
||||
ForecastBundle,
|
||||
ForecastKind,
|
||||
ForecastRequest,
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from '@vpp/domain'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import { loadDataset } from '../src/dataset.js'
|
||||
import { DEFAULT_CONFIG, runL2 } from '../src/harness.js'
|
||||
|
||||
const datasetPath = fileURLToPath(new URL('../datasets/synthetic-hubei-v0.json', import.meta.url))
|
||||
|
||||
/**
|
||||
* Stub skills with trivially checkable behaviour: forecast = last history
|
||||
* day ± 10%; bid = flat quantity hitting the lower energy bound at offer 0.
|
||||
* Lets the harness itself be tested without the Python service running.
|
||||
*/
|
||||
class StubClient implements SkillClient {
|
||||
calls = 0
|
||||
async skills() {
|
||||
return [
|
||||
{ id: 'load-forecast', version: '0.0.1', endpoint: '/stub' },
|
||||
{ id: 'bid-optimization-milp', version: '0.0.1', endpoint: '/stub' },
|
||||
]
|
||||
}
|
||||
async forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle> {
|
||||
this.calls++
|
||||
const last = req.history[req.history.length - 1]!
|
||||
const scaled = (f: number) => ({
|
||||
interval_minutes: 15 as const,
|
||||
date: req.market_date,
|
||||
values: last.values.map((v) => (Number(v) * f).toFixed(3)),
|
||||
})
|
||||
return {
|
||||
id: `stub-${kind}`,
|
||||
kind,
|
||||
market_date: req.market_date,
|
||||
unit: req.unit,
|
||||
quantiles: { p10: scaled(0.9), p50: scaled(1), p90: scaled(1.1) },
|
||||
model: { name: 'stub', version: '0.0.1' },
|
||||
features_snapshot_ref: req.features_snapshot_ref,
|
||||
generated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
}
|
||||
async optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult> {
|
||||
this.calls++
|
||||
const per = (Number(req.position_bounds.daily_energy_min_mwh) / 96).toFixed(3)
|
||||
const flat = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] }
|
||||
const energy = (Number(per) * 96).toFixed(3)
|
||||
return {
|
||||
market_date: req.market_date,
|
||||
prices_yuan_per_mwh: { ...flat, values: Array(96).fill('0.00') },
|
||||
quantities_mwh: flat,
|
||||
daily_energy_mwh: energy,
|
||||
expected_revenue_yuan: '0.00',
|
||||
revenue_distribution_yuan: { p10: '0.00', p50: '0.00', p90: '0.00' },
|
||||
position_bounds: req.position_bounds,
|
||||
solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: '0.00', wall_time_ms: 0 },
|
||||
binding_constraints: ['daily_energy_min'],
|
||||
skill_version: '0.0.1',
|
||||
}
|
||||
}
|
||||
async report(_req: ReportRequest): Promise<SkillReport> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULT_CONFIG, window: 7, holdoutFrom: 10, holdoutDays: 5 }
|
||||
|
||||
describe('L2 harness', () => {
|
||||
it('scores every held-out day and pushes each bid through the real ledger', async () => {
|
||||
const { dataset, sha256 } = loadDataset(datasetPath)
|
||||
const client = new StubClient()
|
||||
const run = await runL2(dataset, sha256, client, cfg, () => '2026-09-01T00:00:00Z')
|
||||
expect(run.per_day).toHaveLength(5)
|
||||
expect(client.calls).toBe(5 * 4)
|
||||
expect(run.metrics.bid.optimal_days).toBe(5)
|
||||
expect(run.metrics.bid.ledger_accepted_days).toBe(5) // stub bids sit exactly on the lower bound
|
||||
expect(run.metrics.load.coverage_p10_p90).toBeGreaterThan(0)
|
||||
expect(run.metrics.price.direction_accuracy).toBeGreaterThan(0.5) // yesterday's shape is informative
|
||||
expect(run.metrics.bid.revenue_hindsight_yuan).toBeGreaterThanOrEqual(run.metrics.bid.revenue_skill_yuan)
|
||||
expect(run.metrics.bid.revenue_hindsight_yuan).toBeGreaterThanOrEqual(run.metrics.bid.revenue_naive_yuan)
|
||||
expect(run.skill_versions['load-forecast']).toBe('0.0.1')
|
||||
expect(run.dataset.synthetic).toBe(true)
|
||||
})
|
||||
|
||||
it('is reproducible: same inputs → same digest, independent of run time', async () => {
|
||||
const { dataset, sha256 } = loadDataset(datasetPath)
|
||||
const a = await runL2(dataset, sha256, new StubClient(), cfg, () => '2026-09-01T00:00:00Z')
|
||||
const b = await runL2(dataset, sha256, new StubClient(), cfg, () => '2026-09-02T00:00:00Z')
|
||||
expect(a.digest).toBe(b.digest)
|
||||
expect(a.id).not.toBe(b.id)
|
||||
})
|
||||
|
||||
it('rejects a holdout that starts before the window is filled', async () => {
|
||||
const { dataset, sha256 } = loadDataset(datasetPath)
|
||||
await expect(runL2(dataset, sha256, new StubClient(), { ...cfg, holdoutFrom: 3 })).rejects.toThrow(/window/)
|
||||
})
|
||||
})
|
||||
@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
coverage,
|
||||
directionAccuracy,
|
||||
hindsightBid,
|
||||
mape,
|
||||
naiveBid,
|
||||
nrmse,
|
||||
realisedRevenue,
|
||||
} from '../src/metrics.js'
|
||||
|
||||
describe('forecast metrics', () => {
|
||||
it('mape ignores zero actuals and is 0 for a perfect forecast', () => {
|
||||
expect(mape([10, 0, 20], [10, 5, 20])).toBe(0)
|
||||
expect(mape([10, 20], [11, 18])).toBeCloseTo((0.1 + 0.1) / 2)
|
||||
})
|
||||
|
||||
it('nrmse normalises by capacity', () => {
|
||||
expect(nrmse([0, 10], [0, 10], 20)).toBe(0)
|
||||
expect(nrmse([10, 10], [12, 8], 20)).toBeCloseTo(2 / 20)
|
||||
})
|
||||
|
||||
it('coverage counts inclusive band hits', () => {
|
||||
expect(coverage([1, 2, 3, 4], [1, 1, 4, 1], [1, 3, 5, 3])).toBe(0.5)
|
||||
})
|
||||
|
||||
it('direction accuracy scores pairwise ordering, not level', () => {
|
||||
expect(directionAccuracy([1, 2, 3], [10, 20, 30])).toBe(1)
|
||||
expect(directionAccuracy([1, 2, 3], [30, 20, 10])).toBe(0)
|
||||
expect(directionAccuracy([1, 2, 3], [1, 3, 2])).toBeCloseTo(2 / 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bid backtest baselines', () => {
|
||||
const price = [100, 300, 200, 50]
|
||||
const cap = [2, 2, 2, 2]
|
||||
|
||||
it('realised revenue clears only where offer ≤ price', () => {
|
||||
expect(realisedRevenue({ offers: [150, 150, 150, 150], quantities: [1, 1, 1, 1] }, price)).toBe(500)
|
||||
})
|
||||
|
||||
it('naive bid is flat, price-taking and meets the energy bound', () => {
|
||||
const bid = naiveBid(cap, 4)
|
||||
expect(bid.quantities).toEqual([1, 1, 1, 1])
|
||||
expect(bid.offers.every((o) => o === 0)).toBe(true)
|
||||
expect(naiveBid(cap, 100).quantities).toEqual(cap) // capped by capacity
|
||||
})
|
||||
|
||||
it('hindsight bid fills highest-priced intervals first and bounds revenue above', () => {
|
||||
const bid = hindsightBid(price, cap, 3, 0.5)
|
||||
expect(bid.quantities).toEqual([0, 2, 1, 0])
|
||||
const best = realisedRevenue(bid, price)
|
||||
expect(best).toBe(800)
|
||||
expect(realisedRevenue(naiveBid(cap, 3), price)).toBeLessThan(best)
|
||||
})
|
||||
|
||||
it('hindsight honours the block size', () => {
|
||||
const bid = hindsightBid(price, cap, 2.2, 0.5)
|
||||
expect(bid.quantities).toEqual([0, 2, 0, 0]) // leftover 0.2 < block → not placed
|
||||
})
|
||||
})
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@vpp/runtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"start": "tsx src/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mastra/core": "^1.63.2",
|
||||
"@mastra/libsql": "^1.22.2",
|
||||
"@vpp/domain": "*",
|
||||
"@vpp/services": "*",
|
||||
"zod": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { z } from 'zod'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import type { TriggerService } from './trigger.js'
|
||||
import { ResumeDecision } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Case Desk v1 HTTP API (docs/02 §5, docs/11 §1.1 CaseDeskPort). Inbox, case
|
||||
* view, lineage expansion, and the approve/reject endpoint that resumes the
|
||||
* lifecycle run. Identity comes from the existing permission system
|
||||
* (docs/06 §4) — here as trusted headers behind that system's gateway:
|
||||
* x-user-id / x-user-role. The approver identity is what I1/I2 checks.
|
||||
*/
|
||||
const DecideBody = z.object({ decision: z.enum(['approve', 'reject']), comment: z.string().optional() })
|
||||
const TaskBody = z.object({ message: z.string().min(1) })
|
||||
|
||||
const json = (res: ServerResponse, status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const readBody = (req: IncomingMessage): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
req.on('data', (c) => (data += c))
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {})
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export function createApi(rt: Runtime, triggers: TriggerService): Server {
|
||||
return createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
const user = { id: req.headers['x-user-id'], role: req.headers['x-user-role'] }
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { status: 'ok', llm: rt.ctx.llm.backend })
|
||||
if (req.method === 'GET' && url.pathname === '/inbox') return json(res, 200, rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== ''))
|
||||
if (req.method === 'GET' && url.pathname === '/cases') return json(res, 200, rt.ctx.caseDesk.cases.list().map((r) => r.value))
|
||||
if (req.method === 'GET' && parts[0] === 'cases' && parts[1] && parts.length === 2) return json(res, 200, rt.ctx.caseDesk.read(parts[1]))
|
||||
if (req.method === 'GET' && parts[0] === 'proposals' && parts[1] && parts[2] === 'lineage') return json(res, 200, rt.ctx.caseDesk.expandLineage(parts[1]))
|
||||
if (req.method === 'GET' && url.pathname === '/events') return json(res, 200, rt.ctx.events.list(url.searchParams.get('correlation_id') ? { correlation_id: url.searchParams.get('correlation_id')! } : {}))
|
||||
|
||||
if (req.method === 'POST' && parts[0] === 'approvals' && parts[1] && parts[2] === 'decide') {
|
||||
if (typeof user.id !== 'string' || typeof user.role !== 'string') return json(res, 401, { error: 'x-user-id and x-user-role required' })
|
||||
const body = DecideBody.parse(await readBody(req))
|
||||
const decision = ResumeDecision.parse({ ...body, approver: { id: user.id, role: user.role } })
|
||||
const result = await triggers.decide(parts[1], decision)
|
||||
return json(res, 200, { run_id: parts[1], status: result.status, outcome: result.status === 'success' ? result.result.outcome : null })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/tasks') {
|
||||
const body = TaskBody.parse(await readBody(req))
|
||||
return json(res, 200, await triggers.manual(body.message))
|
||||
}
|
||||
return json(res, 404, { error: 'not found' })
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) return json(res, 400, { error: e.issues })
|
||||
if (e instanceof LlmUnavailable) return json(res, 503, { error: e.message })
|
||||
return json(res, 500, { error: (e as Error).message })
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import type {
|
||||
AuthorityService,
|
||||
CaseDeskService,
|
||||
EnvelopeService,
|
||||
EventBus,
|
||||
FileExportGateway,
|
||||
LedgerService,
|
||||
LineageRecorder,
|
||||
PolicyEngine,
|
||||
ResourceRegistry,
|
||||
SimulationPort,
|
||||
SkillClient,
|
||||
SnapshotStore,
|
||||
TimeSeriesStore,
|
||||
} from '@vpp/services'
|
||||
import type { LlmPort } from './llm.js'
|
||||
|
||||
/**
|
||||
* Everything a workflow step may touch. Deterministic services are plain
|
||||
* objects imported by steps (docs/09 §7: never exposed to the LLM); the LLM
|
||||
* port is the one non-deterministic dependency and is isolated behind
|
||||
* `llm`. Workflows are created per runtime and close over their context, so
|
||||
* two runtimes can coexist in one process — which is what the restart test does.
|
||||
*/
|
||||
export interface RuntimeConfig {
|
||||
policyPackId: string
|
||||
/** OPEN-QUESTION B3: which roles may approve; placeholder list. */
|
||||
approverRoles: string[]
|
||||
/** OPEN-QUESTION A1: bid permit lifetime — until the market deadline. Placeholder. */
|
||||
bidPermitTtlMs: number
|
||||
/** OPEN-QUESTION B6 / A6 / A4 placeholders passed to the optimizer. */
|
||||
risk: { risk_aversion: string; commitment_buffer_k: string; min_block_mwh: string; marginal_cost_yuan_per_mwh: string }
|
||||
/** OPEN-QUESTION B5: worst-case loss budget for the simulation alert. Placeholder. */
|
||||
worstCaseLossBudgetYuan: string
|
||||
/** History window (days) handed to forecast skills. */
|
||||
forecastWindowDays: number
|
||||
/** OPEN-QUESTION B7: price-spike ratio (p90/p50) above which the day is EXTREME. Placeholder. */
|
||||
extremeDayPriceRatio: string
|
||||
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
|
||||
bidDeadlineLocal: string
|
||||
}
|
||||
|
||||
export interface RuntimeContext {
|
||||
config: RuntimeConfig
|
||||
clock: () => string
|
||||
newId: (prefix: string) => string
|
||||
snapshots: SnapshotStore
|
||||
timeseries: TimeSeriesStore
|
||||
resources: ResourceRegistry
|
||||
ledger: LedgerService
|
||||
events: EventBus
|
||||
policy: PolicyEngine
|
||||
envelopes: EnvelopeService
|
||||
authority: AuthorityService
|
||||
gateway: FileExportGateway
|
||||
simulation: SimulationPort
|
||||
caseDesk: CaseDeskService
|
||||
skills: SkillClient
|
||||
lineage: LineageRecorder
|
||||
llm: LlmPort
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
export * from './llm.js'
|
||||
export * from './skills.js'
|
||||
export * from './context.js'
|
||||
export * from './runtime.js'
|
||||
export * from './trigger.js'
|
||||
export * from './api.js'
|
||||
export * from './workflows/proposal-lifecycle.js'
|
||||
export * from './workflows/day-ahead-situation.js'
|
||||
export * from './workflows/day-ahead-bid.js'
|
||||
@ -1,109 +0,0 @@
|
||||
import { Agent } from '@mastra/core/agent'
|
||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||
import type { z } from 'zod'
|
||||
|
||||
/**
|
||||
* LLM provider abstraction (P6, ADR-0003). Agent code programs against
|
||||
* `LlmPort.structured()` — "give me an object of this schema" — never a vendor
|
||||
* API. Backends: a Mastra Agent over a router model id (dev: commercial API;
|
||||
* prod: the GuangMing OpenAI-compatible adapter), a scripted provider for
|
||||
* tests, and the null provider that models an LLM outage. Every caller must
|
||||
* survive `LlmUnavailable` (I6) — the deterministic fallback is part of the
|
||||
* step, not an afterthought.
|
||||
*/
|
||||
export class LlmUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`LLM unavailable: ${reason}`)
|
||||
this.name = 'LlmUnavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LlmPort {
|
||||
readonly backend: string
|
||||
structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T>
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string
|
||||
}
|
||||
|
||||
/** Real backend: one Mastra Agent per spec, all on the same routed model. */
|
||||
export class MastraLlm implements LlmPort {
|
||||
readonly backend: string
|
||||
private readonly agents = new Map<string, Agent>()
|
||||
|
||||
constructor(model: MastraModelConfig, specs: AgentSpec[], backend = 'mastra') {
|
||||
this.backend = backend
|
||||
for (const s of specs) this.agents.set(s.id, new Agent({ id: s.id, name: s.name, instructions: s.instructions, model }))
|
||||
}
|
||||
|
||||
mastraAgents(): Record<string, Agent> {
|
||||
return Object.fromEntries(this.agents)
|
||||
}
|
||||
|
||||
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
|
||||
const agent = this.agents.get(agentId)
|
||||
if (!agent) throw new Error(`agent ${agentId} not registered`)
|
||||
let res: { object?: unknown }
|
||||
try {
|
||||
res = await agent.generate(prompt, { structuredOutput: { schema: schema as never } })
|
||||
} catch (e) {
|
||||
throw new LlmUnavailable((e as Error).message)
|
||||
}
|
||||
const parsed = schema.safeParse(res.object)
|
||||
if (!parsed.success) throw new LlmUnavailable(`structured output failed schema: ${parsed.error.message}`)
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM outage (or simply "no model configured"): every call fails fast. */
|
||||
export class NullLlm implements LlmPort {
|
||||
readonly backend = 'null'
|
||||
async structured<T>(): Promise<T> {
|
||||
throw new LlmUnavailable('no LLM backend configured')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test backend: canned answers keyed by agent id, optionally a function of the prompt. */
|
||||
export class ScriptedLlm implements LlmPort {
|
||||
readonly backend = 'scripted'
|
||||
readonly prompts: Array<{ agentId: string; prompt: string }> = []
|
||||
constructor(private readonly answers: Record<string, unknown | ((prompt: string) => unknown)>) {}
|
||||
|
||||
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
|
||||
this.prompts.push({ agentId, prompt })
|
||||
const a = this.answers[agentId]
|
||||
if (a === undefined) throw new LlmUnavailable(`no scripted answer for ${agentId}`)
|
||||
const raw = typeof a === 'function' ? (a as (p: string) => unknown)(prompt) : a
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (!parsed.success) throw new LlmUnavailable(`scripted output failed schema: ${parsed.error.message}`)
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */
|
||||
export const AGENT_SPECS: AgentSpec[] = [
|
||||
{
|
||||
id: 'router-agent',
|
||||
name: 'Router',
|
||||
instructions:
|
||||
'You route an operator request to exactly one registered workflow template and extract its parameters. ' +
|
||||
'You never invent workflows. If the request is ambiguous, choose adhoc-analysis with LOW confidence.',
|
||||
},
|
||||
{
|
||||
id: 'analysis-agent',
|
||||
name: 'Analysis',
|
||||
instructions:
|
||||
'You are the situational-awareness analyst for a virtual power plant. Given forecast bundles (already computed by tools), ' +
|
||||
'write short findings. Never state a number that is not in the provided tool outputs; cite snapshot refs.',
|
||||
},
|
||||
{
|
||||
id: 'trading-agent',
|
||||
name: 'Trading',
|
||||
instructions:
|
||||
'You draft day-ahead bid proposals. All numbers come from the bid-optimization tool output you are given; ' +
|
||||
'you reference them by {tool_call_id, path} and explain the strategy in one paragraph for the approver.',
|
||||
},
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Dev/shadow entry point: `npm run start -w @vpp/runtime`.
|
||||
* VPP_DATA_DIR durable state (default ./data)
|
||||
* VPP_SKILLS_URL Python skill service (default http://127.0.0.1:8000)
|
||||
* VPP_LLM_MODEL Mastra router model id, e.g. openai/gpt-... ; unset = LLM down (I6 mode)
|
||||
* VPP_API_PORT Case Desk API port (default 4100)
|
||||
* Scheduled triggers (D-1 06:00 situation, 08:00 bid, Asia/Shanghai) run in-process.
|
||||
*/
|
||||
import { createApi } from './api.js'
|
||||
import { createRuntime } from './runtime.js'
|
||||
import { TriggerService } from './trigger.js'
|
||||
|
||||
const rt = await createRuntime({
|
||||
dataDir: process.env['VPP_DATA_DIR'] ?? './data',
|
||||
skills: process.env['VPP_SKILLS_URL'] ?? 'http://127.0.0.1:8000',
|
||||
llm: process.env['VPP_LLM_MODEL'] ? (process.env['VPP_LLM_MODEL'] as `${string}/${string}`) : null,
|
||||
})
|
||||
const triggers = new TriggerService(rt)
|
||||
triggers.startEventConsumers()
|
||||
triggers.startScheduler()
|
||||
const port = Number(process.env['VPP_API_PORT'] ?? 4100)
|
||||
createApi(rt, triggers).listen(port, () => {
|
||||
console.log(`vpp runtime: api on :${port}, llm=${rt.ctx.llm.backend}, data=${process.env['VPP_DATA_DIR'] ?? './data'}`)
|
||||
})
|
||||
@ -1,133 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
import { Mastra } from '@mastra/core'
|
||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||
import { LibSQLStore } from '@mastra/libsql'
|
||||
import { Approval, DecisionCase, Envelope, ExecutionPermit, ExecutionReceipt, Proposal, ResourceProfile } from '@vpp/domain'
|
||||
import {
|
||||
AuthorityService,
|
||||
CaseDeskService,
|
||||
EnvelopeService,
|
||||
FileEventBus,
|
||||
FileExportGateway,
|
||||
FsRepository,
|
||||
FsSnapshotStore,
|
||||
HUBEI_BID_PACK_PLACEHOLDER,
|
||||
HttpSkillClient,
|
||||
LedgerService,
|
||||
LineageRecorder,
|
||||
MemoryTimeSeriesStore,
|
||||
PendingApprovalSchema,
|
||||
PolicyEngine,
|
||||
RevenueScenarioSimulator,
|
||||
hubeiSpotBiddingPack,
|
||||
} from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import type { RuntimeConfig, RuntimeContext } from './context.js'
|
||||
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
|
||||
import type { LlmPort } from './llm.js'
|
||||
import { createDayAheadBid } from './workflows/day-ahead-bid.js'
|
||||
import { createDayAheadSituation } from './workflows/day-ahead-situation.js'
|
||||
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Runtime = thin shell over Mastra + the self-built services (docs/09 §0).
|
||||
* `createRuntime` wires storage, workflows and agents; everything durable
|
||||
* lives under `dataDir` so a second `createRuntime` on the same directory
|
||||
* *is* a process restart for the purposes of the resumability tests.
|
||||
*/
|
||||
export interface RuntimeOptions {
|
||||
dataDir: string
|
||||
/** Router model id (e.g. 'openai/gpt-...'), an OpenAI-compatible config, or an LlmPort for tests; null = LLM down. */
|
||||
llm: MastraModelConfig | LlmPort | null
|
||||
skills: SkillClient | string
|
||||
clock?: () => string
|
||||
config?: Partial<RuntimeConfig>
|
||||
/** Optional in-memory overrides for tests (time series is in-memory in phase 1 regardless). */
|
||||
timeseries?: RuntimeContext['timeseries']
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
|
||||
policyPackId: 'hubei-spot-bidding',
|
||||
approverRoles: ['senior-trader', 'ops-lead', 'risk-officer'], // OPEN-QUESTION B3
|
||||
bidPermitTtlMs: 6 * 3_600_000, // OPEN-QUESTION A1
|
||||
risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '0' }, // B6 / A6 / A4
|
||||
worstCaseLossBudgetYuan: '100000', // OPEN-QUESTION B5
|
||||
forecastWindowDays: 28,
|
||||
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
|
||||
bidDeadlineLocal: '09:00', // OPEN-QUESTION A1
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
mastra: Mastra
|
||||
ctx: RuntimeContext
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const isLlmPort = (x: unknown): x is LlmPort => !!x && typeof x === 'object' && 'structured' in (x as object) && 'backend' in (x as object)
|
||||
|
||||
export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
||||
const config = { ...DEFAULT_RUNTIME_CONFIG, ...opts.config }
|
||||
const clock = opts.clock ?? (() => new Date().toISOString())
|
||||
let seq = 0
|
||||
const newId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${(++seq).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const dir = (name: string) => join(opts.dataDir, name)
|
||||
|
||||
const snapshots = new FsSnapshotStore(dir('snapshots'))
|
||||
const events = new FileEventBus(dir('events.jsonl'), clock)
|
||||
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock }) // OPEN-QUESTION A5
|
||||
const authority = new AuthorityService(new FsRepository(dir('permits.json'), ExecutionPermit, (p) => p.id, clock), () => newId('permit'))
|
||||
const policy = new PolicyEngine([hubeiSpotBiddingPack('2026.03', HUBEI_BID_PACK_PLACEHOLDER)])
|
||||
const envelopes = new EnvelopeService(new FsRepository(dir('envelopes.json'), Envelope, (e) => e.id, clock))
|
||||
const caseDesk = new CaseDeskService({
|
||||
cases: new FsRepository(dir('cases.json'), DecisionCase, (c) => c.id, clock),
|
||||
proposals: new FsRepository(dir('proposals.json'), Proposal, (p) => p.id, clock),
|
||||
approvals: new FsRepository(dir('approvals.json'), Approval, (a) => a.id, clock),
|
||||
pending: new FsRepository(dir('pending.json'), PendingApprovalSchema, (p) => p.proposal_id, clock),
|
||||
authority,
|
||||
snapshots,
|
||||
events,
|
||||
clock,
|
||||
})
|
||||
const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock))
|
||||
|
||||
let llm: LlmPort
|
||||
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
||||
if (opts.llm === null) llm = new NullLlm()
|
||||
else if (isLlmPort(opts.llm)) llm = opts.llm
|
||||
else {
|
||||
const m = new MastraLlm(opts.llm, AGENT_SPECS)
|
||||
llm = m
|
||||
agents = m.mastraAgents()
|
||||
}
|
||||
|
||||
const ctx: RuntimeContext = {
|
||||
config,
|
||||
clock,
|
||||
newId,
|
||||
snapshots,
|
||||
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
|
||||
resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock),
|
||||
ledger,
|
||||
events,
|
||||
policy,
|
||||
envelopes,
|
||||
authority,
|
||||
gateway,
|
||||
simulation: new RevenueScenarioSimulator(),
|
||||
caseDesk,
|
||||
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
|
||||
lineage: new LineageRecorder(),
|
||||
llm,
|
||||
}
|
||||
|
||||
const storage = new LibSQLStore({ id: 'vpp-runtime', url: `file:${dir('mastra.db')}` })
|
||||
// Workflows are built per runtime so their steps close over *this* context;
|
||||
// module-level singletons would bind to whichever Mastra registered last.
|
||||
const proposalLifecycle = createProposalLifecycle(ctx)
|
||||
const mastra = new Mastra({
|
||||
storage,
|
||||
workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) },
|
||||
agents,
|
||||
})
|
||||
return { mastra, ctx, close: () => storage.close() }
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
import type { ToolCallRef } from '@vpp/domain'
|
||||
import type { LineageRecorder, SnapshotStore } from '@vpp/services'
|
||||
|
||||
/**
|
||||
* Skill registry wrapper (docs/09 §4): every skill call snapshots its input
|
||||
* and output and records a ToolCallRef into the run's lineage buffer. The
|
||||
* Mastra Tool surface (for agent-initiated calls) is layered on top in a
|
||||
* later milestone; M3 workflows call skills deterministically from steps,
|
||||
* which is the docs/09 §3 discipline ("context assembled by upstream steps").
|
||||
*/
|
||||
export interface RegisteredSkill<I, O> {
|
||||
id: string
|
||||
version: string
|
||||
call(input: I): Promise<{ output: O; ref: ToolCallRef }>
|
||||
}
|
||||
|
||||
export function registerSkill<I, O>(
|
||||
deps: { lineage: LineageRecorder; snapshots: SnapshotStore; idGen: () => string },
|
||||
def: { id: string; version: string; invoke: (input: I) => Promise<O> },
|
||||
): RegisteredSkill<I, O> {
|
||||
return {
|
||||
id: def.id,
|
||||
version: def.version,
|
||||
async call(input: I) {
|
||||
const inputs_ref = deps.snapshots.put(input)
|
||||
const output = await def.invoke(input)
|
||||
const outputs_ref = deps.snapshots.put(output)
|
||||
const ref: ToolCallRef = { tool_call_id: deps.idGen(), tool: def.id, version: def.version, inputs_ref, outputs_ref }
|
||||
deps.lineage.record(ref)
|
||||
return { output, ref }
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
import { RouterDecision } from '@vpp/domain'
|
||||
import type { EventEnvelope } from '@vpp/domain'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js'
|
||||
import type { ResumeDecision } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Trigger service (docs/02 §1, docs/09 §1): three sources, one shape —
|
||||
* "instantiate template X with params Y".
|
||||
* scheduled: cron entries (D-1 06:00 situation, 08:00 bid) — no LLM in the path
|
||||
* event: bus consumers with deterministic rules
|
||||
* manual: the router agent turns free text into a RouterDecision
|
||||
* (a template id + params, never a new flow); on LLM outage the
|
||||
* operator picks the template explicitly.
|
||||
* Approval is a resume on the lifecycle run (docs/09 §2).
|
||||
*/
|
||||
export interface ScheduleEntry {
|
||||
id: string
|
||||
/** 'HH:MM' Asia/Shanghai on D-1 */
|
||||
localTime: string
|
||||
workflow: 'dayAheadSituation' | 'dayAheadBid'
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULE: ScheduleEntry[] = [
|
||||
{ id: 'situation-0600', localTime: '06:00', workflow: 'dayAheadSituation' }, // docs/07 D-1 06:00
|
||||
{ id: 'bid-0800', localTime: '08:00', workflow: 'dayAheadBid' }, // docs/07 D-1 08:00; window is OPEN-QUESTION A1
|
||||
]
|
||||
|
||||
export const nextMarketDate = (nowIso: string): string => {
|
||||
const shanghai = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
|
||||
shanghai.setUTCDate(shanghai.getUTCDate() + 1)
|
||||
return shanghai.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export class TriggerService {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private readonly firedToday = new Set<string>()
|
||||
private readonly unsubscribe: Array<() => void> = []
|
||||
|
||||
constructor(
|
||||
private readonly rt: Runtime,
|
||||
private readonly schedule: ScheduleEntry[] = DEFAULT_SCHEDULE,
|
||||
) {}
|
||||
|
||||
// ---- scheduled ---------------------------------------------------------
|
||||
|
||||
/** Polls the clock; fires each entry once per local day. Deterministic, LLM-free. */
|
||||
startScheduler(pollMs = 30_000): void {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(() => void this.tick(), pollMs)
|
||||
this.timer.unref()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
this.timer = null
|
||||
for (const u of this.unsubscribe) u()
|
||||
}
|
||||
|
||||
async tick(nowIso = this.rt.ctx.clock()): Promise<string[]> {
|
||||
const local = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
|
||||
const hhmm = local.toISOString().slice(11, 16)
|
||||
const day = local.toISOString().slice(0, 10)
|
||||
const fired: string[] = []
|
||||
for (const entry of this.schedule) {
|
||||
const key = `${day}:${entry.id}`
|
||||
if (hhmm >= entry.localTime && !this.firedToday.has(key)) {
|
||||
this.firedToday.add(key)
|
||||
await this.startTemplate(entry.workflow, { market_date: nextMarketDate(nowIso) }, 'SCHEDULED', entry.id)
|
||||
fired.push(entry.id)
|
||||
}
|
||||
}
|
||||
return fired
|
||||
}
|
||||
|
||||
// ---- event -------------------------------------------------------------
|
||||
|
||||
/** Deterministic event rules. M3: a SituationPublished with EXTREME risk opens an abnormal-day case (docs/13 §1). */
|
||||
startEventConsumers(): void {
|
||||
this.unsubscribe.push(
|
||||
this.rt.ctx.events.subscribe('SituationPublished', (evt: EventEnvelope) => {
|
||||
const payload = evt.payload as { report: { market_date: string; risk_level: string; id: string } }
|
||||
if (payload.report.risk_level === 'EXTREME') {
|
||||
this.rt.ctx.caseDesk.open({
|
||||
id: this.rt.ctx.newId('case'),
|
||||
kind: 'ADHOC_ANALYSIS',
|
||||
objective: `Abnormal-day protocol review for ${payload.report.market_date} (situation ${payload.report.id})`,
|
||||
owner: 'user-ops-lead', // OPEN-QUESTION B8
|
||||
deadline: `${payload.report.market_date}T00:00:00Z`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- manual ------------------------------------------------------------
|
||||
|
||||
async route(message: string): Promise<RouterDecision> {
|
||||
const prompt = `Operator request: "${message}". Today (UTC) is ${this.rt.ctx.clock()}. Default market_date is ${nextMarketDate(this.rt.ctx.clock())}.`
|
||||
try {
|
||||
return await this.rt.ctx.llm.structured('router-agent', prompt, RouterDecision)
|
||||
} catch (e) {
|
||||
if (e instanceof LlmUnavailable) throw new LlmUnavailable('router needs an LLM; pick a template explicitly via startTemplate')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async manual(message: string): Promise<{ decision: RouterDecision; run_id: string | null }> {
|
||||
const decision = await this.route(message)
|
||||
if (decision.workflow_id === 'adhoc-analysis') return { decision, run_id: null }
|
||||
const run_id = await this.startTemplate(decision.workflow_id === 'day-ahead-bid' ? 'dayAheadBid' : 'dayAheadSituation', decision.params, 'MANUAL', 'router')
|
||||
return { decision, run_id }
|
||||
}
|
||||
|
||||
async startTemplate(workflow: 'dayAheadSituation' | 'dayAheadBid', params: { market_date: string }, trigger: 'SCHEDULED' | 'EVENT' | 'MANUAL', source: string): Promise<string> {
|
||||
const wf = this.rt.mastra.getWorkflow(workflow)
|
||||
const run = await wf.createRun()
|
||||
this.rt.ctx.events.append({ event_type: 'WorkflowTriggered', payload: { workflow, params, trigger, source, run_id: run.runId }, correlation_id: run.runId })
|
||||
const result = await run.start({ inputData: params })
|
||||
this.rt.ctx.events.append({ event_type: 'WorkflowFinished', payload: { workflow, run_id: run.runId, status: result.status }, correlation_id: run.runId })
|
||||
if (result.status === 'failed') throw result.error
|
||||
return run.runId
|
||||
}
|
||||
|
||||
// ---- approval = resume -------------------------------------------------
|
||||
|
||||
async decide(runId: string, decision: ResumeDecision) {
|
||||
const wf = this.rt.mastra.getWorkflow('proposalLifecycle')
|
||||
const run = await wf.createRun({ runId })
|
||||
return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision })
|
||||
}
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { BidProposalDraft, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidProposalDraft as BidProposalDraftSchema, Curve96, MarketDate, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
|
||||
import { Decimal, assembleBidProposal } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
import { runForecasts } from './day-ahead-situation.js'
|
||||
import { LifecycleOutput } from './proposal-lifecycle.js'
|
||||
import type { createProposalLifecycle } from './proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* D-1 08:00 申报策略与报价优化 (docs/07, docs/09 §3): deterministic context
|
||||
* assembly (ledger bounds, capacity, price forecast) → MILP tool → the
|
||||
* trading agent drafts a proposal made of *references* → assembler builds
|
||||
* the Proposal from lineage → submit to the lifecycle workflow.
|
||||
* With the LLM down the draft is templated; the numbers are identical.
|
||||
*/
|
||||
export const BidInput = z.object({ market_date: MarketDate, case_id: z.string().min(1).optional() })
|
||||
|
||||
export const BidOutput = z.object({
|
||||
case_id: z.string(),
|
||||
proposal_id: z.string(),
|
||||
proposal: ProposalSchema,
|
||||
lifecycle_run_id: z.string(),
|
||||
lifecycle: LifecycleOutput.nullable(),
|
||||
lifecycle_status: z.enum(['success', 'suspended', 'failed']),
|
||||
llm_used: z.boolean(),
|
||||
})
|
||||
|
||||
const Context = z.object({
|
||||
market_date: MarketDate,
|
||||
case_id: z.string(),
|
||||
tool_calls: z.array(ToolCallRefSchema),
|
||||
data_refs: z.array(z.string()),
|
||||
milp_call: ToolCallRefSchema,
|
||||
milp_output: z.unknown(),
|
||||
ledger_version: z.int(),
|
||||
policy_pack_version: z.string(),
|
||||
capacity: Curve96,
|
||||
})
|
||||
|
||||
function capacityCurve(ctx: RuntimeContext, marketDate: string) {
|
||||
const total = ctx.resources.list().reduce((s, r) => s.add(new Decimal(r.value.certified_adjustable_mw)), new Decimal(0))
|
||||
return { interval_minutes: 15 as const, date: marketDate, values: Array.from({ length: 96 }, () => total.toFixed(3)) }
|
||||
}
|
||||
|
||||
export function createDayAheadBid(ctx: RuntimeContext, lifecycle: ReturnType<typeof createProposalLifecycle>) {
|
||||
const fetchContext = createStep({
|
||||
id: 'fetch-context',
|
||||
inputSchema: BidInput,
|
||||
outputSchema: Context,
|
||||
execute: async ({ inputData }) => {
|
||||
const caseId =
|
||||
inputData.case_id ??
|
||||
ctx.caseDesk.open({
|
||||
id: ctx.newId('case'),
|
||||
kind: 'DAY_AHEAD_BID',
|
||||
objective: `Submit day-ahead bid for ${inputData.market_date}`,
|
||||
owner: 'user-trader-01', // OPEN-QUESTION B3: owner role mapping
|
||||
// D-1 at the local bid deadline (Asia/Shanghai = UTC+8), stored as UTC.
|
||||
deadline: new Date(new Date(`${inputData.market_date}T${ctx.config.bidDeadlineLocal}:00Z`).getTime() - 86_400_000 - 8 * 3_600_000).toISOString(),
|
||||
}).id
|
||||
|
||||
return ctx.lineage.run(async () => {
|
||||
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
|
||||
const bounds = ctx.ledger.dayAheadBounds(inputData.market_date)
|
||||
const capacity = capacityCurve(ctx, inputData.market_date)
|
||||
ctx.lineage.addDataRef(ctx.snapshots.put({ resources: ctx.resources.list().map((r) => ({ id: r.key, version: r.version })) }))
|
||||
const milp = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: 'bid-optimization-milp', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.optimizeBid>[0]) => ctx.skills.optimizeBid(req) },
|
||||
)
|
||||
const { output, ref } = await milp.call({ market_date: inputData.market_date, price_forecast: forecasts.PRICE, adjustable_capacity_mw: capacity, position_bounds: bounds, risk: ctx.config.risk })
|
||||
if (output.solver.status !== 'OPTIMAL') throw new Error(`bid optimization ${output.solver.status}: ${output.binding_constraints.join('; ')}`)
|
||||
const lin = ctx.lineage.current()
|
||||
ctx.caseDesk.addEvidence(caseId, lin.data_refs)
|
||||
return {
|
||||
market_date: inputData.market_date,
|
||||
case_id: caseId,
|
||||
tool_calls: [...calls, ref].filter((c, i, arr) => arr.findIndex((x) => x.tool_call_id === c.tool_call_id) === i),
|
||||
data_refs: lin.data_refs,
|
||||
milp_call: ref,
|
||||
milp_output: output,
|
||||
ledger_version: bounds.ledger_version,
|
||||
policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version,
|
||||
capacity,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const bidStrategy = createStep({
|
||||
id: 'bid-strategy',
|
||||
inputSchema: Context,
|
||||
outputSchema: Context.extend({ draft: BidProposalDraftSchema, llm_used: z.boolean() }),
|
||||
execute: async ({ inputData }) => {
|
||||
const tc = inputData.milp_call.tool_call_id
|
||||
const out = inputData.milp_output as { expected_revenue_yuan: string; daily_energy_mwh: string; binding_constraints: string[] }
|
||||
const template: BidProposalDraft = {
|
||||
market_date: inputData.market_date,
|
||||
prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' },
|
||||
rationale: `Solver ${tc}: expected revenue ${out.expected_revenue_yuan} yuan over ${out.daily_energy_mwh} MWh; binding: ${out.binding_constraints.join(', ') || 'none'} (template — LLM unavailable)`,
|
||||
}
|
||||
try {
|
||||
const prompt =
|
||||
`Market date ${inputData.market_date}. The bid-optimization tool call id is ${tc}; its output has fields prices_yuan_per_mwh, quantities_mwh, expected_revenue_yuan (= ${out.expected_revenue_yuan}), daily_energy_mwh (= ${out.daily_energy_mwh}), binding_constraints (${out.binding_constraints.join(', ') || 'none'}). ` +
|
||||
'Return a BidProposalDraft whose three refs point at those fields of that tool call, plus a one-paragraph rationale for the approver. Do not write any number that is not quoted above.'
|
||||
const draft = await ctx.llm.structured('trading-agent', prompt, BidProposalDraftSchema)
|
||||
return { ...inputData, draft, llm_used: true }
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
return { ...inputData, draft: template, llm_used: false }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const assembleAndSubmit = createStep({
|
||||
id: 'assemble-and-submit',
|
||||
inputSchema: bidStrategy.outputSchema,
|
||||
outputSchema: BidOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
const proposal: Proposal = assembleBidProposal(inputData.draft, {
|
||||
id: ctx.newId('prop'),
|
||||
originator: { agent: 'trading-agent', trigger: 'SCHEDULED', task_id: inputData.case_id },
|
||||
lineage: { tool_calls: inputData.tool_calls as ToolCallRef[], data_refs: inputData.data_refs },
|
||||
ledger_version: inputData.ledger_version,
|
||||
policy_pack_version: inputData.policy_pack_version,
|
||||
snapshots: ctx.snapshots,
|
||||
now: ctx.clock(),
|
||||
})
|
||||
ctx.snapshots.put({ draft: inputData.draft, proposal_id: proposal.id })
|
||||
ctx.caseDesk.recordProposal(inputData.case_id, proposal)
|
||||
|
||||
// ProposalSubmissionPort (docs/11 §1.1): the business workflow ends here;
|
||||
// the lifecycle workflow takes over on its own run id (resumable).
|
||||
const run = await lifecycle.createRun()
|
||||
const result = await run.start({ inputData: { proposal, case_id: inputData.case_id } })
|
||||
if (result.status === 'suspended') {
|
||||
const pending = ctx.caseDesk.pending.get(proposal.id)
|
||||
if (pending) ctx.caseDesk.pending.put({ ...pending.value, run_id: run.runId }, pending.version)
|
||||
}
|
||||
return {
|
||||
case_id: inputData.case_id,
|
||||
proposal_id: proposal.id,
|
||||
proposal,
|
||||
lifecycle_run_id: run.runId,
|
||||
lifecycle: result.status === 'success' ? result.result : null,
|
||||
lifecycle_status: (result.status === 'success' ? 'success' : result.status === 'suspended' ? 'suspended' : 'failed') as 'success' | 'suspended' | 'failed',
|
||||
llm_used: inputData.llm_used,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'day-ahead-bid',
|
||||
inputSchema: BidInput,
|
||||
outputSchema: BidOutput,
|
||||
})
|
||||
.then(fetchContext)
|
||||
.then(bidStrategy)
|
||||
.then(assembleAndSubmit)
|
||||
.commit()
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { ForecastBundle, ForecastKind, SituationReport, ToolCallRef } from '@vpp/domain'
|
||||
import { ForecastBundle as ForecastBundleSchema, MarketDate, SituationReport as SituationReportSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
|
||||
/**
|
||||
* D-1 06:00 日前态势 (docs/07): periodic trigger → forecasts (tools) →
|
||||
* analysis agent writes findings around the numbers → SituationReport on
|
||||
* the bus. Runs with the LLM down (I6): findings fall back to a template.
|
||||
*/
|
||||
export const SituationInput = z.object({ market_date: MarketDate })
|
||||
|
||||
export const SituationOutput = z.object({
|
||||
report: SituationReportSchema,
|
||||
report_ref: z.string(),
|
||||
forecasts: z.object({ LOAD: ForecastBundleSchema, PV: ForecastBundleSchema, PRICE: ForecastBundleSchema }),
|
||||
tool_calls: z.array(ToolCallRefSchema),
|
||||
llm_backend: z.string(),
|
||||
llm_used: z.boolean(),
|
||||
})
|
||||
|
||||
const SERIES: Record<ForecastKind, { series: string; unit: 'mw' | 'yuan_per_mwh' }> = {
|
||||
LOAD: { series: 'load:aggregate', unit: 'mw' },
|
||||
PV: { series: 'pv:aggregate', unit: 'mw' },
|
||||
PRICE: { series: 'price:da', unit: 'yuan_per_mwh' },
|
||||
}
|
||||
|
||||
const Findings = z.object({
|
||||
findings: z.array(z.object({ kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']), summary: z.string().min(1) })).min(1).max(6),
|
||||
})
|
||||
|
||||
export async function runForecasts(ctx: RuntimeContext, marketDate: string): Promise<{ forecasts: Record<ForecastKind, ForecastBundle>; calls: ToolCallRef[] }> {
|
||||
const d = new Date(`${marketDate}T00:00:00Z`)
|
||||
const from = new Date(d.getTime() - ctx.config.forecastWindowDays * 86_400_000).toISOString().slice(0, 10)
|
||||
const to = new Date(d.getTime() - 86_400_000).toISOString().slice(0, 10)
|
||||
const forecasts = {} as Record<ForecastKind, ForecastBundle>
|
||||
const calls: ToolCallRef[] = []
|
||||
for (const kind of ['LOAD', 'PV', 'PRICE'] as const) {
|
||||
const history = ctx.timeseries.range(SERIES[kind].series, from, to).map((r) => r.curve)
|
||||
if (history.length === 0) throw new Error(`no history for ${SERIES[kind].series} in [${from}, ${to}] — ingest data first`)
|
||||
const featuresRef = ctx.snapshots.put({ series: SERIES[kind].series, from, to, revisions: history.length })
|
||||
ctx.lineage.addDataRef(featuresRef)
|
||||
const skill = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: `${kind.toLowerCase()}-forecast`, version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.forecast>[1]) => ctx.skills.forecast(kind, req) },
|
||||
)
|
||||
const { output, ref } = await skill.call({ kind, market_date: marketDate, unit: SERIES[kind].unit, history, exogenous: {}, features_snapshot_ref: featuresRef })
|
||||
forecasts[kind] = output
|
||||
calls.push(ref)
|
||||
}
|
||||
return { forecasts, calls }
|
||||
}
|
||||
|
||||
/** Risk level is deterministic (it gates the abnormal-day protocol, docs/13 §1); the LLM only narrates. */
|
||||
export function riskLevel(ctx: RuntimeContext, price: ForecastBundle): SituationReport['risk_level'] {
|
||||
const ratio = Math.max(...price.quantiles.p90.values.map((v, i) => Number(v) / Math.max(Number(price.quantiles.p50.values[i]), 1e-9)))
|
||||
const extreme = Number(ctx.config.extremeDayPriceRatio)
|
||||
if (ratio >= extreme) return 'EXTREME'
|
||||
if (ratio >= 1 + (extreme - 1) * 0.66) return 'HIGH'
|
||||
if (ratio >= 1 + (extreme - 1) * 0.33) return 'MEDIUM'
|
||||
return 'LOW'
|
||||
}
|
||||
|
||||
export function createDayAheadSituation(ctx: RuntimeContext) {
|
||||
const forecastStep = createStep({
|
||||
id: 'forecast',
|
||||
inputSchema: SituationInput,
|
||||
outputSchema: z.object({ market_date: MarketDate, forecasts: SituationOutput.shape.forecasts, tool_calls: z.array(ToolCallRefSchema) }),
|
||||
execute: async ({ inputData }) => {
|
||||
return ctx.lineage.run(async () => {
|
||||
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
|
||||
return { market_date: inputData.market_date, forecasts, tool_calls: calls }
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const analyzeStep = createStep({
|
||||
id: 'analyze',
|
||||
inputSchema: forecastStep.outputSchema,
|
||||
outputSchema: SituationOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
const { forecasts, tool_calls } = inputData
|
||||
const refs = tool_calls.map((t) => t.outputs_ref)
|
||||
const level = riskLevel(ctx, forecasts.PRICE)
|
||||
const peakPrice = Math.max(...forecasts.PRICE.quantiles.p50.values.map(Number))
|
||||
const peakLoad = Math.max(...forecasts.LOAD.quantiles.p50.values.map(Number))
|
||||
|
||||
let findings: SituationReport['findings']
|
||||
let llmUsed = false
|
||||
const fallback: SituationReport['findings'] = [
|
||||
{ kind: 'TREND', summary: `P50 peak load ${peakLoad.toFixed(1)} MW; P50 peak price ${peakPrice.toFixed(2)} yuan/MWh (template — LLM unavailable)`, refs },
|
||||
{ kind: 'RISK', summary: `risk level ${level} from price band width`, refs },
|
||||
]
|
||||
try {
|
||||
const prompt =
|
||||
`Market date ${inputData.market_date}. Tool outputs (snapshot refs): ` +
|
||||
tool_calls.map((t) => `${t.tool}→${t.outputs_ref.slice(0, 12)}`).join(', ') +
|
||||
`. Deterministic risk level: ${level}. P50 peak load ${peakLoad.toFixed(1)} MW, P50 peak price ${peakPrice.toFixed(2)} yuan/MWh. ` +
|
||||
'Write 2–4 findings (TREND/ANOMALY/RISK) that only restate these numbers.'
|
||||
const out = await ctx.llm.structured('analysis-agent', prompt, Findings)
|
||||
findings = out.findings.map((f) => ({ ...f, refs }))
|
||||
llmUsed = true
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
findings = fallback
|
||||
}
|
||||
|
||||
const report: SituationReport = {
|
||||
id: ctx.newId('sit'),
|
||||
market_date: inputData.market_date,
|
||||
risk_level: level,
|
||||
findings,
|
||||
forecast_refs: [forecasts.LOAD.id, forecasts.PV.id, forecasts.PRICE.id],
|
||||
generated_at: ctx.clock(),
|
||||
}
|
||||
const report_ref = ctx.snapshots.put(report)
|
||||
ctx.events.append({
|
||||
event_type: 'SituationPublished',
|
||||
payload: { report, report_ref, forecast_tool_calls: tool_calls },
|
||||
correlation_id: `situation-${inputData.market_date}`,
|
||||
})
|
||||
return { report, report_ref, forecasts, tool_calls, llm_backend: ctx.llm.backend, llm_used: llmUsed }
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'day-ahead-situation',
|
||||
inputSchema: SituationInput,
|
||||
outputSchema: SituationOutput,
|
||||
})
|
||||
.then(forecastStep)
|
||||
.then(analyzeStep)
|
||||
.commit()
|
||||
}
|
||||
@ -1,247 +0,0 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { Approval, ForecastBundle, Proposal, ProposalStatus } from '@vpp/domain'
|
||||
import { Approval as ApprovalSchema, ForecastBundle as ForecastBundleSchema, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import { GatewayRejected, assertApproverIndependent, isStaleDenial } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
|
||||
/**
|
||||
* The safety chain as ONE workflow for every proposal type (ADR-0005,
|
||||
* docs/03 §2, docs/09 §2): rule check → simulation → envelope gate
|
||||
* (suspend for humans) → fresh check + permit → release. Every transition is
|
||||
* recorded through the case desk (event-sourced) so the run is replayable.
|
||||
*/
|
||||
export const LifecycleInput = z.object({ proposal: ProposalSchema, case_id: z.string().min(1) })
|
||||
|
||||
export const LifecycleOutcome = z.enum(['RELEASED', 'REJECTED', 'STALE', 'SIMULATION_ALERT'])
|
||||
|
||||
export const LifecycleOutput = z.object({
|
||||
outcome: LifecycleOutcome,
|
||||
proposal: ProposalSchema,
|
||||
permit_id: z.string().nullable(),
|
||||
receipt_id: z.string().nullable(),
|
||||
reasons: z.array(z.string()),
|
||||
})
|
||||
|
||||
const Carry = z.object({
|
||||
proposal: ProposalSchema,
|
||||
case_id: z.string(),
|
||||
simulation_alert: z.boolean(),
|
||||
simulation_alerts: z.array(z.string()),
|
||||
approvals: z.array(ApprovalSchema),
|
||||
envelope_match: z.unknown().nullable(),
|
||||
done: LifecycleOutput.nullable(),
|
||||
})
|
||||
type Carry = z.infer<typeof Carry>
|
||||
|
||||
export const ResumeDecision = z.object({
|
||||
decision: z.enum(['approve', 'reject']),
|
||||
approver: z.object({ id: z.string().min(1), role: z.string().min(1) }),
|
||||
comment: z.string().optional(),
|
||||
})
|
||||
export type ResumeDecision = z.infer<typeof ResumeDecision>
|
||||
|
||||
const SuspendInfo = z.object({
|
||||
reason: z.string(),
|
||||
proposal_id: z.string(),
|
||||
required_level: z.string(),
|
||||
reasons: z.array(z.string()),
|
||||
})
|
||||
|
||||
function transition(ctx: RuntimeContext, caseId: string, p: Proposal, status: ProposalStatus, extra: Record<string, unknown> = {}): Proposal {
|
||||
const next: Proposal = { ...p, status }
|
||||
ctx.caseDesk.recordProposal(caseId, next)
|
||||
if (Object.keys(extra).length) {
|
||||
ctx.events.append({ event_type: `Lifecycle.${status}`, payload: { proposal_id: p.id, digest: p.digest, ...extra }, correlation_id: caseId, causation_id: p.id })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function priceForecastFromLineage(ctx: RuntimeContext, p: Proposal): ForecastBundle | null {
|
||||
for (const tc of [...p.lineage.tool_calls].reverse()) {
|
||||
if (tc.tool !== 'price-forecast') continue
|
||||
const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref))
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const finish = (c: Carry, outcome: z.infer<typeof LifecycleOutcome>, reasons: string[], permit_id: string | null = null, receipt_id: string | null = null): Carry => ({
|
||||
...c,
|
||||
done: { outcome, proposal: c.proposal, permit_id, receipt_id, reasons },
|
||||
})
|
||||
|
||||
/** One lifecycle workflow per runtime: steps close over that runtime's services. */
|
||||
export function createProposalLifecycle(ctx: RuntimeContext) {
|
||||
const ruleCheck = createStep({
|
||||
id: 'rule-check',
|
||||
inputSchema: LifecycleInput,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
const pack = ctx.policy.pack(ctx.config.policyPackId, inputData.proposal.lineage.policy_pack_version)
|
||||
let p = transition(ctx, inputData.case_id, inputData.proposal, 'RULE_CHECK')
|
||||
const result = ctx.policy.check(p, { id: pack.id, version: pack.version }, { ledger: ctx.ledger.read(), ledgerService: ctx.ledger, snapshots: ctx.snapshots, now: ctx.clock() })
|
||||
const ref = ctx.snapshots.put(result)
|
||||
ctx.events.append({ event_type: 'RuleCheckCompleted', payload: { proposal_id: p.id, ok: result.ok, result_ref: ref, violations: result.violations }, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
const carry: Carry = { proposal: p, case_id: inputData.case_id, simulation_alert: false, simulation_alerts: [], approvals: [], envelope_match: null, done: null }
|
||||
if (!result.ok) {
|
||||
p = transition(ctx, inputData.case_id, p, 'REJECTED', { by: 'rule-check', violations: result.violations })
|
||||
return finish({ ...carry, proposal: p }, 'REJECTED', result.violations.map((v) => `${v.rule_id}: ${v.message}`))
|
||||
}
|
||||
return carry
|
||||
},
|
||||
})
|
||||
|
||||
const simulation = createStep({
|
||||
id: 'simulation',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION')
|
||||
const result = ctx.simulation.simulate(p, {
|
||||
now: ctx.clock(),
|
||||
priceForecast: priceForecastFromLineage(ctx, p),
|
||||
marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh,
|
||||
worstCaseLossBudgetYuan: ctx.config.worstCaseLossBudgetYuan,
|
||||
})
|
||||
const ref = ctx.snapshots.put(result)
|
||||
ctx.events.append({ event_type: 'SimulationCompleted', payload: { proposal_id: p.id, alert: result.alert, alerts: result.alerts, metrics: result.metrics, result_ref: ref }, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
return { ...inputData, proposal: p, simulation_alert: result.alert, simulation_alerts: result.alerts }
|
||||
},
|
||||
})
|
||||
|
||||
const envelopeGate = createStep({
|
||||
id: 'envelope-gate',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
suspendSchema: SuspendInfo,
|
||||
resumeSchema: ResumeDecision,
|
||||
execute: async ({ inputData, resumeData, suspend }) => {
|
||||
if (inputData.done) return inputData
|
||||
const now = ctx.clock()
|
||||
const p0 = inputData.proposal
|
||||
|
||||
if (resumeData) {
|
||||
// —— Human decision path (I1/I2 enforced here; the AI has no route to APPROVED).
|
||||
try {
|
||||
assertApproverIndependent(p0, resumeData.approver, ctx.config.approverRoles)
|
||||
} catch (e) {
|
||||
ctx.events.append({ event_type: 'ApprovalRefused', payload: { proposal_id: p0.id, approver: resumeData.approver, reason: (e as Error).message }, correlation_id: inputData.case_id, causation_id: p0.id })
|
||||
return await suspend({ reason: `approval refused: ${(e as Error).message}`, proposal_id: p0.id, required_level: 'L2', reasons: [(e as Error).message] })
|
||||
}
|
||||
const approval: Approval = {
|
||||
id: ctx.newId('appr'),
|
||||
proposal_digest: p0.digest,
|
||||
decision: resumeData.decision === 'approve' ? 'APPROVE' : 'REJECT',
|
||||
approver: resumeData.approver,
|
||||
scope: { effect_type: p0.type, limits: {} },
|
||||
validity: { from: now, to: new Date(new Date(now).getTime() + ctx.config.bidPermitTtlMs).toISOString() },
|
||||
evidence_versions: { policy_pack_version: p0.lineage.policy_pack_version, ledger_version: ctx.ledger.read().version, data_snapshot_refs: p0.lineage.data_refs },
|
||||
...(resumeData.comment ? { comment: resumeData.comment } : {}),
|
||||
decided_at: now,
|
||||
}
|
||||
ctx.caseDesk.recordApproval(inputData.case_id, approval)
|
||||
ctx.caseDesk.clearPending(p0.id)
|
||||
if (approval.decision === 'REJECT') {
|
||||
const p = transition(ctx, inputData.case_id, p0, 'REJECTED', { by: approval.approver.id, comment: resumeData.comment ?? null })
|
||||
return finish({ ...inputData, proposal: p, approvals: [approval] }, 'REJECTED', [`rejected by ${approval.approver.id}`])
|
||||
}
|
||||
const p = transition(ctx, inputData.case_id, p0, 'APPROVED', { by: approval.approver.id })
|
||||
return { ...inputData, proposal: p, approvals: [approval] }
|
||||
}
|
||||
|
||||
// —— Automatic path.
|
||||
let p = transition(ctx, inputData.case_id, p0, 'ENVELOPE_CHECK')
|
||||
if (inputData.simulation_alert) {
|
||||
// One vote: any simulation alert escalates to a human, no exceptions.
|
||||
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'simulation alert', alerts: inputData.simulation_alerts })
|
||||
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: 'L2', reasons: inputData.simulation_alerts, since: now })
|
||||
return await suspend({ reason: 'simulation alert', proposal_id: p.id, required_level: 'L2', reasons: inputData.simulation_alerts })
|
||||
}
|
||||
const match = ctx.envelopes.match(p, { now, priceBaseline: priceForecastFromLineage(ctx, p)?.quantiles.p50 ?? null })
|
||||
ctx.snapshots.put(match)
|
||||
if (match.within) {
|
||||
p = transition(ctx, inputData.case_id, { ...p, envelope_ref: match.envelope_id }, 'AUTO_APPROVED', { envelope_id: match.envelope_id })
|
||||
return { ...inputData, proposal: p, envelope_match: match }
|
||||
}
|
||||
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'outside envelope', reasons: match.reasons })
|
||||
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: match.required_level, reasons: match.reasons, since: now })
|
||||
return await suspend({ reason: 'outside envelope', proposal_id: p.id, required_level: match.required_level, reasons: match.reasons })
|
||||
},
|
||||
})
|
||||
|
||||
const freshCheckAndPermit = createStep({
|
||||
id: 'fresh-check',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData
|
||||
const now = ctx.clock()
|
||||
let p = transition(ctx, inputData.case_id, inputData.proposal, 'FRESH_CHECK')
|
||||
const result = ctx.authority.authorize(
|
||||
p,
|
||||
{ approvals: inputData.approvals, envelopeMatch: (inputData.envelope_match as Parameters<typeof ctx.authority.authorize>[1]['envelopeMatch']) ?? null },
|
||||
{ now, ledger: ctx.ledger, currentPolicyPackVersion: ctx.policy.current(ctx.config.policyPackId).version, offlineResources: [], permitTtlMs: ctx.config.bidPermitTtlMs },
|
||||
)
|
||||
if (isStaleDenial(result)) {
|
||||
p = transition(ctx, inputData.case_id, p, 'STALE', { reasons: result.reasons })
|
||||
return finish({ ...inputData, proposal: p }, 'STALE', result.reasons)
|
||||
}
|
||||
ctx.caseDesk.recordPermit(inputData.case_id, result)
|
||||
p = transition(ctx, inputData.case_id, p, 'AUTHORIZED', { permit_id: result.id, expires_at: result.expires_at })
|
||||
return { ...inputData, proposal: p, done: null, envelope_match: inputData.envelope_match, approvals: inputData.approvals, permit: result } as Carry & { permit: unknown }
|
||||
},
|
||||
})
|
||||
|
||||
const release = createStep({
|
||||
id: 'release',
|
||||
inputSchema: Carry.extend({ permit: z.unknown().optional() }),
|
||||
outputSchema: LifecycleOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData.done
|
||||
const now = ctx.clock()
|
||||
const permitId = (inputData.permit as { id: string } | undefined)?.id
|
||||
const permit = permitId ? ctx.authority.permits.get(permitId)?.value : undefined
|
||||
if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring')
|
||||
try {
|
||||
const receipt = ctx.gateway.dispatch(inputData.proposal, permit, now)
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'RELEASED', { receipt_id: receipt.receipt_id, artifact_ref: receipt.artifact_ref })
|
||||
ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
ctx.ledger.append({
|
||||
id: ctx.newId('pos'),
|
||||
timescale: 'DAY_AHEAD',
|
||||
period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10),
|
||||
kind: 'BID_SUBMITTED',
|
||||
energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0',
|
||||
curve: p.type === 'BID' ? p.payload.quantities_mwh : null,
|
||||
source_ref: p.digest,
|
||||
expected_version: ctx.ledger.read().version,
|
||||
})
|
||||
ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true)
|
||||
return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] }
|
||||
} catch (e) {
|
||||
if (e instanceof GatewayRejected) {
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'STALE', { gateway_reasons: e.reasons })
|
||||
return { outcome: 'STALE' as const, proposal: p, permit_id: permit.id, receipt_id: null, reasons: e.reasons }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'proposal-lifecycle',
|
||||
inputSchema: LifecycleInput,
|
||||
outputSchema: LifecycleOutput,
|
||||
})
|
||||
.then(ruleCheck)
|
||||
.then(simulation)
|
||||
.then(envelopeGate)
|
||||
.then(freshCheckAndPermit)
|
||||
.then(release)
|
||||
.commit()
|
||||
|
||||
}
|
||||
|
||||
export const ENVELOPE_GATE_STEP_ID = 'envelope-gate'
|
||||
@ -1,54 +0,0 @@
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApi } from '../src/api.js'
|
||||
import { TriggerService } from '../src/trigger.js'
|
||||
import { MARKET_DATE, harness } from './helpers.js'
|
||||
|
||||
async function serve(h: Awaited<ReturnType<typeof harness>>) {
|
||||
const server = createApi(h.rt, new TriggerService(h.rt))
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
|
||||
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const call = async (method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<{ status: number; body: any }> => {
|
||||
const init: RequestInit = { method, headers: { 'content-type': 'application/json', ...headers } }
|
||||
if (body !== undefined) init.body = JSON.stringify(body)
|
||||
const res = await fetch(base + path, init)
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
return { call, close: () => new Promise<void>((r) => server.close(() => r())) }
|
||||
}
|
||||
|
||||
describe('Case Desk HTTP API', () => {
|
||||
it('inbox → decide (approve) → case view + lineage expansion', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const { call, close } = await serve(h)
|
||||
expect((await call('GET', '/health')).body).toEqual({ status: 'ok', llm: 'null' })
|
||||
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const out = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
if (out.status !== 'success') throw new Error(out.status)
|
||||
|
||||
const inbox = await call('GET', '/inbox')
|
||||
expect(inbox.body).toHaveLength(1)
|
||||
const { run_id, proposal_id, case_id } = inbox.body[0]
|
||||
|
||||
expect((await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' })).status).toBe(401)
|
||||
const refused = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' }, { 'x-user-id': 'trading-agent', 'x-user-role': 'senior-trader' })
|
||||
expect(refused.body.status).toBe('suspended') // I1: still waiting for a legitimate human
|
||||
|
||||
const ok = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve', comment: 'fine' }, { 'x-user-id': 'user-trader-01', 'x-user-role': 'senior-trader' })
|
||||
expect(ok.body).toMatchObject({ run_id, status: 'success', outcome: 'RELEASED' })
|
||||
expect((await call('GET', '/inbox')).body).toHaveLength(0)
|
||||
|
||||
const view = await call('GET', `/cases/${case_id}`)
|
||||
expect(view.body.case.status).toBe('CLOSED_DONE')
|
||||
expect(view.body.approvals[0].approver.id).toBe('user-trader-01')
|
||||
const lineage = await call('GET', `/proposals/${proposal_id}/lineage`)
|
||||
expect(lineage.body.tool_calls.map((t: { tool: string }) => t.tool)).toContain('bid-optimization-milp')
|
||||
expect((await call('GET', `/events?correlation_id=${case_id}`)).body.length).toBeGreaterThan(8)
|
||||
expect((await call('GET', '/nope')).status).toBe(404)
|
||||
expect((await call('POST', '/tasks', { message: 'hi' })).status).toBe(503) // router needs an LLM
|
||||
await close()
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
@ -1,102 +0,0 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain'
|
||||
import { MemoryTimeSeriesStore } from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import { createRuntime } from '../src/runtime.js'
|
||||
import type { RuntimeOptions } from '../src/runtime.js'
|
||||
import type { LlmPort } from '../src/llm.js'
|
||||
|
||||
const datasetPath = fileURLToPath(new URL('../../evals/datasets/synthetic-hubei-v0.json', import.meta.url))
|
||||
|
||||
interface Day { date: string; load_mw: string[]; pv_mw: string[]; price_yuan_per_mwh: string[]; adjustable_capacity_mw: string[] }
|
||||
|
||||
export const MARKET_DATE = '2026-03-15'
|
||||
export const NOW = '2026-03-14T00:00:00Z' // D-1 08:00 Asia/Shanghai
|
||||
|
||||
/**
|
||||
* Deterministic stand-in for the Python skill service: forecast = last
|
||||
* history day with a ±spread band; bid = flat energy at the position's
|
||||
* lower bound offered at `offer` — enough to steer the lifecycle down every
|
||||
* branch (envelope in/out, simulation alert) from tests.
|
||||
*/
|
||||
export class StubSkills implements SkillClient {
|
||||
spread = 0.1
|
||||
offer = '0.00'
|
||||
energyFraction = 0 // 0 → E_min, 1 → E_max
|
||||
calls: string[] = []
|
||||
|
||||
async skills() {
|
||||
return ['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'].map((id) => ({ id, version: '0.0.1', endpoint: '/stub' }))
|
||||
}
|
||||
async forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle> {
|
||||
this.calls.push(`forecast:${kind}`)
|
||||
const last = req.history[req.history.length - 1]!
|
||||
const scaled = (f: number) => ({ interval_minutes: 15 as const, date: req.market_date, values: last.values.map((v) => (Number(v) * f).toFixed(kind === 'PRICE' ? 2 : 3)) })
|
||||
return {
|
||||
id: `fc-${kind.toLowerCase()}-${req.market_date}`, kind, market_date: req.market_date, unit: req.unit,
|
||||
quantiles: { p10: scaled(1 - this.spread), p50: scaled(1), p90: scaled(1 + this.spread) },
|
||||
model: { name: `${kind.toLowerCase()}-forecast`, version: '0.0.1' }, features_snapshot_ref: req.features_snapshot_ref, generated_at: '2026-03-14T06:00:00Z',
|
||||
}
|
||||
}
|
||||
async optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult> {
|
||||
this.calls.push('milp')
|
||||
const lo = Number(req.position_bounds.daily_energy_min_mwh)
|
||||
const hi = Number(req.position_bounds.daily_energy_max_mwh)
|
||||
const per = ((lo + (hi - lo) * this.energyFraction) / 96).toFixed(3)
|
||||
const q = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] }
|
||||
const energy = (Number(per) * 96).toFixed(3)
|
||||
const p50 = req.price_forecast.quantiles.p50.values
|
||||
const revenue = p50.reduce((s, p) => s + (Number(this.offer) <= Number(p) ? Number(p) * Number(per) : 0), 0).toFixed(2)
|
||||
return {
|
||||
market_date: req.market_date, prices_yuan_per_mwh: { ...q, values: Array(96).fill(this.offer) }, quantities_mwh: q, daily_energy_mwh: energy,
|
||||
expected_revenue_yuan: revenue, revenue_distribution_yuan: { p10: revenue, p50: revenue, p90: revenue }, position_bounds: req.position_bounds,
|
||||
solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: revenue, wall_time_ms: 1 }, binding_constraints: ['daily_energy_min'], skill_version: '0.0.1',
|
||||
}
|
||||
}
|
||||
async report(_r: ReportRequest): Promise<SkillReport> { throw new Error('not used') }
|
||||
}
|
||||
|
||||
export function seededTimeseries(clock: () => string) {
|
||||
const ts = new MemoryTimeSeriesStore(clock)
|
||||
const days = (JSON.parse(readFileSync(datasetPath, 'utf8')) as { days: Day[] }).days
|
||||
for (const d of days) {
|
||||
ts.write('load:aggregate', { interval_minutes: 15, date: d.date, values: d.load_mw }, 'synthetic')
|
||||
ts.write('pv:aggregate', { interval_minutes: 15, date: d.date, values: d.pv_mw }, 'synthetic')
|
||||
ts.write('price:da', { interval_minutes: 15, date: d.date, values: d.price_yuan_per_mwh }, 'synthetic')
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
|
||||
id: 'env-bid-001',
|
||||
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
|
||||
bounds,
|
||||
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
|
||||
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
|
||||
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
})
|
||||
|
||||
export interface Harness { rt: Awaited<ReturnType<typeof createRuntime>>; skills: StubSkills; dataDir: string; setNow: (iso: string) => void }
|
||||
|
||||
/** Fresh runtime on a temp dir, seeded like docs/07 D-1: history, one storage resource, a monthly contract. */
|
||||
export async function harness(opts: { llm?: LlmPort | null; dataDir?: string; seed?: boolean; config?: RuntimeOptions['config'] } = {}): Promise<Harness> {
|
||||
let now = NOW
|
||||
const clock = () => now
|
||||
const skills = new StubSkills()
|
||||
const dataDir = opts.dataDir ?? mkdtempSync(join(tmpdir(), 'vpp-rt-'))
|
||||
const rt = await createRuntime({ dataDir, llm: opts.llm ?? null, skills, clock, timeseries: seededTimeseries(clock), ...(opts.config ? { config: opts.config } : {}) })
|
||||
if (opts.seed !== false) {
|
||||
rt.ctx.resources.put({
|
||||
resource_id: 'res-storage-01', name: 'Wuhan storage #1', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '24.0', confidence: '0.9', reliability_score: '0.9',
|
||||
constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW,
|
||||
})
|
||||
// 24 MW × 0.9 × 0.25 h × 96 = 518.4 MWh sellable/day; contract 60% of it → daily share 311.04, band ±5%
|
||||
rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
|
||||
}
|
||||
return { rt, skills, dataDir, setNow: (iso) => (now = iso) }
|
||||
}
|
||||
@ -1,292 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Proposal } from '@vpp/domain'
|
||||
import { proposalDigest } from '@vpp/services'
|
||||
import { ScriptedLlm } from '../src/llm.js'
|
||||
import { TriggerService } from '../src/trigger.js'
|
||||
import { MARKET_DATE, envelope, harness } from './helpers.js'
|
||||
|
||||
const APPROVER = { id: 'user-trader-01', role: 'senior-trader' }
|
||||
|
||||
async function submitBid(h: Awaited<ReturnType<typeof harness>>) {
|
||||
const wf = h.rt.mastra.getWorkflow('dayAheadBid')
|
||||
const run = await wf.createRun()
|
||||
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
if (res.status !== 'success') throw new Error(`bid workflow ${res.status}: ${JSON.stringify((res as { error?: unknown }).error ?? res)}`)
|
||||
return res.result
|
||||
}
|
||||
|
||||
const statuses = (h: Awaited<ReturnType<typeof harness>>, caseId: string) =>
|
||||
h.rt.ctx.events.list({ event_type: 'ProposalStatusChanged', correlation_id: caseId }).map((e) => (e.payload as { status: string }).status)
|
||||
|
||||
describe('docs/07 timeline D-1 06:00 → 08:30 (LLM down: I6)', () => {
|
||||
it('situation → bid → lifecycle end-to-end, auto-approved inside the envelope, released as a bid file', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
|
||||
|
||||
// 06:00 situation
|
||||
const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun()
|
||||
const sres = await sit.start({ inputData: { market_date: MARKET_DATE } })
|
||||
expect(sres.status).toBe('success')
|
||||
if (sres.status !== 'success') return
|
||||
expect(sres.result.llm_used).toBe(false)
|
||||
expect(sres.result.report.findings[0]!.summary).toMatch(/template/)
|
||||
expect(sres.result.report.risk_level).toBe('LOW')
|
||||
expect(h.rt.ctx.events.list({ event_type: 'SituationPublished' })).toHaveLength(1)
|
||||
|
||||
// 08:00 bid → 08:30 chain
|
||||
const out = await submitBid(h)
|
||||
expect(out.llm_used).toBe(false)
|
||||
expect(out.lifecycle_status).toBe('success')
|
||||
expect(out.lifecycle?.outcome).toBe('RELEASED')
|
||||
expect(statuses(h, out.case_id)).toEqual(['DRAFT', 'RULE_CHECK', 'SIMULATION', 'ENVELOPE_CHECK', 'AUTO_APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
|
||||
|
||||
// P2: every payload number traces to the MILP tool output; P7: the ledger recorded the bid.
|
||||
const lin = h.rt.ctx.caseDesk.expandLineage(out.proposal_id)
|
||||
expect(lin.tool_calls.map((t) => t.tool)).toEqual(['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'])
|
||||
expect((lin.tool_calls[3]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe(out.proposal.payload.expected_revenue_yuan)
|
||||
expect(h.rt.ctx.ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1)
|
||||
expect(out.proposal.lineage.ledger_version).toBe(1)
|
||||
|
||||
// I5: receipt + file; case closed with the receipt as outcome.
|
||||
const view = h.rt.ctx.caseDesk.read(out.case_id)
|
||||
expect(view.case.status).toBe('CLOSED_DONE')
|
||||
expect(view.permits).toHaveLength(1)
|
||||
expect(existsSync(h.rt.ctx.gateway.receipts.list()[0]!.value.artifact_ref)).toBe(true)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('with an LLM, the agents narrate but the numbers are byte-identical to the LLM-down run', async () => {
|
||||
const draftFor = (prompt: string) => {
|
||||
const tc = /tool call id is (tc-[\w-]+)/.exec(prompt)![1]!
|
||||
return { market_date: MARKET_DATE, prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' }, rationale: 'Evening peak carries the position; offer as price-taker.' }
|
||||
}
|
||||
const llm = new ScriptedLlm({
|
||||
'analysis-agent': { findings: [{ kind: 'TREND', summary: 'Load near seasonal norm; no anomalies.' }] },
|
||||
'trading-agent': draftFor,
|
||||
})
|
||||
const withLlm = await harness({ llm })
|
||||
const without = await harness({ llm: null })
|
||||
for (const h of [withLlm, without]) h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const a = await submitBid(withLlm)
|
||||
const b = await submitBid(without)
|
||||
expect(a.llm_used).toBe(true)
|
||||
expect(b.llm_used).toBe(false)
|
||||
expect(a.proposal.payload).toEqual(b.proposal.payload)
|
||||
expect(llm.prompts.map((p) => p.agentId)).toContain('trading-agent')
|
||||
await withLlm.rt.close()
|
||||
await without.rt.close()
|
||||
})
|
||||
|
||||
it('an LLM draft that references a tool call outside lineage cannot become a proposal (P2)', async () => {
|
||||
const llm = new ScriptedLlm({
|
||||
'trading-agent': { market_date: MARKET_DATE, prices_ref: { tool_call_id: 'tc-forged', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-forged', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-forged', path: 'expected_revenue_yuan' }, rationale: 'x' },
|
||||
})
|
||||
const h = await harness({ llm })
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
expect(res.status).toBe('failed')
|
||||
if (res.status === 'failed') expect(String(res.error.message)).toMatch(/not in this proposal's lineage/)
|
||||
expect(h.rt.ctx.caseDesk.proposals.list()).toHaveLength(0)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('human approval path (PENDING_HUMAN → resume)', () => {
|
||||
it('suspends outside the envelope, surfaces in the inbox, and a human approve releases it', async () => {
|
||||
const h = await harness({ llm: null }) // no envelopes → everything manual
|
||||
const out = await submitBid(h)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
expect(statuses(h, out.case_id).at(-1)).toBe('PENDING_HUMAN')
|
||||
const inbox = h.rt.ctx.caseDesk.inbox()
|
||||
expect(inbox).toHaveLength(1)
|
||||
expect(inbox[0]!.run_id).toBe(out.lifecycle_run_id)
|
||||
expect(inbox[0]!.reasons[0]).toMatch(/no ACTIVE envelope/)
|
||||
|
||||
const triggers = new TriggerService(h.rt)
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER, comment: 'ok' })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
|
||||
expect(statuses(h, out.case_id).slice(-4)).toEqual(['APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
|
||||
const view = h.rt.ctx.caseDesk.read(out.case_id)
|
||||
expect(view.approvals[0]!.approver).toEqual(APPROVER)
|
||||
expect(view.approvals[0]!.proposal_digest).toBe(out.proposal.digest)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('I1: the AI (or anyone in the origination chain) cannot approve; the run stays suspended', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
const triggers = new TriggerService(h.rt)
|
||||
for (const approver of [{ id: 'trading-agent', role: 'senior-trader' }, { id: 'user-x', role: 'agent' }, { id: 'bid-optimization-milp', role: 'ops-lead' }]) {
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver })
|
||||
expect(res.status).toBe('suspended')
|
||||
}
|
||||
expect(h.rt.ctx.events.list({ event_type: 'ApprovalRefused' })).toHaveLength(3)
|
||||
expect(h.rt.ctx.caseDesk.read(out.case_id).approvals).toHaveLength(0)
|
||||
const still = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status
|
||||
expect(still).toBe('PENDING_HUMAN')
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a human reject ends the run as REJECTED', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'reject', approver: APPROVER, comment: 'too aggressive' })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('REJECTED')
|
||||
expect(h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('REJECTED')
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a simulation alert escalates to a human even inside the envelope (one vote)', async () => {
|
||||
const h = await harness({ llm: null, config: { worstCaseLossBudgetYuan: '0', risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '10000' } } })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const out = await submitBid(h)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/loss budget/)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('durability: a suspended approval survives process restart', () => {
|
||||
it('resumes on a fresh runtime over the same data dir', async () => {
|
||||
const h1 = await harness({ llm: null })
|
||||
const out = await submitBid(h1)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
await h1.rt.close()
|
||||
|
||||
const h2 = await harness({ llm: null, dataDir: h1.dataDir, seed: false })
|
||||
expect(h2.rt.ctx.caseDesk.inbox()[0]!.run_id).toBe(out.lifecycle_run_id)
|
||||
expect(h2.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('PENDING_HUMAN')
|
||||
// The ledger is in-memory in phase 1: replay its monthly anchor so the fresh check sees the same version.
|
||||
h2.rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
|
||||
const state = await h2.rt.mastra.getWorkflow('proposalLifecycle').getWorkflowRunById(out.lifecycle_run_id)
|
||||
expect(state?.status).toBe('suspended')
|
||||
|
||||
const res = await new TriggerService(h2.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
|
||||
expect(h2.rt.ctx.caseDesk.read(out.case_id).case.status).toBe('CLOSED_DONE')
|
||||
await h2.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleness and permits (I3/I4)', () => {
|
||||
it('approval goes STALE when the ledger moved between approval and fresh check', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
h.rt.ctx.ledger.append({ id: 'late', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
// Approval evidence is captured at resume time; the auto path captured version 1 in lineage.
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') {
|
||||
// The approval itself now references ledger v2 — consistent — so this passes fresh check;
|
||||
// the *auto-approved* path is the one bound to lineage v1. Exercise it below.
|
||||
expect(res.result.outcome).toBe('RELEASED')
|
||||
}
|
||||
await h.rt.close()
|
||||
|
||||
const g = await harness({ llm: null })
|
||||
g.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
// Move the ledger *during* the run: between fetch-context (lineage v1) and fresh check.
|
||||
g.rt.ctx.events.subscribe('Lifecycle.AUTO_APPROVED', () => {
|
||||
g.rt.ctx.ledger.append({ id: 'race', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: g.rt.ctx.ledger.read().version })
|
||||
})
|
||||
const out2 = await submitBid(g)
|
||||
expect(out2.lifecycle?.outcome).toBe('STALE')
|
||||
expect(out2.lifecycle?.reasons[0]).toMatch(/ledger version 1 at auto-approval, now 2/)
|
||||
expect(g.rt.ctx.gateway.receipts.list()).toHaveLength(0)
|
||||
await g.rt.close()
|
||||
})
|
||||
|
||||
it('permit expiry blocks a late release', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
// Resume after the permit TTL would already have passed relative to approval: the gate issues
|
||||
// the permit at `now`, then the gateway is asked at now + TTL + 1s.
|
||||
const triggers = new TriggerService(h.rt)
|
||||
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', () => h.setNow('2026-03-14T06:00:01Z')) // TTL is 6h
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') {
|
||||
expect(res.result.outcome).toBe('STALE')
|
||||
expect(res.result.reasons[0]).toMatch(/permit expired/)
|
||||
}
|
||||
expect(h.rt.ctx.gateway.receipts.list()).toHaveLength(0)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a revoked permit is refused by the gateway (kill switch)', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', (evt) => {
|
||||
h.rt.ctx.authority.revoke((evt.payload as { permit_id: string }).permit_id, 'L1 kill switch', h.rt.ctx.clock())
|
||||
})
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
if (res.status === 'success') expect(res.result.reasons[0]).toMatch(/revoked/)
|
||||
else throw new Error(res.status)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replay (I7) and authoritative store (I8)', () => {
|
||||
it('a released proposal re-checks identically from its snapshots and frozen pack version', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const out = await submitBid(h)
|
||||
const stored: Proposal = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value
|
||||
expect(proposalDigest(stored)).toBe(out.proposal.digest)
|
||||
const originalCheck = h.rt.ctx.events.list({ event_type: 'RuleCheckCompleted' })[0]!.payload as { result_ref: string }
|
||||
const recorded = h.rt.ctx.snapshots.get(originalCheck.result_ref) as { ok: boolean; rules_evaluated: string[] }
|
||||
// Replay against a ledger reconstructed to the lineage version (the bid append moved it to 2).
|
||||
const replayLedger = h.rt.ctx.ledger
|
||||
const replay = h.rt.ctx.policy.check({ ...stored, status: 'DRAFT' }, { id: 'hubei-spot-bidding', version: stored.lineage.policy_pack_version }, { ledger: { version: stored.lineage.ledger_version, entries: [] }, ledgerService: replayLedger, snapshots: h.rt.ctx.snapshots, now: h.rt.ctx.clock() })
|
||||
expect(replay.rules_evaluated).toEqual(recorded.rules_evaluated)
|
||||
expect(replay.ok).toBe(recorded.ok)
|
||||
// I8: the ledger (not any agent memory) is the source the proposal cites.
|
||||
expect(stored.lineage.ledger_version).toBe(1)
|
||||
expect(h.rt.ctx.ledger.read().entries.find((e) => e.kind === 'BID_SUBMITTED')?.source_ref).toBe(stored.digest)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger service', () => {
|
||||
it('scheduled entries fire once per local day and need no LLM', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const t = new TriggerService(h.rt)
|
||||
expect(await t.tick('2026-03-13T21:00:00Z')).toEqual([]) // 05:00 Shanghai
|
||||
expect(await t.tick('2026-03-13T22:30:00Z')).toEqual(['situation-0600'])
|
||||
expect(await t.tick('2026-03-14T00:05:00Z')).toEqual(['bid-0800'])
|
||||
expect(await t.tick('2026-03-14T00:10:00Z')).toEqual([])
|
||||
expect(h.rt.ctx.events.list({ event_type: 'WorkflowTriggered' }).map((e) => (e.payload as { workflow: string }).workflow)).toEqual(['dayAheadSituation', 'dayAheadBid'])
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('manual requests route to a registered template; router refuses when the LLM is down', async () => {
|
||||
const llm = new ScriptedLlm({ 'router-agent': { workflow_id: 'day-ahead-situation', params: { market_date: MARKET_DATE }, confidence: 'HIGH' } })
|
||||
const h = await harness({ llm })
|
||||
const t = new TriggerService(h.rt)
|
||||
const r = await t.manual('明天的态势报告')
|
||||
expect(r.decision.workflow_id).toBe('day-ahead-situation')
|
||||
expect(r.run_id).not.toBeNull()
|
||||
await h.rt.close()
|
||||
|
||||
const down = await harness({ llm: null })
|
||||
await expect(new TriggerService(down.rt).manual('anything')).rejects.toThrow(/pick a template explicitly/)
|
||||
await down.rt.close()
|
||||
})
|
||||
|
||||
it('an EXTREME situation opens an abnormal-day case (event trigger)', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.skills.spread = 1.2 // p90/p50 = 2.2 ≥ extreme ratio 2.0
|
||||
const t = new TriggerService(h.rt)
|
||||
t.startEventConsumers()
|
||||
await t.startTemplate('dayAheadSituation', { market_date: MARKET_DATE }, 'MANUAL', 'test')
|
||||
const cases = h.rt.ctx.caseDesk.cases.list().map((r) => r.value)
|
||||
expect(cases.some((c) => c.kind === 'ADHOC_ANALYSIS' && /Abnormal-day/.test(c.objective))).toBe(true)
|
||||
t.stop()
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@ -7,13 +7,11 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vpp/domain": "*",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"zod": "^4.1.0"
|
||||
"decimal.js-light": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
import type { Approval, EnvelopeMatch, ExecutionPermit, Proposal, StaleDenial } from '@vpp/domain'
|
||||
import { ExecutionPermit as PermitSchema } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
import type { LedgerService } from './ledger.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
/**
|
||||
* Authority service (docs/03 §2.2, I3/I4): fresh-state check + permit issue +
|
||||
* revocation. Approval ≠ execution: hours may pass, the world changes. The
|
||||
* permit is the only credential a gateway accepts, short-lived and revocable.
|
||||
*/
|
||||
export interface FreshCheckContext {
|
||||
now: string
|
||||
ledger: LedgerService
|
||||
currentPolicyPackVersion: string
|
||||
/** Resources referenced by the proposal that are currently offline/de-rated. */
|
||||
offlineResources: string[]
|
||||
/** Permit lifetime for this proposal type. Bids: until market deadline — OPEN-QUESTION A1. */
|
||||
permitTtlMs: number
|
||||
}
|
||||
|
||||
export interface ApprovalBasis {
|
||||
/** Human approvals (may be multiple levels) — or none when auto-approved. */
|
||||
approvals: Approval[]
|
||||
/** Envelope match that auto-approved, if any. */
|
||||
envelopeMatch: EnvelopeMatch | null
|
||||
}
|
||||
|
||||
export interface ApproverIdentity {
|
||||
id: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/**
|
||||
* I1/I2: an approver must be a human role outside the proposal's origination
|
||||
* chain. Agents, the runtime, and any id appearing as originator are rejected.
|
||||
*/
|
||||
export function assertApproverIndependent(proposal: Proposal, approver: ApproverIdentity, humanRoles: string[]): void {
|
||||
const chain = new Set([proposal.originator.agent, proposal.originator.task_id, ...proposal.lineage.tool_calls.map((t) => t.tool)])
|
||||
if (chain.has(approver.id)) throw new Error(`approver ${approver.id} is in the origination chain (I1)`)
|
||||
if (!humanRoles.includes(approver.role)) throw new Error(`role '${approver.role}' may not approve (I1/I2: AI cannot approve)`)
|
||||
}
|
||||
|
||||
export class AuthorityService {
|
||||
constructor(
|
||||
readonly permits: Repository<ExecutionPermit> = new MemoryRepository(PermitSchema, (p) => p.id),
|
||||
private readonly idGen: () => string = () => `permit-${Math.random().toString(36).slice(2, 10)}`,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fresh-state check then permit. Denial reasons are exhaustive so the case
|
||||
* view can show *why* an approval went stale.
|
||||
*/
|
||||
authorize(proposal: Proposal, basis: ApprovalBasis, ctx: FreshCheckContext): ExecutionPermit | StaleDenial {
|
||||
const reasons: string[] = []
|
||||
const ledgerVersion = ctx.ledger.read().version
|
||||
|
||||
if (basis.approvals.length === 0 && !basis.envelopeMatch?.within) {
|
||||
reasons.push('no approval basis: neither human approval nor envelope auto-approval')
|
||||
}
|
||||
for (const a of basis.approvals) {
|
||||
if (a.decision !== 'APPROVE') reasons.push(`approval ${a.id} is ${a.decision}`)
|
||||
if (a.proposal_digest !== proposal.digest) reasons.push(`approval ${a.id} bound to digest ${a.proposal_digest.slice(0, 8)}…, proposal is ${proposal.digest.slice(0, 8)}…`)
|
||||
if (ctx.now < a.validity.from || ctx.now > a.validity.to) reasons.push(`approval ${a.id} validity [${a.validity.from}, ${a.validity.to}] does not cover ${ctx.now}`)
|
||||
if (a.evidence_versions.ledger_version !== ledgerVersion) reasons.push(`ledger version ${a.evidence_versions.ledger_version} at approval, now ${ledgerVersion}`)
|
||||
if (a.evidence_versions.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${a.evidence_versions.policy_pack_version} at approval, now ${ctx.currentPolicyPackVersion}`)
|
||||
}
|
||||
if (basis.envelopeMatch?.within) {
|
||||
if (basis.envelopeMatch.proposal_digest !== proposal.digest) reasons.push('envelope match bound to a different digest')
|
||||
if (proposal.lineage.ledger_version !== ledgerVersion) reasons.push(`ledger version ${proposal.lineage.ledger_version} at auto-approval, now ${ledgerVersion}`)
|
||||
if (proposal.lineage.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${proposal.lineage.policy_pack_version} at check, now ${ctx.currentPolicyPackVersion}`)
|
||||
}
|
||||
if (ctx.offlineResources.length > 0) reasons.push(`resources offline since approval: ${ctx.offlineResources.join(', ')}`)
|
||||
|
||||
if (reasons.length > 0) return { proposal_digest: proposal.digest, reasons, checked_at: ctx.now }
|
||||
|
||||
// Effect limits: narrowest of approval scopes; energy actually offered otherwise.
|
||||
const limits: Record<string, string> = {}
|
||||
for (const a of basis.approvals) for (const [k, v] of Object.entries(a.scope.limits)) {
|
||||
limits[k] = limits[k] === undefined || new Decimal(v).lt(limits[k]!) ? v : limits[k]!
|
||||
}
|
||||
if (proposal.type === 'BID' && limits['max_energy_mwh'] === undefined) {
|
||||
limits['max_energy_mwh'] = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)).toString()
|
||||
}
|
||||
const issuedAt = new Date(ctx.now)
|
||||
let expires = new Date(issuedAt.getTime() + ctx.permitTtlMs)
|
||||
for (const a of basis.approvals) if (new Date(a.validity.to) < expires) expires = new Date(a.validity.to)
|
||||
|
||||
const permit: ExecutionPermit = {
|
||||
id: this.idGen(),
|
||||
proposal_digest: proposal.digest,
|
||||
issued_at: issuedAt.toISOString(),
|
||||
expires_at: expires.toISOString(),
|
||||
effect_limits: limits,
|
||||
revoked_at: null,
|
||||
issuer: 'authority-service',
|
||||
}
|
||||
this.permits.put(permit)
|
||||
return permit
|
||||
}
|
||||
|
||||
revoke(permitId: string, reason: string, now: string): void {
|
||||
const row = this.permits.get(permitId)
|
||||
if (!row) throw new Error(`permit ${permitId} not found`)
|
||||
if (row.value.revoked_at) return
|
||||
this.permits.put({ ...row.value, revoked_at: now }, row.version)
|
||||
void reason
|
||||
}
|
||||
|
||||
/** Gateway-side validation (I4): digest match, in window, not revoked, on record. */
|
||||
validate(permit: ExecutionPermit, proposal: Proposal, now: string): string[] {
|
||||
const reasons: string[] = []
|
||||
const stored = this.permits.get(permit.id)?.value
|
||||
if (!stored) reasons.push(`permit ${permit.id} not on record`)
|
||||
else if (stored.revoked_at) reasons.push(`permit ${permit.id} revoked at ${stored.revoked_at}`)
|
||||
if (permit.proposal_digest !== proposal.digest) reasons.push('permit digest does not match proposal')
|
||||
if (now < permit.issued_at || now >= permit.expires_at) reasons.push(`permit expired at ${permit.expires_at} (now ${now})`)
|
||||
if (proposal.type === 'BID' && permit.effect_limits['max_energy_mwh'] !== undefined) {
|
||||
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.gt(permit.effect_limits['max_energy_mwh'])) reasons.push(`energy ${total.toString()} exceeds permit limit ${permit.effect_limits['max_energy_mwh']}`)
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
|
||||
export const isStaleDenial = (x: ExecutionPermit | StaleDenial): x is StaleDenial => 'reasons' in x
|
||||
@ -1,180 +0,0 @@
|
||||
import type { Approval, CaseKind, DecisionCase, ExecutionPermit, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { Approval as ApprovalSchema, DecisionCase as DecisionCaseSchema, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import type { AuthorityService } from './authority.js'
|
||||
import type { EventBus } from './events.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Case Desk (docs/02 §5, ADR-0008): the operator's unit of accountability.
|
||||
* A case references proposals/approvals/permits by id — never copies — and
|
||||
* the inbox and case view are projections over those stores plus the event
|
||||
* log. Approval itself is a workflow resume (docs/09 §2); the desk only
|
||||
* records the human decision and hands it to the runtime's resume hook.
|
||||
*/
|
||||
export interface PendingApproval {
|
||||
proposal_id: string
|
||||
proposal_digest: string
|
||||
case_id: string
|
||||
run_id: string
|
||||
required_level: string
|
||||
reasons: string[]
|
||||
since: string
|
||||
}
|
||||
|
||||
export interface CaseView {
|
||||
case: DecisionCase
|
||||
proposals: Array<{ proposal: Proposal; pending: PendingApproval | null }>
|
||||
approvals: Approval[]
|
||||
permits: ExecutionPermit[]
|
||||
events: Array<{ event_id: string; event_type: string; occurred_at: string }>
|
||||
}
|
||||
|
||||
export interface LineageExpansion {
|
||||
proposal_id: string
|
||||
digest: string
|
||||
ledger_version: number
|
||||
policy_pack_version: string
|
||||
tool_calls: Array<ToolCallRef & { inputs: unknown; outputs: unknown }>
|
||||
data_refs: Array<{ ref: string; present: boolean }>
|
||||
}
|
||||
|
||||
export interface CaseDeskDeps {
|
||||
cases?: Repository<DecisionCase>
|
||||
proposals?: Repository<Proposal>
|
||||
approvals?: Repository<Approval>
|
||||
pending?: Repository<PendingApproval>
|
||||
authority: AuthorityService
|
||||
snapshots: SnapshotStore
|
||||
events: EventBus
|
||||
clock: () => string
|
||||
}
|
||||
|
||||
export class CaseDeskService {
|
||||
readonly cases: Repository<DecisionCase>
|
||||
readonly proposals: Repository<Proposal>
|
||||
readonly approvals: Repository<Approval>
|
||||
readonly pending: Repository<PendingApproval>
|
||||
|
||||
constructor(private readonly deps: CaseDeskDeps) {
|
||||
this.cases = deps.cases ?? new MemoryRepository(DecisionCaseSchema, (c) => c.id)
|
||||
this.proposals = deps.proposals ?? new MemoryRepository(ProposalSchema, (p) => p.id)
|
||||
this.approvals = deps.approvals ?? new MemoryRepository(ApprovalSchema, (a) => a.id)
|
||||
this.pending = deps.pending ?? new MemoryRepository(PendingApprovalSchema, (p) => p.proposal_id)
|
||||
}
|
||||
|
||||
open(input: { id: string; kind: CaseKind; objective: string; owner: string; deadline: string }): DecisionCase {
|
||||
const now = this.deps.clock()
|
||||
const c: DecisionCase = {
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
objective: input.objective,
|
||||
owner: input.owner,
|
||||
deadline: input.deadline,
|
||||
status: 'OPEN',
|
||||
refs: { evidence: [], proposals: [], approvals: [], permits: [], scenarios: [], outcome: null },
|
||||
opened_at: now,
|
||||
closed_at: null,
|
||||
}
|
||||
this.cases.put(c)
|
||||
this.deps.events.append({ event_type: 'CaseOpened', payload: { case_id: c.id, kind: c.kind }, correlation_id: c.id })
|
||||
return c
|
||||
}
|
||||
|
||||
private update(caseId: string, fn: (c: DecisionCase) => DecisionCase): DecisionCase {
|
||||
const row = this.cases.get(caseId)
|
||||
if (!row) throw new Error(`case ${caseId} not found`)
|
||||
return this.cases.put(fn(structuredClone(row.value)), row.version).value
|
||||
}
|
||||
|
||||
addEvidence(caseId: string, refs: string[]): void {
|
||||
this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, evidence: [...new Set([...c.refs.evidence, ...refs])] }, status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status }))
|
||||
}
|
||||
|
||||
/** Called by the lifecycle workflow whenever the proposal changes status. */
|
||||
recordProposal(caseId: string, proposal: Proposal): void {
|
||||
this.proposals.put(proposal)
|
||||
this.update(caseId, (c) => ({
|
||||
...c,
|
||||
status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status,
|
||||
refs: { ...c.refs, proposals: [...new Set([...c.refs.proposals, proposal.id])] },
|
||||
}))
|
||||
this.deps.events.append({ event_type: 'ProposalStatusChanged', payload: { proposal_id: proposal.id, digest: proposal.digest, status: proposal.status }, correlation_id: caseId, causation_id: proposal.id })
|
||||
}
|
||||
|
||||
/** Lifecycle suspended at the envelope gate: surface it in the inbox. */
|
||||
requestApproval(p: PendingApproval): void {
|
||||
this.pending.put(p)
|
||||
this.update(p.case_id, (c) => ({ ...c, status: 'AWAITING_APPROVAL' }))
|
||||
this.deps.events.append({ event_type: 'ApprovalRequested', payload: p, correlation_id: p.case_id, causation_id: p.proposal_id })
|
||||
}
|
||||
|
||||
inbox(): PendingApproval[] {
|
||||
return this.pending.list().map((r) => r.value).sort((a, b) => (a.since < b.since ? -1 : 1))
|
||||
}
|
||||
|
||||
recordApproval(caseId: string, approval: Approval): void {
|
||||
this.approvals.put(approval)
|
||||
const pending = this.pending.list().find((r) => r.value.proposal_digest === approval.proposal_digest)
|
||||
if (pending) this.pending.put({ ...pending.value, run_id: '' }) // keep for audit; runtime clears
|
||||
this.update(caseId, (c) => ({ ...c, status: 'IN_PROGRESS', refs: { ...c.refs, approvals: [...new Set([...c.refs.approvals, approval.id])] } }))
|
||||
this.deps.events.append({ event_type: 'HumanDecision', payload: approval, correlation_id: caseId, causation_id: approval.proposal_digest })
|
||||
}
|
||||
|
||||
clearPending(proposalId: string): void {
|
||||
const row = this.pending.get(proposalId)
|
||||
if (row) this.pending.put({ ...row.value, run_id: '' }, row.version)
|
||||
// MemoryRepository has no delete; a cleared entry is one with run_id ''.
|
||||
}
|
||||
|
||||
recordPermit(caseId: string, permit: ExecutionPermit): void {
|
||||
this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, permits: [...new Set([...c.refs.permits, permit.id])] } }))
|
||||
}
|
||||
|
||||
close(caseId: string, outcome: string | null, done: boolean): DecisionCase {
|
||||
const c = this.update(caseId, (c) => ({ ...c, status: done ? 'CLOSED_DONE' : 'CLOSED_ABORTED', refs: { ...c.refs, outcome }, closed_at: this.deps.clock() }))
|
||||
this.deps.events.append({ event_type: 'CaseClosed', payload: { case_id: caseId, status: c.status, outcome }, correlation_id: caseId })
|
||||
return c
|
||||
}
|
||||
|
||||
read(caseId: string): CaseView {
|
||||
const row = this.cases.get(caseId)
|
||||
if (!row) throw new Error(`case ${caseId} not found`)
|
||||
const c = row.value
|
||||
const pendingByProposal = new Map(this.pending.list().filter((r) => r.value.run_id !== '').map((r) => [r.value.proposal_id, r.value]))
|
||||
return {
|
||||
case: c,
|
||||
proposals: c.refs.proposals.map((id) => ({ proposal: this.proposals.get(id)!.value, pending: pendingByProposal.get(id) ?? null })),
|
||||
approvals: c.refs.approvals.map((id) => this.approvals.get(id)!.value),
|
||||
permits: c.refs.permits.map((id) => this.deps.authority.permits.get(id)!.value),
|
||||
events: this.deps.events.list({ correlation_id: caseId }).map((e) => ({ event_id: e.event_id, event_type: e.event_type, occurred_at: e.occurred_at })),
|
||||
}
|
||||
}
|
||||
|
||||
/** P5 in the UI: conclusion → tool outputs → data sources. */
|
||||
expandLineage(proposalId: string): LineageExpansion {
|
||||
const row = this.proposals.get(proposalId)
|
||||
if (!row) throw new Error(`proposal ${proposalId} not found`)
|
||||
const p = row.value
|
||||
return {
|
||||
proposal_id: p.id,
|
||||
digest: p.digest,
|
||||
ledger_version: p.lineage.ledger_version,
|
||||
policy_pack_version: p.lineage.policy_pack_version,
|
||||
tool_calls: p.lineage.tool_calls.map((tc) => ({ ...tc, inputs: this.deps.snapshots.get(tc.inputs_ref), outputs: this.deps.snapshots.get(tc.outputs_ref) })),
|
||||
data_refs: p.lineage.data_refs.map((ref) => ({ ref, present: this.deps.snapshots.has(ref) })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { z } from 'zod'
|
||||
export const PendingApprovalSchema = z.object({
|
||||
proposal_id: z.string().min(1),
|
||||
proposal_digest: z.string().min(1),
|
||||
case_id: z.string().min(1),
|
||||
run_id: z.string(),
|
||||
required_level: z.string().min(1),
|
||||
reasons: z.array(z.string()),
|
||||
since: z.string().min(1),
|
||||
})
|
||||
@ -1,12 +0,0 @@
|
||||
import decimalPkg from 'decimal.js-light'
|
||||
|
||||
/**
|
||||
* decimal.js-light ships CommonJS (`module.exports = Decimal`, plus
|
||||
* `Decimal.Decimal = Decimal`). Node ESM exposes only a default export; some
|
||||
* bundlers/test runners expose the class itself as the default. Resolve once
|
||||
* here so callers just `import { Decimal } from './decimal.js'`.
|
||||
*/
|
||||
type DecimalCtor = typeof decimalPkg.Decimal
|
||||
const resolved: unknown = (decimalPkg as { Decimal?: unknown }).Decimal ?? decimalPkg
|
||||
export const Decimal = resolved as DecimalCtor
|
||||
export type Decimal = InstanceType<DecimalCtor>
|
||||
@ -1,12 +0,0 @@
|
||||
import type { Proposal } from '@vpp/domain'
|
||||
import { canonicalJson, contentRef } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Proposal digest (docs/03 §1): sha256 over canonical payload + lineage. It is
|
||||
* what approvals and permits bind to (I3/I4) — any change to a number or to
|
||||
* its provenance yields a different digest and therefore a different approval.
|
||||
*/
|
||||
export const proposalDigest = (p: Pick<Proposal, 'payload' | 'lineage'>): string =>
|
||||
contentRef(canonicalJson({ payload: p.payload, lineage: p.lineage }))
|
||||
|
||||
export const digestMatches = (p: Proposal): boolean => proposalDigest(p) === p.digest
|
||||
@ -1,107 +0,0 @@
|
||||
import type { ApprovalLevel, Curve96, Envelope, EnvelopeMatch, Proposal } from '@vpp/domain'
|
||||
import { Envelope as EnvelopeSchema } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
/**
|
||||
* Envelope gate (docs/03 §3, P3 tiered autonomy). Human-approved envelopes
|
||||
* define what may auto-approve; anything outside, or with no ACTIVE envelope,
|
||||
* needs a human. Bound keys for BID (docs/03 §3, values OPEN-QUESTION B1):
|
||||
* price_deviation_pct — |offer − baseline| / baseline ≤ pct, per interval
|
||||
* max_energy_mwh — Σ quantities ≤ max
|
||||
* The empty envelope set is the "everything manual" autonomy dial position.
|
||||
*/
|
||||
export interface EnvelopeContext {
|
||||
now: string
|
||||
/** Baseline the price bound is relative to (the P50 price forecast in lineage). */
|
||||
priceBaseline: Curve96 | null
|
||||
/** Level required when no envelope applies. OPEN-QUESTION B3. */
|
||||
fallbackLevel?: ApprovalLevel
|
||||
}
|
||||
|
||||
export class EnvelopeService {
|
||||
constructor(readonly repo: Repository<Envelope> = new MemoryRepository(EnvelopeSchema, (e) => e.id)) {}
|
||||
|
||||
register(envelope: Envelope): void {
|
||||
this.repo.put(envelope)
|
||||
}
|
||||
|
||||
match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch {
|
||||
const fallback = ctx.fallbackLevel ?? 'L2'
|
||||
const candidates = this.repo
|
||||
.list()
|
||||
.map((r) => r.value)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.status === 'ACTIVE' &&
|
||||
e.scope.proposal_type === proposal.type &&
|
||||
e.scope.timescales.includes(proposal.timescale) &&
|
||||
e.validity.from <= ctx.now &&
|
||||
ctx.now <= e.validity.to,
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: fallback,
|
||||
reasons: [`no ACTIVE envelope covers ${proposal.type}/${proposal.timescale} at ${ctx.now}`],
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
|
||||
const reasonsByEnvelope: string[] = []
|
||||
for (const env of candidates) {
|
||||
const reasons = this.checkBounds(proposal, env, ctx)
|
||||
if (reasons.length === 0) {
|
||||
return { proposal_digest: proposal.digest, within: true, envelope_id: env.id, required_level: env.approval.level, reasons: [], checked_at: ctx.now }
|
||||
}
|
||||
reasonsByEnvelope.push(...reasons.map((r) => `${env.id}: ${r}`))
|
||||
}
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: fallback,
|
||||
reasons: reasonsByEnvelope,
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
|
||||
private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] {
|
||||
if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1']
|
||||
const reasons: string[] = []
|
||||
const maxEnergy = env.bounds['max_energy_mwh']
|
||||
if (maxEnergy !== undefined) {
|
||||
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.gt(maxEnergy)) reasons.push(`daily energy ${total.toString()} > max_energy_mwh ${maxEnergy}`)
|
||||
}
|
||||
const pct = env.bounds['price_deviation_pct']
|
||||
if (pct !== undefined) {
|
||||
if (!ctx.priceBaseline) {
|
||||
reasons.push('price_deviation_pct bound requires a price baseline in lineage')
|
||||
} else {
|
||||
const tol = new Decimal(pct).div(100)
|
||||
let worst: { i: number; dev: Decimal } | null = null
|
||||
proposal.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
|
||||
const base = new Decimal(ctx.priceBaseline!.values[i]!)
|
||||
const offer = new Decimal(v)
|
||||
// A price-taker offer (0) never exceeds the baseline; only offers above baseline are risky.
|
||||
if (offer.lte(base)) return
|
||||
if (base.eq(0)) {
|
||||
worst = { i, dev: new Decimal('Infinity' as unknown as number) }
|
||||
return
|
||||
}
|
||||
const dev = offer.sub(base).div(base)
|
||||
if (dev.gt(tol) && (!worst || dev.gt(worst.dev))) worst = { i, dev }
|
||||
})
|
||||
if (worst) {
|
||||
const w = worst as { i: number; dev: Decimal }
|
||||
reasons.push(`interval ${w.i}: offer deviates ${w.dev.mul(100).toFixed(2)}% above baseline, bound ${pct}%`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { EventEnvelope } from '@vpp/domain'
|
||||
import { EventEnvelope as EventEnvelopeSchema } from '@vpp/domain'
|
||||
|
||||
/**
|
||||
* Event store + bus (P5 event sourcing, ADR-0004 outbox). Append-only log of
|
||||
* EventEnvelopes; consumers subscribe by type. Phase-1 backing is a JSONL
|
||||
* file (survives restarts, trivially auditable); the Postgres outbox adapter
|
||||
* implements the same interface without leaking storage specifics.
|
||||
*/
|
||||
export interface EventFilter {
|
||||
event_type?: string
|
||||
correlation_id?: string
|
||||
}
|
||||
|
||||
export type EventHandler = (evt: EventEnvelope) => void | Promise<void>
|
||||
|
||||
export interface EventBus {
|
||||
append(input: {
|
||||
event_type: string
|
||||
payload: unknown
|
||||
correlation_id: string
|
||||
causation_id?: string
|
||||
schema_version?: string
|
||||
}): EventEnvelope
|
||||
list(filter?: EventFilter): EventEnvelope[]
|
||||
subscribe(event_type: string, handler: EventHandler): () => void
|
||||
}
|
||||
|
||||
export class MemoryEventBus implements EventBus {
|
||||
protected readonly events: EventEnvelope[] = []
|
||||
private readonly handlers = new Map<string, Set<EventHandler>>()
|
||||
private seq = 0
|
||||
|
||||
constructor(
|
||||
protected readonly clock: () => string = () => new Date().toISOString(),
|
||||
protected readonly prefix = 'evt',
|
||||
) {}
|
||||
|
||||
append(input: {
|
||||
event_type: string
|
||||
payload: unknown
|
||||
correlation_id: string
|
||||
causation_id?: string
|
||||
schema_version?: string
|
||||
}): EventEnvelope {
|
||||
const evt: EventEnvelope = EventEnvelopeSchema.parse({
|
||||
event_id: `${this.prefix}-${String(++this.seq).padStart(6, '0')}`,
|
||||
event_type: input.event_type,
|
||||
schema_version: input.schema_version ?? '1.0.0',
|
||||
occurred_at: this.clock(),
|
||||
causation_id: input.causation_id ?? input.correlation_id,
|
||||
correlation_id: input.correlation_id,
|
||||
payload: input.payload,
|
||||
})
|
||||
this.persist(evt)
|
||||
this.events.push(evt)
|
||||
for (const h of this.handlers.get(evt.event_type) ?? []) void h(evt)
|
||||
for (const h of this.handlers.get('*') ?? []) void h(evt)
|
||||
return evt
|
||||
}
|
||||
|
||||
protected persist(_evt: EventEnvelope): void {}
|
||||
|
||||
list(filter: EventFilter = {}): EventEnvelope[] {
|
||||
return this.events.filter(
|
||||
(e) =>
|
||||
(filter.event_type === undefined || e.event_type === filter.event_type) &&
|
||||
(filter.correlation_id === undefined || e.correlation_id === filter.correlation_id),
|
||||
)
|
||||
}
|
||||
|
||||
subscribe(event_type: string, handler: EventHandler): () => void {
|
||||
const set = this.handlers.get(event_type) ?? new Set()
|
||||
set.add(handler)
|
||||
this.handlers.set(event_type, set)
|
||||
return () => set.delete(handler)
|
||||
}
|
||||
|
||||
protected load(events: EventEnvelope[]): void {
|
||||
this.events.push(...events)
|
||||
this.seq = events.length
|
||||
}
|
||||
}
|
||||
|
||||
/** JSONL-backed bus: every appended event is fsync'd to disk before handlers run. */
|
||||
export class FileEventBus extends MemoryEventBus {
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
clock?: () => string,
|
||||
) {
|
||||
super(clock, 'evt')
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
if (existsSync(path)) {
|
||||
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
||||
this.load(lines.map((l) => EventEnvelopeSchema.parse(JSON.parse(l))))
|
||||
}
|
||||
}
|
||||
|
||||
protected override persist(evt: EventEnvelope): void {
|
||||
appendFileSync(this.path, JSON.stringify(evt) + '\n')
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain'
|
||||
import { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain'
|
||||
import type { AuthorityService } from './authority.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
export class GatewayRejected extends Error {
|
||||
constructor(readonly reasons: string[]) {
|
||||
super(`gateway rejected: ${reasons.join('; ')}`)
|
||||
this.name = 'GatewayRejected'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway port (docs/03 §2.2, docs/11 §1.1): accepts only (Proposal, Permit)
|
||||
* pairs, validates through the authority service, and is idempotent by
|
||||
* (digest, permit) — re-dispatch returns the persisted receipt (I5).
|
||||
*/
|
||||
export interface GatewayPort {
|
||||
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt
|
||||
}
|
||||
|
||||
/**
|
||||
* M3 release path: bid file for manual upload to the trading platform — the
|
||||
* degraded channel by design (docs/06, ROADMAP M3). The programmatic channel
|
||||
* is phase 2 (D2) and will implement the same port.
|
||||
*/
|
||||
export class FileExportGateway implements GatewayPort {
|
||||
constructor(
|
||||
private readonly authority: AuthorityService,
|
||||
private readonly exportDir: string,
|
||||
readonly receipts: Repository<ExecutionReceipt> = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key),
|
||||
) {
|
||||
mkdirSync(exportDir, { recursive: true })
|
||||
}
|
||||
|
||||
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt {
|
||||
const key = `${proposal.digest}:${permit.id}`
|
||||
const existing = this.receipts.get(key)
|
||||
if (existing) return existing.value
|
||||
|
||||
const reasons = this.authority.validate(permit, proposal, now)
|
||||
if (reasons.length > 0) throw new GatewayRejected(reasons)
|
||||
if (proposal.type !== 'BID') throw new GatewayRejected([`file export supports BID only, got ${proposal.type}`])
|
||||
|
||||
const file: BidExportFile = BidExportFileSchema.parse({
|
||||
format_version: '1.0.0',
|
||||
proposal_id: proposal.id,
|
||||
proposal_digest: proposal.digest,
|
||||
permit_id: permit.id,
|
||||
proposal_type: proposal.type,
|
||||
market_date: proposal.payload.market_date,
|
||||
prices_yuan_per_mwh: proposal.payload.prices_yuan_per_mwh,
|
||||
quantities_mwh: proposal.payload.quantities_mwh,
|
||||
exported_at: now,
|
||||
})
|
||||
const name = `bid-${file.market_date}-${proposal.id}-${permit.id}.json`
|
||||
const path = join(this.exportDir, name)
|
||||
if (!existsSync(path)) writeFileSync(path, JSON.stringify(file, null, 2) + '\n', { flag: 'wx' })
|
||||
|
||||
const receipt: ExecutionReceipt = {
|
||||
receipt_id: `rcpt-${proposal.id}-${permit.id}`,
|
||||
proposal_digest: proposal.digest,
|
||||
permit_id: permit.id,
|
||||
channel: 'FILE_EXPORT',
|
||||
idempotency_key: key,
|
||||
artifact_ref: path,
|
||||
accepted_at: now,
|
||||
}
|
||||
this.receipts.put(receipt)
|
||||
return receipt
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,3 @@
|
||||
export * from './snapshot.js'
|
||||
export * from './ledger.js'
|
||||
export * from './quality.js'
|
||||
export * from './timeseries.js'
|
||||
export * from './relational.js'
|
||||
export * from './ingest.js'
|
||||
export { Decimal } from './decimal.js'
|
||||
export * from './digest.js'
|
||||
export * from './policy.js'
|
||||
export * from './envelope.js'
|
||||
export * from './authority.js'
|
||||
export * from './gateway.js'
|
||||
export * from './events.js'
|
||||
export * from './lineage.js'
|
||||
export * from './simulation.js'
|
||||
export * from './casedesk.js'
|
||||
export * from './skillclient.js'
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
import type { Curve96 } from '@vpp/domain'
|
||||
import { qualityGate } from './quality.js'
|
||||
import type { QualityVerdict } from './quality.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
import type { CurveRecord, TimeSeriesStore } from './timeseries.js'
|
||||
|
||||
/**
|
||||
* What a snapshot of an ingested curve contains. Lineage refs point at this,
|
||||
* so a decision can be replayed from exactly the input that was accepted —
|
||||
* or the input that was quarantined and therefore *not* used.
|
||||
*/
|
||||
export interface IngestSnapshot {
|
||||
series_id: string
|
||||
source: string
|
||||
curve: Curve96
|
||||
received_at: string
|
||||
quality: QualityVerdict
|
||||
}
|
||||
|
||||
export type IngestResult =
|
||||
| { accepted: true; record: CurveRecord; snapshot_ref: string }
|
||||
| { accepted: false; issues: string[]; snapshot_ref: string }
|
||||
|
||||
export interface QuarantineEntry {
|
||||
series_id: string
|
||||
date: string
|
||||
source: string
|
||||
issues: string[]
|
||||
snapshot_ref: string
|
||||
received_at: string
|
||||
}
|
||||
|
||||
export interface IngestionPipelineDeps {
|
||||
timeseries: TimeSeriesStore
|
||||
snapshots: SnapshotStore
|
||||
gate?: (curve: Curve96) => QualityVerdict
|
||||
clock?: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingestion pipeline skeleton (docs/05 §3.1 接入管道): every incoming curve
|
||||
* is snapshotted first (evidence of what arrived), then passed through the
|
||||
* data-quality gate. Passing curves are written to the time-series store;
|
||||
* failing curves are tagged and quarantined (不合格数据打标隔离, §3.2) — they
|
||||
* never reach a store a forecast skill reads from. Cleaning/alignment stages
|
||||
* and the feature-store hand-off are M2 work; this fixes the shape.
|
||||
*/
|
||||
export class IngestionPipeline {
|
||||
private readonly quarantine: QuarantineEntry[] = []
|
||||
private readonly gate: (curve: Curve96) => QualityVerdict
|
||||
private readonly clock: () => string
|
||||
|
||||
constructor(private readonly deps: IngestionPipelineDeps) {
|
||||
this.gate = deps.gate ?? qualityGate
|
||||
this.clock = deps.clock ?? (() => new Date().toISOString())
|
||||
}
|
||||
|
||||
ingestCurve(series_id: string, curve: Curve96, source: string): IngestResult {
|
||||
const received_at = this.clock()
|
||||
const quality = this.gate(curve)
|
||||
const snapshot: IngestSnapshot = { series_id, source, curve, received_at, quality }
|
||||
const snapshot_ref = this.deps.snapshots.put(snapshot)
|
||||
|
||||
if (!quality.ok) {
|
||||
this.quarantine.push({
|
||||
series_id,
|
||||
date: curve.date,
|
||||
source,
|
||||
issues: quality.issues,
|
||||
snapshot_ref,
|
||||
received_at,
|
||||
})
|
||||
return { accepted: false, issues: quality.issues, snapshot_ref }
|
||||
}
|
||||
|
||||
const record = this.deps.timeseries.write(series_id, curve, source)
|
||||
return { accepted: true, record, snapshot_ref }
|
||||
}
|
||||
|
||||
quarantined(): QuarantineEntry[] {
|
||||
return [...this.quarantine]
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { Decimal } from './decimal.js'
|
||||
import type { LedgerView, PositionBounds, PositionEntry, PositionUpdate, Timescale } from '@vpp/domain'
|
||||
import { Decimal } from 'decimal.js-light'
|
||||
import type { LedgerView, PositionEntry, PositionUpdate, Timescale } from '@vpp/domain'
|
||||
import { PositionUpdate as PositionUpdateSchema } from '@vpp/domain'
|
||||
|
||||
export class LedgerConcurrencyError extends Error {
|
||||
@ -46,22 +46,6 @@ export class LedgerService {
|
||||
return this.entries.filter((e) => e.timescale === timescale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily energy bounds for a day-ahead bid on `date`, derived from the
|
||||
* MONTHLY CONTRACT position (the same rule checkCascade enforces). Handed
|
||||
* to the bid-optimization skill as hard constraints so the optimizer can
|
||||
* never produce a bid the ledger would then reject. Throws when there is
|
||||
* no monthly anchor — same policy as append.
|
||||
*/
|
||||
dayAheadBounds(date: string): PositionBounds {
|
||||
const { min, max } = this.cascadeBand(date)
|
||||
return {
|
||||
ledger_version: this.version,
|
||||
daily_energy_min_mwh: min.toString(),
|
||||
daily_energy_max_mwh: max.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
append(update: PositionUpdate): LedgerView {
|
||||
PositionUpdateSchema.parse(update)
|
||||
if (update.expected_version !== this.version) {
|
||||
@ -87,18 +71,8 @@ export class LedgerService {
|
||||
*/
|
||||
private checkCascade(update: PositionUpdate): void {
|
||||
if (update.kind !== 'BID_SUBMITTED' || update.timescale !== 'DAY_AHEAD') return
|
||||
const { min, max, contracted, daysInMonth, band } = this.cascadeBand(update.period)
|
||||
const bid = new Decimal(update.energy_mwh)
|
||||
if (bid.lt(min) || bid.gt(max)) {
|
||||
throw new CascadeViolation(
|
||||
`day-ahead bid ${bid.toString()} MWh outside monthly cascade band ` +
|
||||
`[${min.toString()}, ${max.toString()}] (contracted ${contracted.toString()} MWh / ${daysInMonth} days ± ${band.mul(100).toString()}%)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private cascadeBand(date: string) {
|
||||
const month = date.slice(0, 7)
|
||||
const month = update.period.slice(0, 7)
|
||||
const monthly = this.entries.filter(
|
||||
(e) => e.timescale === 'MONTHLY' && e.kind === 'CONTRACT' && e.period === month,
|
||||
)
|
||||
@ -107,18 +81,22 @@ export class LedgerService {
|
||||
`no MONTHLY CONTRACT position recorded for ${month}; day-ahead bid has no cascade anchor`,
|
||||
)
|
||||
}
|
||||
|
||||
const contracted = monthly.reduce((sum, e) => sum.add(new Decimal(e.energy_mwh)), new Decimal(0))
|
||||
const daysInMonth = new Date(
|
||||
Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0),
|
||||
).getUTCDate()
|
||||
const dailyShare = contracted.div(daysInMonth)
|
||||
const band = new Decimal(this.cfg.daMonthlyDeviationBand)
|
||||
return {
|
||||
min: dailyShare.mul(new Decimal(1).sub(band)),
|
||||
max: dailyShare.mul(new Decimal(1).add(band)),
|
||||
contracted,
|
||||
daysInMonth,
|
||||
band,
|
||||
const min = dailyShare.mul(new Decimal(1).sub(band))
|
||||
const max = dailyShare.mul(new Decimal(1).add(band))
|
||||
const bid = new Decimal(update.energy_mwh)
|
||||
|
||||
if (bid.lt(min) || bid.gt(max)) {
|
||||
throw new CascadeViolation(
|
||||
`day-ahead bid ${bid.toString()} MWh outside monthly cascade band ` +
|
||||
`[${min.toString()}, ${max.toString()}] (contracted ${contracted.toString()} MWh / ${daysInMonth} days ± ${band.mul(100).toString()}%)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain'
|
||||
import { proposalDigest } from './digest.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Lineage recorder (docs/09 §4): every skill call records
|
||||
* {tool, version, inputs_ref, outputs_ref}; the buffer travels with the
|
||||
* workflow run via AsyncLocalStorage and is frozen into the Proposal.
|
||||
*/
|
||||
export class LineageRecorder {
|
||||
private readonly als = new AsyncLocalStorage<{ calls: ToolCallRef[]; dataRefs: Set<string> }>()
|
||||
|
||||
run<T>(fn: () => T): T {
|
||||
return this.als.run({ calls: [], dataRefs: new Set() }, fn)
|
||||
}
|
||||
|
||||
record(call: ToolCallRef): void {
|
||||
const store = this.als.getStore()
|
||||
if (!store) throw new Error('lineage.record called outside a lineage.run scope')
|
||||
store.calls.push(call)
|
||||
}
|
||||
|
||||
addDataRef(ref: string): void {
|
||||
this.als.getStore()?.dataRefs.add(ref)
|
||||
}
|
||||
|
||||
current(): { tool_calls: ToolCallRef[]; data_refs: string[] } {
|
||||
const store = this.als.getStore()
|
||||
if (!store) throw new Error('lineage.current called outside a lineage.run scope')
|
||||
return { tool_calls: [...store.calls], data_refs: [...store.dataRefs].sort() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap any deterministic skill call so it is snapshotted and recorded. */
|
||||
export function withLineage<I, O>(
|
||||
lineage: LineageRecorder,
|
||||
snapshots: SnapshotStore,
|
||||
def: { tool: string; version: string; idGen: () => string },
|
||||
call: (input: I) => O,
|
||||
): (input: I) => { output: O; call: ToolCallRef } {
|
||||
return (input: I) => {
|
||||
const inputs_ref = snapshots.put(input)
|
||||
const output = call(input)
|
||||
const outputs_ref = snapshots.put(output)
|
||||
const ref: ToolCallRef = { tool_call_id: def.idGen(), tool: def.tool, version: def.version, inputs_ref, outputs_ref }
|
||||
lineage.record(ref)
|
||||
return { output, call: ref }
|
||||
}
|
||||
}
|
||||
|
||||
export class LineageRefError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'LineageRefError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Dereference {tool_call_id, path} into the recorded output snapshot. */
|
||||
export function resolveRef(ref: LineageRef, calls: ToolCallRef[], snapshots: SnapshotStore): unknown {
|
||||
const call = calls.find((c) => c.tool_call_id === ref.tool_call_id)
|
||||
if (!call) throw new LineageRefError(`tool call ${ref.tool_call_id} is not in this proposal's lineage`)
|
||||
const output = snapshots.get(call.outputs_ref)
|
||||
if (output === undefined) throw new LineageRefError(`output snapshot for ${ref.tool_call_id} missing`)
|
||||
let node: unknown = output
|
||||
for (const part of ref.path.split('.')) {
|
||||
if (node === null || typeof node !== 'object') throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
||||
node = Array.isArray(node) ? node[Number(part)] : (node as Record<string, unknown>)[part]
|
||||
if (node === undefined) throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* The P2 assembler (docs/09 §3): builds a Proposal from an LLM draft that
|
||||
* contains only references. Numbers are dereferenced from lineage snapshots;
|
||||
* a draft with a literal number is rejected by the schema before we get here.
|
||||
*/
|
||||
export function assembleBidProposal(
|
||||
draft: BidProposalDraft,
|
||||
ctx: {
|
||||
id: string
|
||||
originator: Proposal['originator']
|
||||
lineage: { tool_calls: ToolCallRef[]; data_refs: string[] }
|
||||
ledger_version: number
|
||||
policy_pack_version: string
|
||||
snapshots: SnapshotStore
|
||||
now: string
|
||||
},
|
||||
): Proposal {
|
||||
const prices = Curve96.parse(resolveRef(draft.prices_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const quantities = Curve96.parse(resolveRef(draft.quantities_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const revenue = DecimalString.parse(resolveRef(draft.expected_revenue_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const payload: BidPayload = BidPayloadSchema.parse({
|
||||
kind: 'BID',
|
||||
market_date: draft.market_date,
|
||||
prices_yuan_per_mwh: prices,
|
||||
quantities_mwh: quantities,
|
||||
expected_revenue_yuan: revenue,
|
||||
})
|
||||
const lineage = {
|
||||
tool_calls: ctx.lineage.tool_calls,
|
||||
data_refs: ctx.lineage.data_refs,
|
||||
ledger_version: ctx.ledger_version,
|
||||
policy_pack_version: ctx.policy_pack_version,
|
||||
}
|
||||
const base = {
|
||||
id: ctx.id,
|
||||
type: 'BID' as const,
|
||||
timescale: 'DAY_AHEAD' as const,
|
||||
originator: ctx.originator,
|
||||
payload,
|
||||
lineage,
|
||||
envelope_ref: null,
|
||||
status: 'DRAFT' as const,
|
||||
created_at: ctx.now,
|
||||
}
|
||||
return { ...base, digest: proposalDigest(base) }
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain'
|
||||
import { digestMatches } from './digest.js'
|
||||
import type { LedgerService } from './ledger.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
import { Decimal } from './decimal.js'
|
||||
|
||||
/**
|
||||
* Policy engine (docs/05 §4.1, docs/03 §2 规则校核): versioned, tested,
|
||||
* executable rule packs. Rules are deterministic functions of the proposal
|
||||
* and its evidence context; the pack version is frozen into lineage so any
|
||||
* historical check is replayable (I7). Old packs stay in the registry.
|
||||
*/
|
||||
export interface RuleContext {
|
||||
ledger: LedgerView
|
||||
ledgerService: LedgerService
|
||||
snapshots: SnapshotStore
|
||||
now: string
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
rule_id: string
|
||||
severity: 'REJECT' | 'WARN'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: string
|
||||
description: string
|
||||
/** Knowledge-base entries this rule implements (explainability: rule → source). */
|
||||
source_refs: string[]
|
||||
check(proposal: Proposal, ctx: RuleContext): Violation[]
|
||||
}
|
||||
|
||||
export interface PolicyPack {
|
||||
id: string
|
||||
version: string
|
||||
effective: { from: string; to: string | null }
|
||||
rules: Rule[]
|
||||
}
|
||||
|
||||
export class PolicyEngine {
|
||||
private readonly packs = new Map<string, PolicyPack>()
|
||||
|
||||
constructor(packs: PolicyPack[]) {
|
||||
for (const p of packs) this.packs.set(`${p.id}@${p.version}`, p)
|
||||
}
|
||||
|
||||
pack(id: string, version: string): PolicyPack {
|
||||
const p = this.packs.get(`${id}@${version}`)
|
||||
if (!p) throw new Error(`policy pack ${id}@${version} not registered`)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Latest pack by version string order (versions are YYYY.MM, lexicographically ordered). */
|
||||
current(id: string): PolicyPack {
|
||||
const versions = [...this.packs.values()].filter((p) => p.id === id).sort((a, b) => (a.version < b.version ? 1 : -1))
|
||||
if (versions.length === 0) throw new Error(`no policy pack registered for ${id}`)
|
||||
return versions[0]!
|
||||
}
|
||||
|
||||
check(proposal: Proposal, packRef: { id: string; version: string }, ctx: RuleContext): ValidationResult {
|
||||
const pack = this.pack(packRef.id, packRef.version)
|
||||
const violations: Violation[] = []
|
||||
const evaluated: string[] = []
|
||||
for (const rule of pack.rules) {
|
||||
evaluated.push(rule.id)
|
||||
violations.push(...rule.check(proposal, ctx))
|
||||
}
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
policy_pack: { id: pack.id, version: pack.version },
|
||||
ok: !violations.some((v) => v.severity === 'REJECT'),
|
||||
violations,
|
||||
rules_evaluated: evaluated,
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hubei spot bidding pack v1. Numeric limits below are placeholders pending
|
||||
// OPEN-QUESTION D5 (首批转为政策包的湖北规则清单) and A1/A4/A5 — they are named
|
||||
// config so the values can be replaced without touching rule logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HubeiBidPackConfig {
|
||||
// OPEN-QUESTION D5: Hubei day-ahead price floor/cap for VPP offers.
|
||||
priceFloorYuanPerMwh: string
|
||||
priceCapYuanPerMwh: string
|
||||
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
|
||||
originators: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
|
||||
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
|
||||
priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5
|
||||
originators: { BID: ['trading-agent', 'operator'] },
|
||||
}
|
||||
|
||||
const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message })
|
||||
|
||||
export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): PolicyPack {
|
||||
return {
|
||||
id: 'hubei-spot-bidding',
|
||||
version,
|
||||
effective: { from: '2026-01-01T00:00:00Z', to: null },
|
||||
rules: [
|
||||
{
|
||||
id: 'digest-valid',
|
||||
description: 'digest equals sha256(canonical payload + lineage)',
|
||||
source_refs: ['docs/03 §1'],
|
||||
check: (p) => (digestMatches(p) ? [] : [reject('digest-valid', 'digest does not match payload + lineage')]),
|
||||
},
|
||||
{
|
||||
id: 'bid-format',
|
||||
description: '96 intervals, curves dated on market_date',
|
||||
source_refs: ['docs/11 §3.3', 'D5'],
|
||||
check: (p) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const out: Violation[] = []
|
||||
for (const [name, c] of [['prices_yuan_per_mwh', p.payload.prices_yuan_per_mwh], ['quantities_mwh', p.payload.quantities_mwh]] as const) {
|
||||
if (c.date !== p.payload.market_date) out.push(reject('bid-format', `${name} dated ${c.date}, market_date is ${p.payload.market_date}`))
|
||||
if (c.values.length !== 96) out.push(reject('bid-format', `${name} has ${c.values.length} values`))
|
||||
}
|
||||
if (p.timescale !== 'DAY_AHEAD') out.push(reject('bid-format', `BID must be DAY_AHEAD, got ${p.timescale}`))
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'price-limits',
|
||||
description: 'every offer price within the market floor/cap',
|
||||
source_refs: ['D5'],
|
||||
check: (p) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const floor = new Decimal(cfg.priceFloorYuanPerMwh)
|
||||
const cap = new Decimal(cfg.priceCapYuanPerMwh)
|
||||
const out: Violation[] = []
|
||||
p.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
|
||||
const d = new Decimal(v)
|
||||
if (d.lt(floor) || d.gt(cap)) out.push(reject('price-limits', `interval ${i}: price ${v} outside [${floor}, ${cap}]`))
|
||||
})
|
||||
return out.slice(0, 5)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quantity-non-negative',
|
||||
description: 'offer quantities are non-negative',
|
||||
source_refs: ['D5'],
|
||||
check: (p) =>
|
||||
p.type === 'BID' && p.payload.quantities_mwh.values.some((v) => new Decimal(v).lt(0))
|
||||
? [reject('quantity-non-negative', 'negative quantity in bid')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: 'ledger-consistency',
|
||||
description: 'lineage ledger version is current and daily energy is inside the cascade band (P7)',
|
||||
source_refs: ['docs/00 §4', 'A5'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const out: Violation[] = []
|
||||
if (p.lineage.ledger_version !== ctx.ledger.version) {
|
||||
out.push(reject('ledger-consistency', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`))
|
||||
}
|
||||
try {
|
||||
const b = ctx.ledgerService.dayAheadBounds(p.payload.market_date)
|
||||
const total = p.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.lt(b.daily_energy_min_mwh) || total.gt(b.daily_energy_max_mwh)) {
|
||||
out.push(reject('ledger-consistency', `daily energy ${total.toString()} outside [${b.daily_energy_min_mwh}, ${b.daily_energy_max_mwh}]`))
|
||||
}
|
||||
} catch (e) {
|
||||
out.push(reject('ledger-consistency', (e as Error).message))
|
||||
}
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lineage-integrity',
|
||||
description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)',
|
||||
source_refs: ['docs/03 §1', 'docs/01 P2'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'BID') return []
|
||||
if (p.lineage.tool_calls.length === 0) return [reject('lineage-integrity', 'no tool calls in lineage')]
|
||||
const outputs: Record<string, unknown>[] = []
|
||||
for (const tc of p.lineage.tool_calls) {
|
||||
const out = ctx.snapshots.get(tc.outputs_ref)
|
||||
if (out === undefined) return [reject('lineage-integrity', `tool call ${tc.tool_call_id}: output snapshot ${tc.outputs_ref.slice(0, 8)}… missing`)]
|
||||
if (out && typeof out === 'object') outputs.push(out as Record<string, unknown>)
|
||||
}
|
||||
const found = (pred: (o: Record<string, unknown>) => boolean) => outputs.some(pred)
|
||||
const eqCurve = (a: unknown, b: { values: string[] }) =>
|
||||
!!a && typeof a === 'object' && JSON.stringify((a as { values?: unknown }).values) === JSON.stringify(b.values)
|
||||
const out: Violation[] = []
|
||||
if (!found((o) => eqCurve(o['prices_yuan_per_mwh'], p.payload.prices_yuan_per_mwh))) out.push(reject('lineage-integrity', 'prices_yuan_per_mwh not traceable to a tool output'))
|
||||
if (!found((o) => eqCurve(o['quantities_mwh'], p.payload.quantities_mwh))) out.push(reject('lineage-integrity', 'quantities_mwh not traceable to a tool output'))
|
||||
if (!found((o) => o['expected_revenue_yuan'] === p.payload.expected_revenue_yuan)) out.push(reject('lineage-integrity', 'expected_revenue_yuan not traceable to a tool output'))
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'originator-permission',
|
||||
description: 'originating agent is allowed to originate this proposal type (I2)',
|
||||
source_refs: ['docs/03 §2', 'docs/06 §4'],
|
||||
check: (p) =>
|
||||
(cfg.originators[p.type] ?? []).includes(p.originator.agent)
|
||||
? []
|
||||
: [reject('originator-permission', `${p.originator.agent} may not originate ${p.type}`)],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { z } from 'zod'
|
||||
import { ResourceProfile as ResourceProfileSchema } from '@vpp/domain'
|
||||
import type { ResourceProfile } from '@vpp/domain'
|
||||
|
||||
export class RepositoryConcurrencyError extends Error {
|
||||
constructor(key: string, expected: number, actual: number) {
|
||||
super(`repository row '${key}': expected version ${expected}, current is ${actual}`)
|
||||
this.name = 'RepositoryConcurrencyError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface Row<T> {
|
||||
key: string
|
||||
/** 0 = never written; increments on every accepted put. */
|
||||
version: number
|
||||
value: T
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational store port (docs/05 §3, 关系库): master data and business
|
||||
* records — resource registry (资源台账), contracts, market results. Rows
|
||||
* are schema-validated on write (schemas come from @vpp/domain only, CLAUDE.md
|
||||
* rule 6) and versioned with optimistic concurrency, mirroring the ledger.
|
||||
* Postgres adapter arrives with M3; the ledger keeps its own service because
|
||||
* its constraint cascade is not generic row storage.
|
||||
*/
|
||||
export interface Repository<T> {
|
||||
get(key: string): Row<T> | undefined
|
||||
list(): Row<T>[]
|
||||
/**
|
||||
* Insert or replace. When expectedVersion is given it must equal the current
|
||||
* row version (0 for a new row); omit it for unconditional master-data loads.
|
||||
*/
|
||||
put(value: T, expectedVersion?: number): Row<T>
|
||||
}
|
||||
|
||||
export class MemoryRepository<T> implements Repository<T> {
|
||||
private readonly rows = new Map<string, Row<T>>()
|
||||
|
||||
constructor(
|
||||
private readonly schema: z.ZodType<T>,
|
||||
private readonly keyOf: (value: T) => string,
|
||||
private readonly clock: () => string = () => new Date().toISOString(),
|
||||
) {}
|
||||
|
||||
get(key: string): Row<T> | undefined {
|
||||
const row = this.rows.get(key)
|
||||
return row && { ...row, value: structuredClone(row.value) }
|
||||
}
|
||||
|
||||
list(): Row<T>[] {
|
||||
return [...this.rows.keys()].sort().map((k) => this.get(k)!)
|
||||
}
|
||||
|
||||
/** Load a persisted row verbatim (versions preserved). */
|
||||
protected restore(row: Row<T>): void {
|
||||
this.rows.set(row.key, row)
|
||||
}
|
||||
|
||||
put(value: T, expectedVersion?: number): Row<T> {
|
||||
const parsed = this.schema.parse(value)
|
||||
const key = this.keyOf(parsed)
|
||||
const current = this.rows.get(key)?.version ?? 0
|
||||
if (expectedVersion !== undefined && expectedVersion !== current) {
|
||||
throw new RepositoryConcurrencyError(key, expectedVersion, current)
|
||||
}
|
||||
const row: Row<T> = {
|
||||
key,
|
||||
version: current + 1,
|
||||
value: structuredClone(parsed),
|
||||
updated_at: this.clock(),
|
||||
}
|
||||
this.rows.set(key, row)
|
||||
return this.get(key)!
|
||||
}
|
||||
}
|
||||
|
||||
/** 资源台账: ResourceProfile keyed by resource_id. */
|
||||
export type ResourceRegistry = Repository<ResourceProfile>
|
||||
|
||||
export const createResourceRegistry = (clock?: () => string): ResourceRegistry =>
|
||||
new MemoryRepository(ResourceProfileSchema, (r) => r.resource_id, clock)
|
||||
|
||||
/**
|
||||
* JSON-file-backed repository: full rewrite on every put (row counts in
|
||||
* phase 1 are tiny: cases, envelopes, permits). Enough for "a suspended
|
||||
* approval survives process restart" until the Postgres adapter (M3+).
|
||||
*/
|
||||
export class FsRepository<T> extends MemoryRepository<T> {
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
schema: z.ZodType<T>,
|
||||
keyOf: (value: T) => string,
|
||||
clock?: () => string,
|
||||
) {
|
||||
super(schema, keyOf, clock)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
if (existsSync(path)) {
|
||||
const rows = JSON.parse(readFileSync(path, 'utf8')) as Row<T>[]
|
||||
for (const r of rows) this.restore({ ...r, value: schema.parse(r.value) })
|
||||
}
|
||||
}
|
||||
|
||||
override put(value: T, expectedVersion?: number): Row<T> {
|
||||
const row = super.put(value, expectedVersion)
|
||||
writeFileSync(this.path, JSON.stringify(this.list(), null, 1) + '\n')
|
||||
return row
|
||||
}
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
import type { ForecastBundle, Proposal, SimulationResult } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
|
||||
/**
|
||||
* Simulation port (docs/03 §2 仿真验证; docs/05 §2.3 收益情景回测). Bids are
|
||||
* stress-tested against many price scenarios drawn around the forecast band;
|
||||
* any alert escalates to a human — one vote, no exceptions.
|
||||
*
|
||||
* v1 is deterministic (seeded) and in-process. Scenario paths are built from
|
||||
* the P10/P50/P90 band by a seeded uniform draw per interval, interpolated
|
||||
* piecewise-linearly, with per-day correlated shocks so extreme days exist.
|
||||
*/
|
||||
export interface SimulationPort {
|
||||
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult
|
||||
}
|
||||
|
||||
export interface SimulationContext {
|
||||
now: string
|
||||
priceForecast: ForecastBundle | null
|
||||
marginalCostYuanPerMwh: string
|
||||
/**
|
||||
* Alert when the 5th-percentile scenario revenue net of cost falls below
|
||||
* −budget. OPEN-QUESTION B5 (单日亏损预算) — placeholder.
|
||||
*/
|
||||
worstCaseLossBudgetYuan: string
|
||||
scenarioCount?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
/** Small deterministic PRNG (mulberry32) — enough for scenario sampling. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export class RevenueScenarioSimulator implements SimulationPort {
|
||||
static readonly NAME = 'revenue-scenarios'
|
||||
static readonly VERSION = '1.0.0'
|
||||
|
||||
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult {
|
||||
const seed = ctx.seed ?? 20260315
|
||||
const n = ctx.scenarioCount ?? 1000
|
||||
const alerts: string[] = []
|
||||
const base = { proposal_digest: proposal.digest, kind: 'REVENUE_SCENARIOS' as const, simulator: { name: RevenueScenarioSimulator.NAME, version: RevenueScenarioSimulator.VERSION, seed }, simulated_at: ctx.now }
|
||||
|
||||
if (proposal.type !== 'BID') {
|
||||
return { ...base, alert: true, alerts: ['revenue simulation only supports BID'], metrics: {}, scenario_count: 0 }
|
||||
}
|
||||
if (!ctx.priceForecast) {
|
||||
return { ...base, alert: true, alerts: ['no price forecast in lineage to simulate against'], metrics: {}, scenario_count: 0 }
|
||||
}
|
||||
|
||||
const p10 = ctx.priceForecast.quantiles.p10.values.map(Number)
|
||||
const p50 = ctx.priceForecast.quantiles.p50.values.map(Number)
|
||||
const p90 = ctx.priceForecast.quantiles.p90.values.map(Number)
|
||||
const offers = proposal.payload.prices_yuan_per_mwh.values.map(Number)
|
||||
const qty = proposal.payload.quantities_mwh.values.map(Number)
|
||||
const cost = Number(ctx.marginalCostYuanPerMwh)
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const revenues: number[] = []
|
||||
for (let s = 0; s < n; s++) {
|
||||
const dayShock = rand() // correlated across intervals: a "high day" or "low day"
|
||||
let rev = 0
|
||||
for (let t = 0; t < 96; t++) {
|
||||
const u = 0.5 * dayShock + 0.5 * rand()
|
||||
// Map u∈[0,1] onto the band: 0→below P10, 0.5→P50, 1→above P90 (tails extend 50%).
|
||||
let price: number
|
||||
if (u < 0.1) price = p10[t]! - (0.1 - u) / 0.1 * 0.5 * (p50[t]! - p10[t]!)
|
||||
else if (u < 0.5) price = p10[t]! + (u - 0.1) / 0.4 * (p50[t]! - p10[t]!)
|
||||
else if (u < 0.9) price = p50[t]! + (u - 0.5) / 0.4 * (p90[t]! - p50[t]!)
|
||||
else price = p90[t]! + (u - 0.9) / 0.1 * 0.5 * (p90[t]! - p50[t]!)
|
||||
if (offers[t]! <= price) rev += (price - cost) * qty[t]!
|
||||
}
|
||||
revenues.push(rev)
|
||||
}
|
||||
revenues.sort((a, b) => a - b)
|
||||
const pct = (p: number) => revenues[Math.min(n - 1, Math.floor(p * n))]!
|
||||
const p05 = pct(0.05)
|
||||
const worstLoss = Math.max(0, -revenues[0]!)
|
||||
const budget = Number(ctx.worstCaseLossBudgetYuan)
|
||||
if (p05 < -budget) alerts.push(`P05 scenario revenue ${p05.toFixed(2)} below loss budget −${budget}`)
|
||||
if (qty.every((q) => q === 0)) alerts.push('bid offers zero energy in every interval')
|
||||
|
||||
const money = (x: number) => new Decimal(x.toFixed(2)).toFixed(2)
|
||||
return {
|
||||
...base,
|
||||
alert: alerts.length > 0,
|
||||
alerts,
|
||||
metrics: {
|
||||
revenue_p05_yuan: money(p05),
|
||||
revenue_p50_yuan: money(pct(0.5)),
|
||||
revenue_p95_yuan: money(pct(0.95)),
|
||||
worst_loss_yuan: money(worstLoss),
|
||||
},
|
||||
scenario_count: n,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
import {
|
||||
BidOptimizationRequest,
|
||||
BidOptimizationResult,
|
||||
ForecastBundle,
|
||||
ForecastRequest,
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from '@vpp/domain'
|
||||
import type { ForecastKind } from '@vpp/domain'
|
||||
|
||||
/**
|
||||
* Typed access to the Python skill service. Both directions are validated
|
||||
* against the domain schemas — the same discipline registerSkill applies in
|
||||
* the runtime (docs/09 §4), minus the lineage recording the harness does not
|
||||
* need. The interface exists so tests can substitute a stub.
|
||||
*/
|
||||
export interface SkillClient {
|
||||
skills(): Promise<Array<{ id: string; version: string; endpoint: string }>>
|
||||
forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle>
|
||||
optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult>
|
||||
report(req: ReportRequest): Promise<SkillReport>
|
||||
}
|
||||
|
||||
export class SkillHttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly url: string,
|
||||
body: string,
|
||||
) {
|
||||
super(`skill call ${url} failed with HTTP ${status}: ${body.slice(0, 500)}`)
|
||||
this.name = 'SkillHttpError'
|
||||
}
|
||||
}
|
||||
|
||||
const FORECAST_PATH: Record<ForecastKind, string> = {
|
||||
LOAD: '/v1/forecast/load',
|
||||
PV: '/v1/forecast/pv',
|
||||
PRICE: '/v1/forecast/price',
|
||||
}
|
||||
|
||||
export class HttpSkillClient implements SkillClient {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
private async post<T>(path: string, body: unknown, parse: (x: unknown) => T): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) throw new SkillHttpError(res.status, url, text)
|
||||
return parse(JSON.parse(text))
|
||||
}
|
||||
|
||||
async skills() {
|
||||
const res = await fetch(`${this.baseUrl}/v1/skills`)
|
||||
if (!res.ok) throw new SkillHttpError(res.status, `${this.baseUrl}/v1/skills`, await res.text())
|
||||
return (await res.json()) as Array<{ id: string; version: string; endpoint: string }>
|
||||
}
|
||||
|
||||
forecast(kind: ForecastKind, req: ForecastRequest) {
|
||||
return this.post(FORECAST_PATH[kind], ForecastRequest.parse(req), (x) => ForecastBundle.parse(x))
|
||||
}
|
||||
|
||||
optimizeBid(req: BidOptimizationRequest) {
|
||||
return this.post('/v1/optimize/bid', BidOptimizationRequest.parse(req), (x) =>
|
||||
BidOptimizationResult.parse(x),
|
||||
)
|
||||
}
|
||||
|
||||
report(req: ReportRequest) {
|
||||
return this.post('/v1/report', ReportRequest.parse(req), (x) => SkillReport.parse(x))
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import { Curve96 as Curve96Schema, MarketDate } from '@vpp/domain'
|
||||
import type { Curve96 } from '@vpp/domain'
|
||||
import { canonicalJson } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* One stored revision of a daily curve. Telemetry is corrected after the fact
|
||||
* (late meter reads, re-transmissions), so a (series, date) has a revision
|
||||
* history — never an in-place overwrite. Lineage that referenced revision N
|
||||
* still resolves to revision N (docs/05 §3.2 快照与引用).
|
||||
*/
|
||||
export interface CurveRecord {
|
||||
series_id: string
|
||||
date: string
|
||||
/** 1-based, monotonically increasing per (series_id, date). */
|
||||
version: number
|
||||
curve: Curve96
|
||||
/** Where the curve came from: adapter name, file, edge node id. */
|
||||
source: string
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series store port (docs/05 §3, 时序库). Curves are stored as whole
|
||||
* market days (Curve96) — the granularity every forecast and bid consumes;
|
||||
* sub-day streaming telemetry lives in the edge protocol domain, not here
|
||||
* (docs/11 §4). TimescaleDB adapter arrives with M3; the in-memory
|
||||
* implementation is the reference semantics.
|
||||
*/
|
||||
export interface TimeSeriesStore {
|
||||
/** Appends a revision. Identical content to the latest revision is a no-op returning it. */
|
||||
write(series_id: string, curve: Curve96, source: string): CurveRecord
|
||||
latest(series_id: string, date: string): CurveRecord | undefined
|
||||
revisions(series_id: string, date: string): CurveRecord[]
|
||||
/** Latest revision per date, inclusive of both bounds, ascending by date. */
|
||||
range(series_id: string, from: string, to: string): CurveRecord[]
|
||||
seriesIds(): string[]
|
||||
}
|
||||
|
||||
const keyOf = (series_id: string, date: string) => `${series_id} ${date}`
|
||||
|
||||
export class MemoryTimeSeriesStore implements TimeSeriesStore {
|
||||
private readonly byKey = new Map<string, CurveRecord[]>()
|
||||
|
||||
constructor(private readonly clock: () => string = () => new Date().toISOString()) {}
|
||||
|
||||
write(series_id: string, curve: Curve96, source: string): CurveRecord {
|
||||
if (!series_id) throw new Error('series_id must be non-empty')
|
||||
Curve96Schema.parse(curve)
|
||||
const key = keyOf(series_id, curve.date)
|
||||
const history = this.byKey.get(key) ?? []
|
||||
const last = history[history.length - 1]
|
||||
if (last && canonicalJson(last.curve) === canonicalJson(curve)) return last
|
||||
|
||||
const record: CurveRecord = {
|
||||
series_id,
|
||||
date: curve.date,
|
||||
version: history.length + 1,
|
||||
curve: structuredClone(curve),
|
||||
source,
|
||||
recorded_at: this.clock(),
|
||||
}
|
||||
this.byKey.set(key, [...history, record])
|
||||
return record
|
||||
}
|
||||
|
||||
latest(series_id: string, date: string): CurveRecord | undefined {
|
||||
const history = this.byKey.get(keyOf(series_id, date))
|
||||
return history?.[history.length - 1]
|
||||
}
|
||||
|
||||
revisions(series_id: string, date: string): CurveRecord[] {
|
||||
return [...(this.byKey.get(keyOf(series_id, date)) ?? [])]
|
||||
}
|
||||
|
||||
range(series_id: string, from: string, to: string): CurveRecord[] {
|
||||
MarketDate.parse(from)
|
||||
MarketDate.parse(to)
|
||||
const out: CurveRecord[] = []
|
||||
for (const history of this.byKey.values()) {
|
||||
const last = history[history.length - 1]
|
||||
if (!last || last.series_id !== series_id) continue
|
||||
// ISO dates compare lexicographically.
|
||||
if (last.date >= from && last.date <= to) out.push(last)
|
||||
}
|
||||
return out.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
||||
}
|
||||
|
||||
seriesIds(): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const history of this.byKey.values()) {
|
||||
const first = history[0]
|
||||
if (first) ids.add(first.series_id)
|
||||
}
|
||||
return [...ids].sort()
|
||||
}
|
||||
}
|
||||
@ -1,293 +0,0 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
|
||||
import { CaseDeskService } from '../src/casedesk.js'
|
||||
import { proposalDigest } from '../src/digest.js'
|
||||
import { EnvelopeService } from '../src/envelope.js'
|
||||
import { FileEventBus, MemoryEventBus } from '../src/events.js'
|
||||
import { FileExportGateway, GatewayRejected } from '../src/gateway.js'
|
||||
import { LineageRefError, assembleBidProposal } from '../src/lineage.js'
|
||||
import { FsRepository } from '../src/relational.js'
|
||||
import { RevenueScenarioSimulator } from '../src/simulation.js'
|
||||
import { MemorySnapshotStore } from '../src/snapshot.js'
|
||||
import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, priceForecast, world } from './helpers.js'
|
||||
|
||||
const HUMAN_ROLES = ['senior-trader', 'ops-lead', 'risk-officer']
|
||||
const freshCtx = (w: ReturnType<typeof world>, over: Record<string, unknown> = {}) => ({
|
||||
now: NOW, ledger: w.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000, ...over,
|
||||
})
|
||||
|
||||
describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fail case)', () => {
|
||||
it('passes a well-formed, traceable, in-band bid', () => {
|
||||
const w = world()
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.ok, JSON.stringify(r.violations)).toBe(true)
|
||||
expect(r.rules_evaluated).toContain('lineage-integrity')
|
||||
expect(r.policy_pack.version).toBe(PACK_VERSION)
|
||||
})
|
||||
|
||||
it('digest-valid: a tampered payload no longer matches its digest', () => {
|
||||
const w = world()
|
||||
const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } }
|
||||
const r = w.engine.check(tampered, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('digest-valid')
|
||||
})
|
||||
|
||||
it('price-limits: offer above the cap is rejected', () => {
|
||||
const w = world({ price: '1500.01' })
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.violations[0]!.rule_id).toBe('price-limits')
|
||||
})
|
||||
|
||||
it('ledger-consistency: energy outside the cascade band, or a stale ledger version, is rejected', () => {
|
||||
const over = world({ qty: '14.0' }) // 1344 MWh > 1260
|
||||
let r = over.engine.check(over.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, over.ruleCtx())
|
||||
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /outside/.test(v.message))).toBe(true)
|
||||
|
||||
const w = world()
|
||||
w.ledger.append({ id: 'x', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /ledger_version/.test(v.message))).toBe(true)
|
||||
})
|
||||
|
||||
it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => {
|
||||
const w = world()
|
||||
const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } }
|
||||
forged.digest = proposalDigest(forged) // even a re-digested forgery fails: the number has no provenance
|
||||
const r = w.engine.check(forged, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('lineage-integrity')
|
||||
expect(r.violations.map((v) => v.rule_id)).not.toContain('digest-valid')
|
||||
})
|
||||
|
||||
it('originator-permission: an agent outside the allowlist may not originate a BID (I2)', () => {
|
||||
const w = world({ agent: 'load-control-agent' })
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission')
|
||||
})
|
||||
|
||||
it('old pack versions stay registered for replay (I7)', () => {
|
||||
const w = world()
|
||||
expect(w.engine.current('hubei-spot-bidding').version).toBe(PACK_VERSION)
|
||||
expect(() => w.engine.pack('hubei-spot-bidding', '2025.01')).toThrow(/not registered/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('P2 assembler: numbers enter a proposal only by lineage reference', () => {
|
||||
it('rejects a draft carrying a literal number at the schema boundary', () => {
|
||||
const draft = { market_date: DATE, prices_ref: { tool_call_id: 'tc-1', path: 'p' }, quantities_ref: { tool_call_id: 'tc-1', path: 'q' }, expected_revenue_ref: '510600.00', rationale: 'x' }
|
||||
expect(BidProposalDraft.safeParse(draft).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a reference to a tool call that is not in lineage', () => {
|
||||
const w = world()
|
||||
expect(() =>
|
||||
assembleBidProposal(
|
||||
{ market_date: DATE, prices_ref: { tool_call_id: 'tc-99', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-99', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-99', path: 'expected_revenue_yuan' }, rationale: 'x' },
|
||||
{ id: 'p', originator: w.proposal.originator, lineage: w.proposal.lineage, ledger_version: 1, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW },
|
||||
),
|
||||
).toThrow(LineageRefError)
|
||||
})
|
||||
|
||||
it('assembled proposal validates against the domain schema and its digest is reproducible', () => {
|
||||
const w = world()
|
||||
expect(ProposalSchema.safeParse(w.proposal).success).toBe(true)
|
||||
expect(proposalDigest(w.proposal)).toBe(w.proposal.digest)
|
||||
expect(w.proposal.lineage.tool_calls.map((t) => t.tool)).toEqual(['price-forecast', 'bid-optimization-milp'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('envelope gate (P3)', () => {
|
||||
it('empty envelope set → everything goes to a human (autonomy dial at zero)', () => {
|
||||
const w = world()
|
||||
const m = new EnvelopeService().match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
|
||||
expect(m.within).toBe(false)
|
||||
expect(m.envelope_id).toBeNull()
|
||||
expect(m.required_level).toBe('L2')
|
||||
})
|
||||
|
||||
it('auto-approves inside bounds and reports which envelope', () => {
|
||||
const w = world()
|
||||
const svc = new EnvelopeService()
|
||||
svc.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
|
||||
const m = svc.match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
|
||||
expect(m.within).toBe(true)
|
||||
expect(m.envelope_id).toBe('env-bid-001')
|
||||
})
|
||||
|
||||
it('exceeding a bound, an expired window, or SUSPENDED status all fall out of the envelope', () => {
|
||||
const w = world()
|
||||
const outOfEnergy = new EnvelopeService(); outOfEnergy.register(envelope({ max_energy_mwh: '1000.0' }))
|
||||
expect(outOfEnergy.match(w.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_energy_mwh/)
|
||||
|
||||
const expired = new EnvelopeService(); expired.register(envelope({ max_energy_mwh: '1500.0' }, { validity: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z' } }))
|
||||
expect(expired.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
|
||||
|
||||
const suspended = new EnvelopeService(); suspended.register(envelope({ max_energy_mwh: '1500.0' }, { status: 'SUSPENDED' }))
|
||||
expect(suspended.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
|
||||
|
||||
const priced = world({ price: '450.00' }) // 5.8% above 425.50 baseline
|
||||
const tight = new EnvelopeService(); tight.register(envelope({ price_deviation_pct: '5.0' }))
|
||||
expect(tight.match(priced.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }).reasons[0]).toMatch(/deviates/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('approval identity (I1/I2)', () => {
|
||||
it('rejects the originating agent, any tool in the chain, and non-human roles', () => {
|
||||
const w = world()
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'trading-agent', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'bid-optimization-milp', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'agent' }, HUMAN_ROLES)).toThrow(/AI cannot approve/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'senior-trader' }, HUMAN_ROLES)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('authority: fresh check, permits, revocation (I3/I4)', () => {
|
||||
it('issues a permit bound to the digest with the narrowest effect limits', () => {
|
||||
const w = world()
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1, { scope: { effect_type: 'BID', limits: { max_energy_mwh: '1250.0' } } })], envelopeMatch: null }, freshCtx(w))
|
||||
expect(isStaleDenial(permit)).toBe(false)
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(permit.proposal_digest).toBe(w.proposal.digest)
|
||||
expect(permit.effect_limits['max_energy_mwh']).toBe('1250.0')
|
||||
expect(permit.expires_at).toBe('2026-03-14T09:00:00.000Z')
|
||||
})
|
||||
|
||||
it('goes STALE when the digest, window, ledger, policy pack or resources changed since approval (I3)', () => {
|
||||
const w = world()
|
||||
const a = approvalFor(w.proposal, 1)
|
||||
const stale = (basis: Parameters<AuthorityService['authorize']>[1], ctx = freshCtx(w)) => {
|
||||
const r = w.authority.authorize(w.proposal, basis, ctx)
|
||||
if (!isStaleDenial(r)) throw new Error('expected stale')
|
||||
return r.reasons.join(' | ')
|
||||
}
|
||||
expect(stale({ approvals: [{ ...a, proposal_digest: 'e'.repeat(64) }], envelopeMatch: null })).toMatch(/digest/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { now: '2026-03-15T10:00:00Z' }))).toMatch(/validity/)
|
||||
expect(stale({ approvals: [{ ...a, evidence_versions: { ...a.evidence_versions, ledger_version: 0 } }], envelopeMatch: null })).toMatch(/ledger version/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { currentPolicyPackVersion: '2026.04' }))).toMatch(/policy pack/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { offlineResources: ['res-storage-01'] }))).toMatch(/offline/)
|
||||
expect(stale({ approvals: [{ ...a, decision: 'REJECT' }], envelopeMatch: null })).toMatch(/REJECT/)
|
||||
expect(stale({ approvals: [], envelopeMatch: null })).toMatch(/no approval basis/)
|
||||
})
|
||||
|
||||
it('an auto-approval is a valid basis only while its evidence is current', () => {
|
||||
const w = world()
|
||||
const match = { proposal_digest: w.proposal.digest, within: true, envelope_id: 'env-bid-001', required_level: 'L1' as const, reasons: [], checked_at: NOW }
|
||||
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(false)
|
||||
w.ledger.append({ id: 'y', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(true)
|
||||
})
|
||||
|
||||
it('validate: expiry, revocation, digest mismatch and unknown permits are all rejected (I4)', () => {
|
||||
const w = world()
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(w.authority.validate(permit, w.proposal, NOW)).toEqual([])
|
||||
expect(w.authority.validate(permit, w.proposal, '2026-03-14T09:00:00.000Z')[0]).toMatch(/expired/)
|
||||
expect(w.authority.validate({ ...permit, proposal_digest: 'e'.repeat(64) }, w.proposal, NOW)[0]).toMatch(/digest/)
|
||||
expect(w.authority.validate({ ...permit, id: 'permit-forged' }, w.proposal, NOW)[0]).toMatch(/not on record/)
|
||||
w.authority.revoke(permit.id, 'storage offline', '2026-03-14T08:30:00Z')
|
||||
expect(w.authority.validate(permit, w.proposal, NOW)[0]).toMatch(/revoked/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gateway: only (Proposal, Permit) pairs; idempotent receipts (I4/I5)', () => {
|
||||
it('exports a bid file once and returns the same receipt on re-dispatch', () => {
|
||||
const w = world()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vpp-export-'))
|
||||
const gw = new FileExportGateway(w.authority, dir)
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
const r1 = gw.dispatch(w.proposal, permit, NOW)
|
||||
const r2 = gw.dispatch(w.proposal, permit, '2026-03-14T08:05:00Z')
|
||||
expect(r2).toEqual(r1)
|
||||
expect(gw.receipts.list()).toHaveLength(1)
|
||||
const file = JSON.parse(readFileSync(r1.artifact_ref, 'utf8'))
|
||||
expect(file.proposal_digest).toBe(w.proposal.digest)
|
||||
expect(file.quantities_mwh.values[0]).toBe('12.5')
|
||||
})
|
||||
|
||||
it('refuses a late release, a revoked permit, and a bare proposal with a forged permit', () => {
|
||||
const w = world()
|
||||
const gw = new FileExportGateway(w.authority, mkdtempSync(join(tmpdir(), 'vpp-export-')))
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(() => gw.dispatch(w.proposal, permit, '2026-03-15T09:00:01Z')).toThrow(GatewayRejected)
|
||||
expect(() => gw.dispatch(w.proposal, { ...permit, id: 'permit-forged' }, NOW)).toThrow(/not on record/)
|
||||
w.authority.revoke(permit.id, 'kill switch', NOW)
|
||||
expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(/revoked/)
|
||||
expect(gw.receipts.list()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revenue scenario simulation', () => {
|
||||
it('is deterministic for a seed and does not alert on a sane price-taker bid', () => {
|
||||
const w = world()
|
||||
const sim = new RevenueScenarioSimulator()
|
||||
const ctx = { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '100000' }
|
||||
const a = sim.simulate(w.proposal, ctx)
|
||||
const b = sim.simulate(w.proposal, ctx)
|
||||
expect(a).toEqual(b)
|
||||
expect(a.alert).toBe(false)
|
||||
expect(a.scenario_count).toBe(1000)
|
||||
expect(Number(a.metrics['revenue_p05_yuan'])).toBeLessThan(Number(a.metrics['revenue_p95_yuan']))
|
||||
})
|
||||
|
||||
it('alerts when the P05 scenario breaches the loss budget, and when there is no forecast', () => {
|
||||
const w = world()
|
||||
const sim = new RevenueScenarioSimulator()
|
||||
const costly = sim.simulate(w.proposal, { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '600', worstCaseLossBudgetYuan: '1000' })
|
||||
expect(costly.alert).toBe(true)
|
||||
expect(costly.alerts[0]).toMatch(/loss budget/)
|
||||
expect(sim.simulate(w.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '1' }).alerts[0]).toMatch(/no price forecast/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case desk projections', () => {
|
||||
it('opens a case, tracks proposal/approval/permit refs, and expands lineage to tool IO', () => {
|
||||
const w = world()
|
||||
const desk = new CaseDeskService({ authority: w.authority, snapshots: w.snapshots, events: w.events, clock: () => NOW })
|
||||
const c = desk.open({ id: 'case-1', kind: 'DAY_AHEAD_BID', objective: 'D bid', owner: 'user-trader-01', deadline: '2026-03-14T09:00:00Z' })
|
||||
desk.recordProposal(c.id, w.proposal)
|
||||
desk.requestApproval({ proposal_id: w.proposal.id, proposal_digest: w.proposal.digest, case_id: c.id, run_id: 'run-1', required_level: 'L2', reasons: ['no envelope'], since: NOW })
|
||||
expect(desk.inbox().map((p) => p.run_id)).toEqual(['run-1'])
|
||||
expect(desk.read(c.id).case.status).toBe('AWAITING_APPROVAL')
|
||||
|
||||
desk.recordApproval(c.id, approvalFor(w.proposal, 1))
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
desk.recordPermit(c.id, permit)
|
||||
const view = desk.read(c.id)
|
||||
expect(view.approvals[0]!.approver.id).toBe('user-trader-01')
|
||||
expect(view.permits[0]!.id).toBe('permit-001')
|
||||
expect(view.events.map((e) => e.event_type)).toEqual(['CaseOpened', 'ProposalStatusChanged', 'ApprovalRequested', 'HumanDecision'])
|
||||
|
||||
const lin = desk.expandLineage(w.proposal.id)
|
||||
expect(lin.tool_calls[1]!.tool).toBe('bid-optimization-milp')
|
||||
expect((lin.tool_calls[1]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe('510600.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence for restart survival', () => {
|
||||
it('FsRepository and FileEventBus reload their state in a new instance', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vpp-persist-'))
|
||||
const w = world()
|
||||
const repo = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
|
||||
repo.put(w.proposal)
|
||||
repo.put({ ...w.proposal, status: 'PENDING_HUMAN' }, 1)
|
||||
const reloaded = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
|
||||
expect(reloaded.get('prop-001')?.version).toBe(2)
|
||||
expect(reloaded.get('prop-001')?.value.status).toBe('PENDING_HUMAN')
|
||||
|
||||
const bus = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
|
||||
bus.append({ event_type: 'A', payload: { n: 1 }, correlation_id: 'c1' })
|
||||
bus.append({ event_type: 'B', payload: { n: 2 }, correlation_id: 'c1', causation_id: 'evt-000001' })
|
||||
const again = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
|
||||
expect(again.list().map((e) => e.event_type)).toEqual(['A', 'B'])
|
||||
expect(again.append({ event_type: 'C', payload: {}, correlation_id: 'c2' }).event_id).toBe('evt-000003')
|
||||
expect(new MemoryEventBus().list()).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,99 +0,0 @@
|
||||
import type { Approval, Curve96, Envelope, ForecastBundle, Proposal } from '@vpp/domain'
|
||||
import { AuthorityService } from '../src/authority.js'
|
||||
import { MemoryEventBus } from '../src/events.js'
|
||||
import { LedgerService } from '../src/ledger.js'
|
||||
import { LineageRecorder, assembleBidProposal, withLineage } from '../src/lineage.js'
|
||||
import { HUBEI_BID_PACK_PLACEHOLDER, PolicyEngine, hubeiSpotBiddingPack } from '../src/policy.js'
|
||||
import { MemorySnapshotStore } from '../src/snapshot.js'
|
||||
|
||||
export const NOW = '2026-03-14T08:00:00Z'
|
||||
export const DATE = '2026-03-15'
|
||||
export const PACK_VERSION = '2026.03'
|
||||
|
||||
export const curve = (v: string | string[], date = DATE): Curve96 => ({
|
||||
interval_minutes: 15,
|
||||
date,
|
||||
values: Array.isArray(v) ? v : Array.from({ length: 96 }, () => v),
|
||||
})
|
||||
|
||||
export const priceForecast = (p50 = '425.50'): ForecastBundle => ({
|
||||
id: 'fc-price',
|
||||
kind: 'PRICE',
|
||||
market_date: DATE,
|
||||
unit: 'yuan_per_mwh',
|
||||
quantiles: { p10: curve('360.00'), p50: curve(p50), p90: curve('490.00') },
|
||||
model: { name: 'price-forecast', version: '1.0.0' },
|
||||
features_snapshot_ref: 'a'.repeat(64),
|
||||
generated_at: NOW,
|
||||
})
|
||||
|
||||
/** A consistent little world: ledger with a monthly contract, a recorded MILP output, a proposal built from refs. */
|
||||
export function world(opts: { qty?: string; price?: string; agent?: string } = {}) {
|
||||
const snapshots = new MemorySnapshotStore()
|
||||
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock: () => NOW })
|
||||
ledger.append({
|
||||
id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT',
|
||||
energy_mwh: '37200.0', curve: null, source_ref: 'contract-2026-03-001', expected_version: 0,
|
||||
}) // 1200 MWh/day → band [1140, 1260]
|
||||
const engine = new PolicyEngine([hubeiSpotBiddingPack(PACK_VERSION, HUBEI_BID_PACK_PLACEHOLDER)])
|
||||
const lineage = new LineageRecorder()
|
||||
const events = new MemoryEventBus(() => NOW)
|
||||
const authority = new AuthorityService(undefined, () => 'permit-001')
|
||||
|
||||
let n = 0
|
||||
const milp = withLineage(lineage, snapshots, { tool: 'bid-optimization-milp', version: '1.0.0', idGen: () => `tc-${++n}` }, (_input: unknown) => ({
|
||||
market_date: DATE,
|
||||
prices_yuan_per_mwh: curve(opts.price ?? '0.00'),
|
||||
quantities_mwh: curve(opts.qty ?? '12.5'), // 12.5 × 96 = 1200 MWh
|
||||
expected_revenue_yuan: '510600.00',
|
||||
}))
|
||||
const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast())
|
||||
|
||||
const proposal = lineage.run(() => {
|
||||
forecastTool({ kind: 'PRICE' })
|
||||
const { call } = milp({ market_date: DATE })
|
||||
return assembleBidProposal(
|
||||
{
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: call.tool_call_id, path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: call.tool_call_id, path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: call.tool_call_id, path: 'expected_revenue_yuan' },
|
||||
rationale: 'test',
|
||||
},
|
||||
{
|
||||
id: 'prop-001',
|
||||
originator: { agent: opts.agent ?? 'trading-agent', trigger: 'SCHEDULED', task_id: 'task-001' },
|
||||
lineage: lineage.current(),
|
||||
ledger_version: ledger.read().version,
|
||||
policy_pack_version: PACK_VERSION,
|
||||
snapshots,
|
||||
now: NOW,
|
||||
},
|
||||
)
|
||||
})
|
||||
const ruleCtx = () => ({ ledger: ledger.read(), ledgerService: ledger, snapshots, now: NOW })
|
||||
return { snapshots, ledger, engine, lineage, events, authority, proposal, ruleCtx }
|
||||
}
|
||||
|
||||
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
|
||||
id: 'env-bid-001',
|
||||
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
|
||||
bounds,
|
||||
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
|
||||
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
|
||||
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
})
|
||||
|
||||
export const approvalFor = (p: Proposal, ledgerVersion: number, over: Partial<Approval> = {}): Approval => ({
|
||||
id: 'appr-001',
|
||||
proposal_digest: p.digest,
|
||||
decision: 'APPROVE',
|
||||
approver: { id: 'user-trader-01', role: 'senior-trader' },
|
||||
scope: { effect_type: 'BID', limits: {} },
|
||||
validity: { from: NOW, to: '2026-03-15T09:00:00Z' },
|
||||
evidence_versions: { policy_pack_version: PACK_VERSION, ledger_version: ledgerVersion, data_snapshot_refs: [] },
|
||||
decided_at: NOW,
|
||||
...over,
|
||||
})
|
||||
@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Curve96 } from '@vpp/domain'
|
||||
import { IngestionPipeline } from '../src/ingest.js'
|
||||
import type { IngestSnapshot } from '../src/ingest.js'
|
||||
import { MemorySnapshotStore } from '../src/snapshot.js'
|
||||
import { MemoryTimeSeriesStore } from '../src/timeseries.js'
|
||||
|
||||
const curve = (fill: string): Curve96 => ({
|
||||
interval_minutes: 15,
|
||||
date: '2026-03-14',
|
||||
values: Array.from({ length: 96 }, () => fill),
|
||||
})
|
||||
|
||||
const clock = () => '2026-03-14T08:00:00Z'
|
||||
|
||||
const make = () => {
|
||||
const timeseries = new MemoryTimeSeriesStore(clock)
|
||||
const snapshots = new MemorySnapshotStore()
|
||||
return { timeseries, snapshots, pipeline: new IngestionPipeline({ timeseries, snapshots, clock }) }
|
||||
}
|
||||
|
||||
describe('ingestion pipeline skeleton', () => {
|
||||
it('snapshots then stores a curve that passes the quality gate', () => {
|
||||
const { timeseries, snapshots, pipeline } = make()
|
||||
const result = pipeline.ingestCurve('load:agg-01', curve('12.5'), 'meter-file')
|
||||
expect(result.accepted).toBe(true)
|
||||
if (!result.accepted) throw new Error('unreachable')
|
||||
expect(result.record.version).toBe(1)
|
||||
expect(timeseries.latest('load:agg-01', '2026-03-14')).toEqual(result.record)
|
||||
|
||||
const snap = snapshots.get(result.snapshot_ref) as IngestSnapshot
|
||||
expect(snap.series_id).toBe('load:agg-01')
|
||||
expect(snap.source).toBe('meter-file')
|
||||
expect(snap.quality.ok).toBe(true)
|
||||
expect(snap.curve).toEqual(curve('12.5'))
|
||||
expect(pipeline.quarantined()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('quarantines a curve that fails the gate and keeps it out of the store', () => {
|
||||
const { timeseries, snapshots, pipeline } = make()
|
||||
const result = pipeline.ingestCurve('load:agg-01', curve('0'), 'edge')
|
||||
expect(result.accepted).toBe(false)
|
||||
if (result.accepted) throw new Error('unreachable')
|
||||
expect(result.issues[0]).toMatch(/flat-zero/)
|
||||
expect(timeseries.latest('load:agg-01', '2026-03-14')).toBeUndefined()
|
||||
|
||||
// Quarantined input is still evidence: tagged with its issues and snapshotted.
|
||||
const q = pipeline.quarantined()
|
||||
expect(q).toHaveLength(1)
|
||||
expect(q[0]).toMatchObject({ series_id: 'load:agg-01', date: '2026-03-14', source: 'edge' })
|
||||
const snap = snapshots.get(q[0]!.snapshot_ref) as IngestSnapshot
|
||||
expect(snap.quality.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a custom gate so stricter checks can be plugged in later', () => {
|
||||
const timeseries = new MemoryTimeSeriesStore(clock)
|
||||
const snapshots = new MemorySnapshotStore()
|
||||
const pipeline = new IngestionPipeline({
|
||||
timeseries,
|
||||
snapshots,
|
||||
clock,
|
||||
gate: () => ({ ok: false, issues: ['always rejected'] }),
|
||||
})
|
||||
const result = pipeline.ingestCurve('load:x', curve('1.0'), 'edge')
|
||||
expect(result.accepted).toBe(false)
|
||||
expect(timeseries.seriesIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -75,22 +75,3 @@ describe('views', () => {
|
||||
expect(ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayAheadBounds: the cascade band handed to the bid optimizer', () => {
|
||||
it('returns the same band checkCascade enforces, stamped with the ledger version', () => {
|
||||
const ledger = makeLedger()
|
||||
ledger.append(monthlyContract)
|
||||
expect(ledger.dayAheadBounds('2026-03-15')).toEqual({
|
||||
ledger_version: 1,
|
||||
daily_energy_min_mwh: '1140',
|
||||
daily_energy_max_mwh: '1260',
|
||||
})
|
||||
// A bid exactly at each bound is accepted by append — bounds are inclusive.
|
||||
ledger.append(daBid('1140', 1))
|
||||
ledger.append({ ...daBid('1260', 2), id: 'pos-da-max' })
|
||||
})
|
||||
|
||||
it('throws when there is no monthly anchor', () => {
|
||||
expect(() => makeLedger().dayAheadBounds('2026-03-15')).toThrow(CascadeViolation)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ResourceProfile } from '@vpp/domain'
|
||||
import { RepositoryConcurrencyError, createResourceRegistry } from '../src/relational.js'
|
||||
|
||||
const fixture: ResourceProfile = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../../../contracts/fixtures/resource_profile/storage.json', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
)
|
||||
|
||||
const clock = () => '2026-03-14T08:00:00Z'
|
||||
|
||||
describe('relational store: resource registry (资源台账)', () => {
|
||||
it('puts and gets a schema-valid row with version 1', () => {
|
||||
const reg = createResourceRegistry(clock)
|
||||
const row = reg.put(fixture)
|
||||
expect(row.key).toBe('res-storage-01')
|
||||
expect(row.version).toBe(1)
|
||||
expect(reg.get('res-storage-01')?.value).toEqual(fixture)
|
||||
expect(reg.get('missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects rows that fail the domain schema (no unvalidated master data)', () => {
|
||||
const reg = createResourceRegistry(clock)
|
||||
expect(() => reg.put({ ...fixture, rated_power_mw: 10 as unknown as string })).toThrow()
|
||||
expect(() => reg.put({ ...fixture, type: 'WINDMILL' as never })).toThrow()
|
||||
expect(reg.list()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enforces optimistic concurrency when expectedVersion is supplied', () => {
|
||||
const reg = createResourceRegistry(clock)
|
||||
reg.put(fixture, 0)
|
||||
expect(() => reg.put({ ...fixture, reliability_score: '0.90' }, 0)).toThrow(
|
||||
RepositoryConcurrencyError,
|
||||
)
|
||||
const updated = reg.put({ ...fixture, reliability_score: '0.90' }, 1)
|
||||
expect(updated.version).toBe(2)
|
||||
expect(updated.value.reliability_score).toBe('0.90')
|
||||
})
|
||||
|
||||
it('allows unconditional replace for master-data loads', () => {
|
||||
const reg = createResourceRegistry(clock)
|
||||
reg.put(fixture)
|
||||
const row = reg.put({ ...fixture, name: 'renamed' })
|
||||
expect(row.version).toBe(2)
|
||||
})
|
||||
|
||||
it('lists rows sorted by key and isolates stored values from mutation', () => {
|
||||
const reg = createResourceRegistry(clock)
|
||||
reg.put({ ...fixture, resource_id: 'res-b' })
|
||||
reg.put({ ...fixture, resource_id: 'res-a' })
|
||||
expect(reg.list().map((r) => r.key)).toEqual(['res-a', 'res-b'])
|
||||
const row = reg.get('res-a')!
|
||||
row.value.name = 'mutated'
|
||||
expect(reg.get('res-a')!.value.name).toBe(fixture.name)
|
||||
})
|
||||
})
|
||||
@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Curve96 } from '@vpp/domain'
|
||||
import { MemoryTimeSeriesStore } from '../src/timeseries.js'
|
||||
|
||||
const curve = (date: string, fill = '1.0'): Curve96 => ({
|
||||
interval_minutes: 15,
|
||||
date,
|
||||
values: Array.from({ length: 96 }, () => fill),
|
||||
})
|
||||
|
||||
let tick = 0
|
||||
const clock = () => `2026-03-14T08:00:${String(tick++).padStart(2, '0')}Z`
|
||||
|
||||
describe('time-series store', () => {
|
||||
it('writes a revision and reads it back as latest', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
const rec = store.write('load:agg-01', curve('2026-03-14'), 'meter-file')
|
||||
expect(rec.version).toBe(1)
|
||||
expect(store.latest('load:agg-01', '2026-03-14')).toEqual(rec)
|
||||
expect(store.latest('load:agg-01', '2026-03-15')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps revision history instead of overwriting (late corrections)', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
store.write('load:agg-01', curve('2026-03-14', '1.0'), 'edge')
|
||||
const corrected = store.write('load:agg-01', curve('2026-03-14', '1.1'), 'settlement-meter')
|
||||
expect(corrected.version).toBe(2)
|
||||
const history = store.revisions('load:agg-01', '2026-03-14')
|
||||
expect(history.map((r) => r.version)).toEqual([1, 2])
|
||||
expect(history[0]!.curve.values[0]).toBe('1.0')
|
||||
expect(store.latest('load:agg-01', '2026-03-14')!.curve.values[0]).toBe('1.1')
|
||||
})
|
||||
|
||||
it('is idempotent for identical content', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
const a = store.write('pv:site-7', curve('2026-03-14'), 'edge')
|
||||
const b = store.write('pv:site-7', curve('2026-03-14'), 'edge-retry')
|
||||
expect(b).toBe(a)
|
||||
expect(store.revisions('pv:site-7', '2026-03-14')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns an inclusive date range with latest revision per date, sorted', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
store.write('load:agg-01', curve('2026-03-16'), 'edge')
|
||||
store.write('load:agg-01', curve('2026-03-14', '1.0'), 'edge')
|
||||
store.write('load:agg-01', curve('2026-03-14', '2.0'), 'edge')
|
||||
store.write('load:agg-01', curve('2026-03-13'), 'edge')
|
||||
store.write('load:agg-01', curve('2026-03-17'), 'edge')
|
||||
store.write('pv:site-7', curve('2026-03-15'), 'edge')
|
||||
const out = store.range('load:agg-01', '2026-03-14', '2026-03-16')
|
||||
expect(out.map((r) => r.date)).toEqual(['2026-03-14', '2026-03-16'])
|
||||
expect(out[0]!.version).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects malformed curves and empty series ids', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
expect(() => store.write('', curve('2026-03-14'), 'edge')).toThrow(/series_id/)
|
||||
expect(() =>
|
||||
store.write('load:x', { ...curve('2026-03-14'), values: ['1.0'] }, 'edge'),
|
||||
).toThrow()
|
||||
expect(() => store.range('load:x', '2026/03/14', '2026-03-15')).toThrow()
|
||||
})
|
||||
|
||||
it('stored curves are isolated from caller mutation', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
const input = curve('2026-03-14')
|
||||
store.write('load:x', input, 'edge')
|
||||
input.values[0] = '999'
|
||||
expect(store.latest('load:x', '2026-03-14')!.curve.values[0]).toBe('1.0')
|
||||
})
|
||||
|
||||
it('lists series ids', () => {
|
||||
const store = new MemoryTimeSeriesStore(clock)
|
||||
store.write('pv:site-7', curve('2026-03-14'), 'edge')
|
||||
store.write('load:agg-01', curve('2026-03-14'), 'edge')
|
||||
expect(store.seriesIds()).toEqual(['load:agg-01', 'pv:site-7'])
|
||||
})
|
||||
})
|
||||
@ -1 +0,0 @@
|
||||
3.11
|
||||
@ -1,9 +0,0 @@
|
||||
[project]
|
||||
name = "vpp-skills"
|
||||
version = "0.1.0"
|
||||
description = "Python skill services and generated contract models (see ../ROADMAP.md M2)"
|
||||
# Generated models use StrEnum and PEP 604 unions (generate_models.sh targets 3.11).
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@ -1,9 +1,3 @@
|
||||
pydantic>=2.7
|
||||
datamodel-code-generator>=0.26
|
||||
pytest>=8.0
|
||||
hypothesis>=6.100
|
||||
numpy>=2.0
|
||||
scipy>=1.14
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
httpx>=0.27
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user