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
|
## Current phase
|
||||||
|
|
||||||
Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
|
Docs complete, implementation not started. Build order is ROADMAP.md (M1→M5).
|
||||||
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.
|
|
||||||
Do not start a milestone's work before its predecessor's acceptance criteria are
|
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,
|
testable, and do not build phase-2 items (edge control links, federation,
|
||||||
interaction/load-control agents) unless explicitly asked.
|
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
|
execution permit) governs everything before any external effect. **The LLM never
|
||||||
computes numbers and never touches the second-level control loop.**
|
computes numbers and never touches the second-level control loop.**
|
||||||
|
|
||||||
**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation
|
**Status: architecture/design phase.** No implementation code yet. The design is
|
||||||
follows [ROADMAP.md](ROADMAP.md). Present today:
|
complete and internally consistent (docs 00–13); implementation follows
|
||||||
|
[ROADMAP.md](ROADMAP.md), starting with M1.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Orientation
|
## 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` |
|
| A3 | 日内市场机制(是否开、频次、截止) | `market.intraday` |
|
||||||
| A4 | 偏差考核规则:偏差带、考核价格机制(→ MILP 目标函数与风险口径) | `market.deviation` |
|
| A4 | 偏差考核规则:偏差带、考核价格机制(→ MILP 目标函数与风险口径) | `market.deviation` |
|
||||||
| A5 | 中长期持仓对日前申报的约束形式(分解曲线偏差带) | `ledger.da_bounds` |
|
| A5 | 中长期持仓对日前申报的约束形式(分解曲线偏差带) | `ledger.da_bounds` |
|
||||||
| A6 | 灵活性资源边际成本口径(用户补偿、设备损耗)——报价优化的报价下限;未定前按 0(价格接受者)申报 | `bidding.marginal_cost` |
|
|
||||||
|
|
||||||
## B. 包络与风控参数(owner: 运营团队 · blocks M4 envelopes + M5 breakers)
|
## 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/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"export:schemas": "npm run export:schemas -w @vpp/domain",
|
"export:schemas": "npm run export:schemas -w @vpp/domain",
|
||||||
"make:fixtures": "npm run make:fixtures -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"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"export:schemas": "tsx scripts/export-schemas.ts",
|
"export:schemas": "tsx scripts/export-schemas.ts",
|
||||||
"make:fixtures": "tsx scripts/make-fixtures.ts"
|
"make:fixtures": "tsx scripts/make-fixtures.ts"
|
||||||
|
|||||||
@ -150,199 +150,6 @@ const valid: Record<string, Record<string, unknown>> = {
|
|||||||
generated_at: T0,
|
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: {
|
situation_report: {
|
||||||
'normal-day': {
|
'normal-day': {
|
||||||
id: 'sit-001',
|
id: 'sit-001',
|
||||||
@ -412,36 +219,6 @@ const invalid: Record<string, Record<string, unknown>> = {
|
|||||||
// digest not sha256 hex
|
// digest not sha256 hex
|
||||||
'bad-digest': { ...(valid['approval']!['approve'] as object), proposal_digest: 'not-a-digest' },
|
'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: {
|
position_update: {
|
||||||
// missing optimistic-concurrency field
|
// missing optimistic-concurrency field
|
||||||
'missing-version': (() => {
|
'missing-version': (() => {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
|
|||||||
import { ProposalType } from './proposal.js'
|
import { ProposalType } from './proposal.js'
|
||||||
|
|
||||||
export const ApprovalLevel = z.enum(['L1', 'L2', 'L3'])
|
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). */
|
/** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */
|
||||||
export const Approval = z.object({
|
export const Approval = z.object({
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { z } from 'zod'
|
|||||||
import { Id, IsoUtc } from './common.js'
|
import { Id, IsoUtc } from './common.js'
|
||||||
|
|
||||||
export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS'])
|
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,
|
* 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 —
|
* Forecast output contract (docs/05 §2.1): quantile intervals are mandatory —
|
||||||
* bid risk assessment depends on calibrated bands, not point values.
|
* 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({
|
export const ForecastBundle = z.object({
|
||||||
id: Id,
|
id: Id,
|
||||||
kind: ForecastKind,
|
kind: z.enum(['LOAD', 'PV', 'PRICE']),
|
||||||
market_date: MarketDate,
|
market_date: MarketDate,
|
||||||
unit: z.enum(['mw', 'yuan_per_mwh']),
|
unit: z.enum(['mw', 'yuan_per_mwh']),
|
||||||
quantiles: z.object({
|
quantiles: z.object({
|
||||||
|
|||||||
@ -8,23 +8,6 @@ import { LedgerView, PositionUpdate } from './ledger.js'
|
|||||||
import { Proposal } from './proposal.js'
|
import { Proposal } from './proposal.js'
|
||||||
import { ResourceProfile } from './resource.js'
|
import { ResourceProfile } from './resource.js'
|
||||||
import { SituationReport } from './situation.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 './common.js'
|
||||||
export * from './proposal.js'
|
export * from './proposal.js'
|
||||||
@ -36,8 +19,6 @@ export * from './situation.js'
|
|||||||
export * from './resource.js'
|
export * from './resource.js'
|
||||||
export * from './case.js'
|
export * from './case.js'
|
||||||
export * from './event.js'
|
export * from './event.js'
|
||||||
export * from './skill.js'
|
|
||||||
export * from './chain.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
||||||
@ -56,17 +37,4 @@ export const schemaRegistry: Record<string, z.ZodType> = {
|
|||||||
resource_profile: ResourceProfile,
|
resource_profile: ResourceProfile,
|
||||||
decision_case: DecisionCase,
|
decision_case: DecisionCase,
|
||||||
event_envelope: EventEnvelope,
|
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',
|
'ROLLED_BACK',
|
||||||
'REJECTED',
|
'REJECTED',
|
||||||
])
|
])
|
||||||
export type ProposalStatus = z.infer<typeof ProposalStatus>
|
|
||||||
|
|
||||||
export const ToolCallRef = z.object({
|
export const ToolCallRef = z.object({
|
||||||
tool_call_id: Id,
|
tool_call_id: Id,
|
||||||
@ -33,7 +32,6 @@ export const ToolCallRef = z.object({
|
|||||||
inputs_ref: SnapshotRef,
|
inputs_ref: SnapshotRef,
|
||||||
outputs_ref: SnapshotRef,
|
outputs_ref: SnapshotRef,
|
||||||
})
|
})
|
||||||
export type ToolCallRef = z.infer<typeof ToolCallRef>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provenance attached to every proposal (docs/03 §1). Rule check enforces that
|
* 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(),
|
ledger_version: z.int().nonnegative(),
|
||||||
policy_pack_version: z.string().min(1),
|
policy_pack_version: z.string().min(1),
|
||||||
})
|
})
|
||||||
export type Lineage = z.infer<typeof Lineage>
|
|
||||||
|
|
||||||
/** Day-ahead bid: per-interval price/quantity pairs. */
|
/** Day-ahead bid: per-interval price/quantity pairs. */
|
||||||
export const BidPayload = z.object({
|
export const BidPayload = z.object({
|
||||||
@ -55,7 +52,6 @@ export const BidPayload = z.object({
|
|||||||
quantities_mwh: Curve96,
|
quantities_mwh: Curve96,
|
||||||
expected_revenue_yuan: DecimalString,
|
expected_revenue_yuan: DecimalString,
|
||||||
})
|
})
|
||||||
export type BidPayload = z.infer<typeof BidPayload>
|
|
||||||
|
|
||||||
const ProposalBase = z.object({
|
const ProposalBase = z.object({
|
||||||
id: Id,
|
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"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vpp/domain": "*",
|
"@vpp/domain": "*",
|
||||||
"decimal.js-light": "^2.5.1",
|
"decimal.js-light": "^2.5.1"
|
||||||
"zod": "^4.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.4.1",
|
"@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 './snapshot.js'
|
||||||
export * from './ledger.js'
|
export * from './ledger.js'
|
||||||
export * from './quality.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 { Decimal } from 'decimal.js-light'
|
||||||
import type { LedgerView, PositionBounds, PositionEntry, PositionUpdate, Timescale } from '@vpp/domain'
|
import type { LedgerView, PositionEntry, PositionUpdate, Timescale } from '@vpp/domain'
|
||||||
import { PositionUpdate as PositionUpdateSchema } from '@vpp/domain'
|
import { PositionUpdate as PositionUpdateSchema } from '@vpp/domain'
|
||||||
|
|
||||||
export class LedgerConcurrencyError extends Error {
|
export class LedgerConcurrencyError extends Error {
|
||||||
@ -46,22 +46,6 @@ export class LedgerService {
|
|||||||
return this.entries.filter((e) => e.timescale === timescale)
|
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 {
|
append(update: PositionUpdate): LedgerView {
|
||||||
PositionUpdateSchema.parse(update)
|
PositionUpdateSchema.parse(update)
|
||||||
if (update.expected_version !== this.version) {
|
if (update.expected_version !== this.version) {
|
||||||
@ -87,18 +71,8 @@ export class LedgerService {
|
|||||||
*/
|
*/
|
||||||
private checkCascade(update: PositionUpdate): void {
|
private checkCascade(update: PositionUpdate): void {
|
||||||
if (update.kind !== 'BID_SUBMITTED' || update.timescale !== 'DAY_AHEAD') return
|
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 = update.period.slice(0, 7)
|
||||||
const month = date.slice(0, 7)
|
|
||||||
const monthly = this.entries.filter(
|
const monthly = this.entries.filter(
|
||||||
(e) => e.timescale === 'MONTHLY' && e.kind === 'CONTRACT' && e.period === month,
|
(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`,
|
`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 contracted = monthly.reduce((sum, e) => sum.add(new Decimal(e.energy_mwh)), new Decimal(0))
|
||||||
const daysInMonth = new Date(
|
const daysInMonth = new Date(
|
||||||
Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0),
|
Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0),
|
||||||
).getUTCDate()
|
).getUTCDate()
|
||||||
const dailyShare = contracted.div(daysInMonth)
|
const dailyShare = contracted.div(daysInMonth)
|
||||||
const band = new Decimal(this.cfg.daMonthlyDeviationBand)
|
const band = new Decimal(this.cfg.daMonthlyDeviationBand)
|
||||||
return {
|
const min = dailyShare.mul(new Decimal(1).sub(band))
|
||||||
min: dailyShare.mul(new Decimal(1).sub(band)),
|
const max = dailyShare.mul(new Decimal(1).add(band))
|
||||||
max: dailyShare.mul(new Decimal(1).add(band)),
|
const bid = new Decimal(update.energy_mwh)
|
||||||
contracted,
|
|
||||||
daysInMonth,
|
if (bid.lt(min) || bid.gt(max)) {
|
||||||
band,
|
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)
|
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
|
pydantic>=2.7
|
||||||
datamodel-code-generator>=0.26
|
datamodel-code-generator>=0.26
|
||||||
pytest>=8.0
|
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