diff --git a/CLAUDE.md b/CLAUDE.md index f4fd354..68a1b38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,17 @@ docs win — or write an ADR changing the doc first. ## Current phase -Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic -services (ledger, stores, policy engine, envelope, authority/permits, gateway, -events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval -harness with committed baseline (synthetic data); Mastra runtime with the -proposal-lifecycle safety chain (durable suspend/resume), situation and bid -workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage -is file-backed reference semantics — Postgres/Timescale adapters later. Next is -M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric +Docs complete; M1–M4 implemented: schemas + contracts pipeline; deterministic +services (ledger, stores, policy engine incl. dispatch rules, envelope with +deviation-streak suspension, authority/permits, file-export + simulation +gateways, events, lineage assemblers, revenue + power-balance simulation, +review service, Case Desk); Python skill service (forecasts, bid MILP, report, +potential assessment, dispatch optimization); L2 eval harness with committed +baseline (synthetic data); Mastra runtime with the proposal-lifecycle safety +chain (durable suspend/resume), situation / bid / award-decomposition / review / +envelope-review workflows, trigger service, LLM port with LLM-down mode, Case +Desk API with insight cards. Storage is file-backed reference semantics — +Postgres/Timescale adapters later. Next is M5 (shadow run). 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. diff --git a/README.md b/README.md index c3c2b69..27d8504 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ a deterministic safety chain (rule check → simulation → envelope/human appro execution permit) governs everything before any external effect. **The LLM never computes numbers and never touches the second-level control loop.** -**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation +**Status: M1–M4 implemented.** Design docs 00–13 are complete; implementation follows [ROADMAP.md](ROADMAP.md). Present today: - `packages/domain` — zod schemas for every business and safety-chain object, exported to @@ -20,14 +20,20 @@ follows [ROADMAP.md](ROADMAP.md). Present today: - `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). + `award-decomposition` (resource agent: potential assessment → dispatch optimization → + DISPATCH_PLAN through the same chain to a simulation gateway), `review` (D+1 + plan-vs-actual attribution → ReviewFinding → semantic memory + reliability writebacks + + envelope change requests), `envelope-review` (the only path that changes an envelope, + human-approved); envelope deviation-streak suspension; trigger service (scheduled / event / + manual via router agent); provider-abstracted LLM port with an LLM-down mode; Case Desk + HTTP API incl. AI insight cards. 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; +`packages/runtime/test/lifecycle.test.ts`); the docs/07 D-1 16:00 and D+1 sections run in +`packages/runtime/test/m4.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. +M5 (shadow run) is next. ## Development diff --git a/contracts/fixtures-invalid/review_finding/unknown-writeback.json b/contracts/fixtures-invalid/review_finding/unknown-writeback.json new file mode 100644 index 0000000..9bfcf74 --- /dev/null +++ b/contracts/fixtures-invalid/review_finding/unknown-writeback.json @@ -0,0 +1,31 @@ +{ + "id": "rf-2026-03-15-response", + "market_date": "2026-03-15", + "kind": "RESPONSE", + "summary": "Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.", + "attribution": { + "trading_mwh": "48.000", + "control_mwh": "0.000", + "response_mwh": "57.600", + "forecast_load_mape": "0.041", + "forecast_price_mape": "0.017", + "net_deviation_mwh": "57.600" + }, + "writebacks": [ + { + "target": "LEDGER", + "note": "nope" + } + ], + "evidence_refs": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "lineage_refs": [ + { + "tool_call_id": "tc-review-001", + "path": "attribution" + } + ], + "generated_at": "2026-03-16T03:00:00Z" +} diff --git a/contracts/fixtures/award_notice/da-award.json b/contracts/fixtures/award_notice/da-award.json new file mode 100644 index 0000000..fe391bc --- /dev/null +++ b/contracts/fixtures/award_notice/da-award.json @@ -0,0 +1,210 @@ +{ + "id": "award-2026-03-15", + "market_date": "2026-03-15", + "bid_proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "awarded_mwh": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0", + "12.0" + ] + }, + "clearing_price_yuan_per_mwh": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30", + "418.30" + ] + }, + "received_at": "2026-03-14T08:00:00Z" +} diff --git a/contracts/fixtures/dispatch_optimization_request/basic.json b/contracts/fixtures/dispatch_optimization_request/basic.json new file mode 100644 index 0000000..b5f69f0 --- /dev/null +++ b/contracts/fixtures/dispatch_optimization_request/basic.json @@ -0,0 +1,319 @@ +{ + "market_date": "2026-03-15", + "target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0" + ] + }, + "units": [ + { + "unit_id": "agg-unit-wuhan-01", + "available_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0" + ] + }, + "cost_weight": "1.0" + }, + { + "unit_id": "agg-unit-wuhan-02", + "available_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0", + "30.0" + ] + }, + "cost_weight": "1.2" + } + ] +} diff --git a/contracts/fixtures/dispatch_optimization_result/basic.json b/contracts/fixtures/dispatch_optimization_result/basic.json new file mode 100644 index 0000000..0a8ac03 --- /dev/null +++ b/contracts/fixtures/dispatch_optimization_result/basic.json @@ -0,0 +1,325 @@ +{ + "market_date": "2026-03-15", + "total_target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0" + ] + }, + "allocations": [ + { + "unit_id": "agg-unit-wuhan-01", + "target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000", + "30.000" + ] + } + }, + { + "unit_id": "agg-unit-wuhan-02", + "target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000", + "18.000" + ] + } + } + ], + "shortfall_mwh": "0.000", + "solver": { + "name": "highs", + "version": "scipy-1.17.1", + "status": "OPTIMAL", + "wall_time_ms": 4 + }, + "skill_version": "1.0.0" +} diff --git a/contracts/fixtures/dispatch_proposal_draft/basic.json b/contracts/fixtures/dispatch_proposal_draft/basic.json new file mode 100644 index 0000000..7000adc --- /dev/null +++ b/contracts/fixtures/dispatch_proposal_draft/basic.json @@ -0,0 +1,12 @@ +{ + "market_date": "2026-03-15", + "total_target_ref": { + "tool_call_id": "tc-002", + "path": "total_target_mw" + }, + "allocations_ref": { + "tool_call_id": "tc-002", + "path": "allocations" + }, + "rationale": "Storage covers the evening block; industrial load takes the midday share." +} diff --git a/contracts/fixtures/envelope_change_request/widen.json b/contracts/fixtures/envelope_change_request/widen.json new file mode 100644 index 0000000..a6ae6b1 --- /dev/null +++ b/contracts/fixtures/envelope_change_request/widen.json @@ -0,0 +1,12 @@ +{ + "id": "ecr-001", + "envelope_id": "env-bid-001", + "action": "WIDEN", + "bounds": { + "price_deviation_pct": "6.0", + "max_energy_mwh": "1500.0" + }, + "basis": "ReviewFinding rf-2026-03-15-response: 26/26 within-envelope days under threshold", + "source_finding_id": "rf-2026-03-15-response", + "requested_at": "2026-03-16T03:00:00Z" +} diff --git a/contracts/fixtures/execution_report/unit-01.json b/contracts/fixtures/execution_report/unit-01.json new file mode 100644 index 0000000..926d8e3 --- /dev/null +++ b/contracts/fixtures/execution_report/unit-01.json @@ -0,0 +1,213 @@ +{ + "id": "exec-2026-03-15-agg-unit-wuhan-01", + "market_date": "2026-03-15", + "unit_id": "agg-unit-wuhan-01", + "dispatch_proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "planned_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0" + ] + }, + "actual_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6" + ] + }, + "deviation_mwh": "57.600", + "fulfillment_ratio": "0.950", + "reported_at": "2026-03-16T01:00:00Z" +} diff --git a/contracts/fixtures/insight_card/trading-desk.json b/contracts/fixtures/insight_card/trading-desk.json new file mode 100644 index 0000000..180a1b1 --- /dev/null +++ b/contracts/fixtures/insight_card/trading-desk.json @@ -0,0 +1,24 @@ +{ + "id": "card-001", + "page": "TRADING_DESK", + "market_date": "2026-03-15", + "title": "Day-ahead situation", + "headline": "Risk LOW; P50 peak price 425.50 yuan/MWh", + "metrics": [ + { + "name": "peak_price_p50", + "value": "425.50", + "unit": "yuan_per_mwh", + "ref": { + "tool_call_id": "tc-001", + "path": "quantiles.p50.values.76" + } + } + ], + "source": { + "kind": "SITUATION_REPORT", + "id": "sit-001", + "ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "generated_at": "2026-03-14T06:00:00Z" +} diff --git a/contracts/fixtures/metering_record/da-metering.json b/contracts/fixtures/metering_record/da-metering.json new file mode 100644 index 0000000..f9ad758 --- /dev/null +++ b/contracts/fixtures/metering_record/da-metering.json @@ -0,0 +1,209 @@ +{ + "id": "meter-2026-03-15", + "market_date": "2026-03-15", + "metered_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6", + "45.6" + ] + }, + "actual_price_yuan_per_mwh": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00", + "421.00" + ] + }, + "received_at": "2026-03-16T02:00:00Z" +} diff --git a/contracts/fixtures/potential_assessment_request/basic.json b/contracts/fixtures/potential_assessment_request/basic.json new file mode 100644 index 0000000..3f6d4e7 --- /dev/null +++ b/contracts/fixtures/potential_assessment_request/basic.json @@ -0,0 +1,37 @@ +{ + "market_date": "2026-03-15", + "resources": [ + { + "profile": { + "resource_id": "res-storage-01", + "name": "Wuhan industrial park storage #1", + "type": "STORAGE", + "rated_power_mw": "10.0", + "certified_adjustable_mw": "8.0", + "confidence": "0.92", + "reliability_score": "0.88", + "constraints": { + "min_duration_min": 60, + "recovery_rate_mw_per_min": "0.5" + }, + "evidence_refs": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "updated_at": "2026-03-14T06:00:00Z" + }, + "fulfillment": [ + { + "market_date": "2026-03-13", + "planned_mwh": "40.0", + "delivered_mwh": "38.0" + }, + { + "market_date": "2026-03-14", + "planned_mwh": "40.0", + "delivered_mwh": "39.0" + } + ] + } + ], + "features_snapshot_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/contracts/fixtures/potential_assessment_result/basic.json b/contracts/fixtures/potential_assessment_result/basic.json new file mode 100644 index 0000000..5b87ec9 --- /dev/null +++ b/contracts/fixtures/potential_assessment_result/basic.json @@ -0,0 +1,216 @@ +{ + "market_date": "2026-03-15", + "assessments": [ + { + "resource_id": "res-storage-01", + "adjustable_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700" + ] + }, + "confidence": "0.90", + "fulfillment_rate": "0.9625", + "evidence_days": 2 + } + ], + "total_adjustable_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700", + "7.700" + ] + }, + "skill_version": "1.0.0" +} diff --git a/contracts/fixtures/proposal/dispatch-plan.json b/contracts/fixtures/proposal/dispatch-plan.json new file mode 100644 index 0000000..57b1bbd --- /dev/null +++ b/contracts/fixtures/proposal/dispatch-plan.json @@ -0,0 +1,244 @@ +{ + "id": "prop-002", + "type": "DISPATCH_PLAN", + "timescale": "DAY_AHEAD", + "originator": { + "agent": "resource-agent", + "trigger": "EVENT", + "task_id": "task-002" + }, + "payload": { + "kind": "DISPATCH_PLAN", + "market_date": "2026-03-15", + "award_ref": "award-2026-03-15", + "total_target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0" + ] + }, + "allocations": [ + { + "unit_id": "agg-unit-wuhan-01", + "target_mw": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0", + "48.0" + ] + } + } + ] + }, + "lineage": { + "tool_calls": [ + { + "tool_call_id": "tc-002", + "tool": "dispatch-optimization", + "version": "1.0.0", + "inputs_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "outputs_ref": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ], + "data_refs": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "ledger_version": 43, + "policy_pack_version": "2026.03" + }, + "envelope_ref": null, + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "status": "DRAFT", + "created_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/review_finding/d-plus-1.json b/contracts/fixtures/review_finding/d-plus-1.json new file mode 100644 index 0000000..bbf3a76 --- /dev/null +++ b/contracts/fixtures/review_finding/d-plus-1.json @@ -0,0 +1,48 @@ +{ + "id": "rf-2026-03-15-response", + "market_date": "2026-03-15", + "kind": "RESPONSE", + "summary": "Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.", + "attribution": { + "trading_mwh": "48.000", + "control_mwh": "0.000", + "response_mwh": "57.600", + "forecast_load_mape": "0.041", + "forecast_price_mape": "0.017", + "net_deviation_mwh": "57.600" + }, + "writebacks": [ + { + "target": "SEMANTIC_MEMORY", + "topic": "response:agg-unit-wuhan-01", + "lesson": "Evening block under-delivery on 2026-03-15." + }, + { + "target": "RESOURCE_PROFILE", + "resource_id": "res-storage-01", + "previous_reliability_score": "0.88", + "new_reliability_score": "0.894", + "fulfillment_ratio": "0.950" + }, + { + "target": "ENVELOPE_RECOMMENDATION", + "envelope_id": "env-bid-001", + "action": "WIDEN", + "bound": "price_deviation_pct", + "current_value": "5.0", + "recommended_value": "6.0", + "basis": "26/26 within-envelope days under threshold" + } + ], + "evidence_refs": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "lineage_refs": [ + { + "tool_call_id": "tc-review-001", + "path": "attribution" + } + ], + "generated_at": "2026-03-16T03:00:00Z" +} diff --git a/contracts/fixtures/semantic_memory_entry/basic.json b/contracts/fixtures/semantic_memory_entry/basic.json new file mode 100644 index 0000000..4902ffd --- /dev/null +++ b/contracts/fixtures/semantic_memory_entry/basic.json @@ -0,0 +1,10 @@ +{ + "id": "mem-001", + "topic": "forecast:load:hot-day", + "lesson": "Hot-day HVAC load systematically under-forecast; feature-engineering candidate.", + "source_finding_id": "rf-2026-03-15-response", + "evidence_refs": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "created_at": "2026-03-16T03:00:00Z" +} diff --git a/contracts/schema/award_notice.json b/contracts/schema/award_notice.json new file mode 100644 index 0000000..0e2101e --- /dev/null +++ b/contracts/schema/award_notice.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "bid_proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "awarded_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 + }, + "clearing_price_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 + }, + "received_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", + "market_date", + "bid_proposal_digest", + "awarded_mwh", + "clearing_price_yuan_per_mwh", + "received_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/award_notice.json", + "title": "AwardNotice" +} diff --git a/contracts/schema/dispatch_optimization_request.json b/contracts/schema/dispatch_optimization_request.json new file mode 100644 index 0000000..790393d --- /dev/null +++ b/contracts/schema/dispatch_optimization_request.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "target_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 + }, + "units": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "unit_id": { + "type": "string", + "minLength": 1 + }, + "available_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 + }, + "cost_weight": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "required": [ + "unit_id", + "available_mw", + "cost_weight" + ], + "additionalProperties": false + } + } + }, + "required": [ + "market_date", + "target_mw", + "units" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/dispatch_optimization_request.json", + "title": "DispatchOptimizationRequest" +} diff --git a/contracts/schema/dispatch_optimization_result.json b/contracts/schema/dispatch_optimization_result.json new file mode 100644 index 0000000..22431ed --- /dev/null +++ b/contracts/schema/dispatch_optimization_result.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "total_target_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 + }, + "allocations": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "unit_id": { + "type": "string", + "minLength": 1 + }, + "target_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 + } + }, + "required": [ + "unit_id", + "target_mw" + ], + "additionalProperties": false + } + }, + "shortfall_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "solver": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "OPTIMAL", + "INFEASIBLE", + "ERROR" + ] + }, + "wall_time_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "name", + "version", + "status", + "wall_time_ms" + ], + "additionalProperties": false + }, + "skill_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + } + }, + "required": [ + "market_date", + "total_target_mw", + "allocations", + "shortfall_mwh", + "solver", + "skill_version" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/dispatch_optimization_result.json", + "title": "DispatchOptimizationResult" +} diff --git a/contracts/schema/dispatch_proposal_draft.json b/contracts/schema/dispatch_proposal_draft.json new file mode 100644 index 0000000..5fc11b3 --- /dev/null +++ b/contracts/schema/dispatch_proposal_draft.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "total_target_ref": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + }, + "allocations_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", + "total_target_ref", + "allocations_ref", + "rationale" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/dispatch_proposal_draft.json", + "title": "DispatchProposalDraft" +} diff --git a/contracts/schema/envelope_change_request.json b/contracts/schema/envelope_change_request.json new file mode 100644 index 0000000..32d81e8 --- /dev/null +++ b/contracts/schema/envelope_change_request.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "envelope_id": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "enum": [ + "WIDEN", + "NARROW", + "SUSPEND", + "REACTIVATE" + ] + }, + "bounds": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "basis": { + "type": "string", + "minLength": 1 + }, + "source_finding_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "requested_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", + "envelope_id", + "action", + "bounds", + "basis", + "source_finding_id", + "requested_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/envelope_change_request.json", + "title": "EnvelopeChangeRequest" +} diff --git a/contracts/schema/execution_report.json b/contracts/schema/execution_report.json new file mode 100644 index 0000000..5524659 --- /dev/null +++ b/contracts/schema/execution_report.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "unit_id": { + "type": "string", + "minLength": 1 + }, + "dispatch_proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "planned_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 + }, + "actual_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 + }, + "deviation_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "fulfillment_ratio": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "reported_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", + "market_date", + "unit_id", + "dispatch_proposal_digest", + "planned_mw", + "actual_mw", + "deviation_mwh", + "fulfillment_ratio", + "reported_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/execution_report.json", + "title": "ExecutionReport" +} diff --git a/contracts/schema/insight_card.json b/contracts/schema/insight_card.json new file mode 100644 index 0000000..db4be85 --- /dev/null +++ b/contracts/schema/insight_card.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "page": { + "type": "string", + "enum": [ + "TRADING_DESK", + "RESOURCE_POOL", + "OPERATIONS_DASHBOARD" + ] + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "headline": { + "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": { + "anyOf": [ + { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "value", + "unit", + "ref" + ], + "additionalProperties": false + } + }, + "source": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "SITUATION_REPORT", + "REVIEW_FINDING", + "DECISION_CASE", + "POTENTIAL_ASSESSMENT" + ] + }, + "id": { + "type": "string", + "minLength": 1 + }, + "ref": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "id", + "ref" + ], + "additionalProperties": false + }, + "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", + "page", + "market_date", + "title", + "headline", + "metrics", + "source", + "generated_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/insight_card.json", + "title": "InsightCard" +} diff --git a/contracts/schema/metering_record.json b/contracts/schema/metering_record.json new file mode 100644 index 0000000..c19c711 --- /dev/null +++ b/contracts/schema/metering_record.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "metered_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 + }, + "actual_price_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 + }, + "received_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", + "market_date", + "metered_mw", + "actual_price_yuan_per_mwh", + "received_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/metering_record.json", + "title": "MeteringRecord" +} diff --git a/contracts/schema/potential_assessment_request.json b/contracts/schema/potential_assessment_request.json new file mode 100644 index 0000000..bb5f724 --- /dev/null +++ b/contracts/schema/potential_assessment_request.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "resources": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "resource_id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": [ + "STORAGE", + "PV", + "EV_CHARGER", + "HVAC", + "INDUSTRIAL_LOAD", + "MICROGRID", + "BASE_STATION" + ] + }, + "rated_power_mw": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "certified_adjustable_mw": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "confidence": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "reliability_score": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "constraints": { + "type": "object", + "properties": { + "min_duration_min": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "recovery_rate_mw_per_min": { + "anyOf": [ + { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "min_duration_min", + "recovery_rate_mw_per_min" + ], + "additionalProperties": false + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "updated_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": [ + "resource_id", + "name", + "type", + "rated_power_mw", + "certified_adjustable_mw", + "confidence", + "reliability_score", + "constraints", + "evidence_refs", + "updated_at" + ], + "additionalProperties": false + }, + "fulfillment": { + "type": "array", + "items": { + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "planned_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "delivered_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "required": [ + "market_date", + "planned_mwh", + "delivered_mwh" + ], + "additionalProperties": false + } + } + }, + "required": [ + "profile", + "fulfillment" + ], + "additionalProperties": false + } + }, + "features_snapshot_ref": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "market_date", + "resources", + "features_snapshot_ref" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/potential_assessment_request.json", + "title": "PotentialAssessmentRequest" +} diff --git a/contracts/schema/potential_assessment_result.json b/contracts/schema/potential_assessment_result.json new file mode 100644 index 0000000..82a2dd7 --- /dev/null +++ b/contracts/schema/potential_assessment_result.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "assessments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource_id": { + "type": "string", + "minLength": 1 + }, + "adjustable_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 + }, + "confidence": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "fulfillment_rate": { + "anyOf": [ + { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + { + "type": "null" + } + ] + }, + "evidence_days": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "resource_id", + "adjustable_mw", + "confidence", + "fulfillment_rate", + "evidence_days" + ], + "additionalProperties": false + } + }, + "total_adjustable_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 + }, + "skill_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + } + }, + "required": [ + "market_date", + "assessments", + "total_adjustable_mw", + "skill_version" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/potential_assessment_result.json", + "title": "PotentialAssessmentResult" +} diff --git a/contracts/schema/proposal.json b/contracts/schema/proposal.json index bbeb95b..e146885 100644 --- a/contracts/schema/proposal.json +++ b/contracts/schema/proposal.json @@ -248,6 +248,272 @@ "payload" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "timescale": { + "type": "string", + "enum": [ + "ANNUAL", + "MONTHLY", + "DAY_AHEAD", + "INTRADAY", + "REALTIME" + ] + }, + "originator": { + "type": "object", + "properties": { + "agent": { + "type": "string", + "minLength": 1 + }, + "trigger": { + "type": "string", + "enum": [ + "MANUAL", + "EVENT", + "SCHEDULED" + ] + }, + "task_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "agent", + "trigger", + "task_id" + ], + "additionalProperties": false + }, + "lineage": { + "type": "object", + "properties": { + "tool_calls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "inputs_ref": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "outputs_ref": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "tool_call_id", + "tool", + "version", + "inputs_ref", + "outputs_ref" + ], + "additionalProperties": false + } + }, + "data_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "ledger_version": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "policy_pack_version": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_calls", + "data_refs", + "ledger_version", + "policy_pack_version" + ], + "additionalProperties": false + }, + "envelope_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "RULE_CHECK", + "SIMULATION", + "ENVELOPE_CHECK", + "PENDING_HUMAN", + "AUTO_APPROVED", + "APPROVED", + "FRESH_CHECK", + "STALE", + "AUTHORIZED", + "RELEASED", + "EXECUTING", + "COMPLETED", + "ROLLED_BACK", + "REJECTED" + ] + }, + "created_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))$" + }, + "type": { + "type": "string", + "const": "DISPATCH_PLAN" + }, + "payload": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "DISPATCH_PLAN" + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "award_ref": { + "type": "string", + "minLength": 1 + }, + "total_target_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 + }, + "allocations": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "unit_id": { + "type": "string", + "minLength": 1 + }, + "target_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 + } + }, + "required": [ + "unit_id", + "target_mw" + ], + "additionalProperties": false + } + } + }, + "required": [ + "kind", + "market_date", + "award_ref", + "total_target_mw", + "allocations" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "timescale", + "originator", + "lineage", + "envelope_ref", + "digest", + "status", + "created_at", + "type", + "payload" + ], + "additionalProperties": false } ], "$id": "https://vpp-ai-platform/contracts/proposal.json", diff --git a/contracts/schema/review_finding.json b/contracts/schema/review_finding.json new file mode 100644 index 0000000..3f27d94 --- /dev/null +++ b/contracts/schema/review_finding.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "kind": { + "type": "string", + "enum": [ + "FORECAST", + "RESPONSE", + "TRADING", + "CONTROL" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "attribution": { + "type": "object", + "properties": { + "trading_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "control_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "response_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "forecast_load_mape": { + "anyOf": [ + { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + { + "type": "null" + } + ] + }, + "forecast_price_mape": { + "anyOf": [ + { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + { + "type": "null" + } + ] + }, + "net_deviation_mwh": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "required": [ + "trading_mwh", + "control_mwh", + "response_mwh", + "forecast_load_mape", + "forecast_price_mape", + "net_deviation_mwh" + ], + "additionalProperties": false + }, + "writebacks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "target": { + "type": "string", + "const": "SEMANTIC_MEMORY" + }, + "topic": { + "type": "string", + "minLength": 1 + }, + "lesson": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "target", + "topic", + "lesson" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "target": { + "type": "string", + "const": "RESOURCE_PROFILE" + }, + "resource_id": { + "type": "string", + "minLength": 1 + }, + "previous_reliability_score": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "new_reliability_score": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "fulfillment_ratio": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "required": [ + "target", + "resource_id", + "previous_reliability_score", + "new_reliability_score", + "fulfillment_ratio" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "target": { + "type": "string", + "const": "ENVELOPE_RECOMMENDATION" + }, + "envelope_id": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "enum": [ + "WIDEN", + "NARROW", + "SUSPEND", + "KEEP" + ] + }, + "bound": { + "type": "string", + "minLength": 1 + }, + "current_value": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "recommended_value": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "basis": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "target", + "envelope_id", + "action", + "bound", + "current_value", + "recommended_value", + "basis" + ], + "additionalProperties": false + } + ] + } + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "lineage_refs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + } + }, + "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", + "market_date", + "kind", + "summary", + "attribution", + "writebacks", + "evidence_refs", + "lineage_refs", + "generated_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/review_finding.json", + "title": "ReviewFinding" +} diff --git a/contracts/schema/semantic_memory_entry.json b/contracts/schema/semantic_memory_entry.json new file mode 100644 index 0000000..38e651a --- /dev/null +++ b/contracts/schema/semantic_memory_entry.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "topic": { + "type": "string", + "minLength": 1 + }, + "lesson": { + "type": "string", + "minLength": 1 + }, + "source_finding_id": { + "type": "string", + "minLength": 1 + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "created_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", + "topic", + "lesson", + "source_finding_id", + "evidence_refs", + "created_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/semantic_memory_entry.json", + "title": "SemanticMemoryEntry" +} diff --git a/docs/open-questions.md b/docs/open-questions.md index 0306f43..2a4d906 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -29,6 +29,7 @@ values for items on this list** — wire named config with placeholder + an | B7 | 极端日协议触发的气象条件 | `abnormal_day.triggers` | | B8 | 各级熔断(L0–L4)授权岗位与恢复条件 | `breaker.authority` | | B9 | 演习样本制度:频率与考核挂钩方式 | — (制度非配置) | +| B10 | 可靠性评分与可调潜力辨识口径:评分更新权重(EWMA α)、证据窗口与饱和天数、包络放宽所需连续达标天数与放宽步长 | `review.reliability_alpha` / `potential.evidence_window` / `envelope.widen_*` | ## C. KPI 口径(owner: 运营 + 验收双方 · blocks M5 dashboard) diff --git a/packages/domain/scripts/make-fixtures.ts b/packages/domain/scripts/make-fixtures.ts index fa7358a..d340776 100644 --- a/packages/domain/scripts/make-fixtures.ts +++ b/packages/domain/scripts/make-fixtures.ts @@ -57,6 +57,31 @@ const valid: Record> = { status: 'DRAFT', created_at: T1, }, + 'dispatch-plan': { + id: 'prop-002', + type: 'DISPATCH_PLAN', + timescale: 'DAY_AHEAD', + originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: 'task-002' }, + payload: { + kind: 'DISPATCH_PLAN', + market_date: DATE, + award_ref: 'award-2026-03-15', + total_target_mw: curve('48.0'), + allocations: [{ unit_id: 'agg-unit-wuhan-01', target_mw: curve('48.0') }], + }, + lineage: { + tool_calls: [ + { tool_call_id: 'tc-002', tool: 'dispatch-optimization', version: '1.0.0', inputs_ref: REF_A, outputs_ref: REF_C }, + ], + data_refs: [REF_C], + ledger_version: 43, + policy_pack_version: '2026.03', + }, + envelope_ref: null, + digest: REF_C, + status: 'DRAFT', + created_at: T1, + }, }, approval: { approve: { @@ -343,6 +368,162 @@ const valid: Record> = { exported_at: T1, }, }, + award_notice: { + 'da-award': { + id: 'award-2026-03-15', + market_date: DATE, + bid_proposal_digest: REF_B, + awarded_mwh: curve('12.0'), + clearing_price_yuan_per_mwh: curve('418.30'), + received_at: '2026-03-14T08:00:00Z', + }, + }, + dispatch_proposal_draft: { + basic: { + market_date: DATE, + total_target_ref: { tool_call_id: 'tc-002', path: 'total_target_mw' }, + allocations_ref: { tool_call_id: 'tc-002', path: 'allocations' }, + rationale: 'Storage covers the evening block; industrial load takes the midday share.', + }, + }, + execution_report: { + 'unit-01': { + id: 'exec-2026-03-15-agg-unit-wuhan-01', + market_date: DATE, + unit_id: 'agg-unit-wuhan-01', + dispatch_proposal_digest: REF_B, + planned_mw: curve('48.0'), + actual_mw: curve('45.6'), + deviation_mwh: '57.600', + fulfillment_ratio: '0.950', + reported_at: '2026-03-16T01:00:00Z', + }, + }, + metering_record: { + 'da-metering': { + id: 'meter-2026-03-15', + market_date: DATE, + metered_mw: curve('45.6'), + actual_price_yuan_per_mwh: curve('421.00'), + received_at: '2026-03-16T02:00:00Z', + }, + }, + potential_assessment_request: { + basic: { + market_date: DATE, + resources: [ + { + profile: { + resource_id: 'res-storage-01', + name: 'Wuhan industrial park storage #1', + type: 'STORAGE', + rated_power_mw: '10.0', + certified_adjustable_mw: '8.0', + confidence: '0.92', + reliability_score: '0.88', + constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, + evidence_refs: [REF_C], + updated_at: T0, + }, + fulfillment: [ + { market_date: '2026-03-13', planned_mwh: '40.0', delivered_mwh: '38.0' }, + { market_date: '2026-03-14', planned_mwh: '40.0', delivered_mwh: '39.0' }, + ], + }, + ], + features_snapshot_ref: REF_A, + }, + }, + potential_assessment_result: { + basic: { + market_date: DATE, + assessments: [ + { resource_id: 'res-storage-01', adjustable_mw: curve('7.700'), confidence: '0.90', fulfillment_rate: '0.9625', evidence_days: 2 }, + ], + total_adjustable_mw: curve('7.700'), + skill_version: '1.0.0', + }, + }, + dispatch_optimization_request: { + basic: { + market_date: DATE, + target_mw: curve('48.0'), + units: [ + { unit_id: 'agg-unit-wuhan-01', available_mw: curve('30.0'), cost_weight: '1.0' }, + { unit_id: 'agg-unit-wuhan-02', available_mw: curve('30.0'), cost_weight: '1.2' }, + ], + }, + }, + dispatch_optimization_result: { + basic: { + market_date: DATE, + total_target_mw: curve('48.0'), + allocations: [ + { unit_id: 'agg-unit-wuhan-01', target_mw: curve('30.000') }, + { unit_id: 'agg-unit-wuhan-02', target_mw: curve('18.000') }, + ], + shortfall_mwh: '0.000', + solver: { name: 'highs', version: 'scipy-1.17.1', status: 'OPTIMAL', wall_time_ms: 4 }, + skill_version: '1.0.0', + }, + }, + review_finding: { + 'd-plus-1': { + id: 'rf-2026-03-15-response', + market_date: DATE, + kind: 'RESPONSE', + summary: 'Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.', + attribution: { + trading_mwh: '48.000', + control_mwh: '0.000', + response_mwh: '57.600', + forecast_load_mape: '0.041', + forecast_price_mape: '0.017', + net_deviation_mwh: '57.600', + }, + writebacks: [ + { target: 'SEMANTIC_MEMORY', topic: 'response:agg-unit-wuhan-01', lesson: 'Evening block under-delivery on 2026-03-15.' }, + { target: 'RESOURCE_PROFILE', resource_id: 'res-storage-01', previous_reliability_score: '0.88', new_reliability_score: '0.894', fulfillment_ratio: '0.950' }, + { target: 'ENVELOPE_RECOMMENDATION', envelope_id: 'env-bid-001', action: 'WIDEN', bound: 'price_deviation_pct', current_value: '5.0', recommended_value: '6.0', basis: '26/26 within-envelope days under threshold' }, + ], + evidence_refs: [REF_A, REF_C], + lineage_refs: [{ tool_call_id: 'tc-review-001', path: 'attribution' }], + generated_at: '2026-03-16T03:00:00Z', + }, + }, + semantic_memory_entry: { + basic: { + id: 'mem-001', + topic: 'forecast:load:hot-day', + lesson: 'Hot-day HVAC load systematically under-forecast; feature-engineering candidate.', + source_finding_id: 'rf-2026-03-15-response', + evidence_refs: [REF_A], + created_at: '2026-03-16T03:00:00Z', + }, + }, + envelope_change_request: { + widen: { + id: 'ecr-001', + envelope_id: 'env-bid-001', + action: 'WIDEN', + bounds: { price_deviation_pct: '6.0', max_energy_mwh: '1500.0' }, + basis: 'ReviewFinding rf-2026-03-15-response: 26/26 within-envelope days under threshold', + source_finding_id: 'rf-2026-03-15-response', + requested_at: '2026-03-16T03:00:00Z', + }, + }, + insight_card: { + 'trading-desk': { + id: 'card-001', + page: 'TRADING_DESK', + market_date: DATE, + title: 'Day-ahead situation', + headline: 'Risk LOW; P50 peak price 425.50 yuan/MWh', + metrics: [{ name: 'peak_price_p50', value: '425.50', unit: 'yuan_per_mwh', ref: { tool_call_id: 'tc-001', path: 'quantiles.p50.values.76' } }], + source: { kind: 'SITUATION_REPORT', id: 'sit-001', ref: REF_A }, + generated_at: T0, + }, + }, situation_report: { 'normal-day': { id: 'sit-001', @@ -442,6 +623,14 @@ const invalid: Record> = { // a workflow id that is not a registered template 'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' }, }, + review_finding: { + // a writeback with an unknown target — findings may only write to the three sanctioned stores + 'unknown-writeback': (() => { + const o = structuredClone(valid['review_finding']!['d-plus-1']) as any + o.writebacks = [{ target: 'LEDGER', note: 'nope' }] + return o + })(), + }, position_update: { // missing optimistic-concurrency field 'missing-version': (() => { diff --git a/packages/domain/src/dispatch.ts b/packages/domain/src/dispatch.ts new file mode 100644 index 0000000..e38b1b4 --- /dev/null +++ b/packages/domain/src/dispatch.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js' +import { LineageRef } from './chain.js' +import { UnitAllocation } from './proposal.js' +import { ResourceProfile } from './resource.js' +import { SkillVersion } from './skill.js' + +/** + * D-1 16:00 award handling and the resource-dispatch agent's objects + * (docs/07 D-1 16:00, docs/04 §1–2, docs/05 §2.2). + */ + +/** Market clearing result for one day (出清 / 中标) — written by the trading-platform adapter. */ +export const AwardNotice = z.object({ + id: Id, + market_date: MarketDate, + /** Digest of the bid proposal this award answers. */ + bid_proposal_digest: SnapshotRef, + awarded_mwh: Curve96, + clearing_price_yuan_per_mwh: Curve96, + received_at: IsoUtc, +}) +export type AwardNotice = z.infer + +/** What the resource agent's LLM step may emit: references and prose only (P2). */ +export const DispatchProposalDraft = z.object({ + market_date: MarketDate, + total_target_ref: LineageRef, + allocations_ref: LineageRef, + rationale: z.string().min(1), +}) +export type DispatchProposalDraft = z.infer + +/** Execution outcome per unit (docs/04 §2 ExecutionReport → review). */ +export const ExecutionReport = z.object({ + id: Id, + market_date: MarketDate, + unit_id: Id, + dispatch_proposal_digest: SnapshotRef, + planned_mw: Curve96, + actual_mw: Curve96, + /** |actual − planned| summed over intervals, MWh. */ + deviation_mwh: DecimalString, + /** Share of planned energy delivered, 0–1 as decimal string. */ + fulfillment_ratio: DecimalString, + reported_at: IsoUtc, +}) +export type ExecutionReport = z.infer + +/** Metering facts for the day (docs/06 Settlement precursor). */ +export const MeteringRecord = z.object({ + id: Id, + market_date: MarketDate, + metered_mw: Curve96, + actual_price_yuan_per_mwh: Curve96, + received_at: IsoUtc, +}) +export type MeteringRecord = z.infer + +// ---- skills (docs/05 §2.2) -------------------------------------------------- + +/** History of what a resource was asked for vs delivered — the evidence a potential assessment must cite. */ +export const FulfillmentRecord = z.object({ + market_date: MarketDate, + planned_mwh: DecimalString, + delivered_mwh: DecimalString, +}) + +export const PotentialAssessmentRequest = z.object({ + market_date: MarketDate, + resources: z.array(z.object({ profile: ResourceProfile, fulfillment: z.array(FulfillmentRecord) })).min(1), + features_snapshot_ref: SnapshotRef, +}) +export type PotentialAssessmentRequest = z.infer + +export const PotentialAssessmentResult = z.object({ + market_date: MarketDate, + assessments: z.array( + z.object({ + resource_id: Id, + adjustable_mw: Curve96, + /** 0–1 */ + confidence: DecimalString, + /** Rolling fulfillment rate used for de-rating; null when no history. */ + fulfillment_rate: DecimalString.nullable(), + evidence_days: z.int().nonnegative(), + }), + ), + total_adjustable_mw: Curve96, + skill_version: SkillVersion, +}) +export type PotentialAssessmentResult = z.infer + +export const DispatchOptimizationRequest = z.object({ + market_date: MarketDate, + target_mw: Curve96, + units: z + .array( + z.object({ + unit_id: Id, + available_mw: Curve96, + /** Relative cost/wear weight; lower is dispatched first. */ + cost_weight: DecimalString, + }), + ) + .min(1), +}) +export type DispatchOptimizationRequest = z.infer + +export const DispatchOptimizationResult = z.object({ + market_date: MarketDate, + total_target_mw: Curve96, + allocations: z.array(UnitAllocation).min(1), + /** Σ over intervals of max(0, target − Σ allocations), MWh. Non-zero means capacity was short. */ + shortfall_mwh: DecimalString, + solver: z.object({ name: z.string().min(1), version: z.string().min(1), status: z.enum(['OPTIMAL', 'INFEASIBLE', 'ERROR']), wall_time_ms: z.int().nonnegative() }), + skill_version: SkillVersion, +}) +export type DispatchOptimizationResult = z.infer diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 1e481e6..2634964 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -25,6 +25,17 @@ import { StaleDenial, ValidationResult, } from './chain.js' +import { + AwardNotice, + DispatchOptimizationRequest, + DispatchOptimizationResult, + DispatchProposalDraft, + ExecutionReport, + MeteringRecord, + PotentialAssessmentRequest, + PotentialAssessmentResult, +} from './dispatch.js' +import { EnvelopeChangeRequest, InsightCard, ReviewFinding, SemanticMemoryEntry } from './review.js' export * from './common.js' export * from './proposal.js' @@ -38,6 +49,8 @@ export * from './case.js' export * from './event.js' export * from './skill.js' export * from './chain.js' +export * from './dispatch.js' +export * from './review.js' /** * Registry driving the contracts pipeline: keys become schema/fixture/module @@ -69,4 +82,16 @@ export const schemaRegistry: Record = { bid_proposal_draft: BidProposalDraft, router_decision: RouterDecision, bid_export_file: BidExportFile, + award_notice: AwardNotice, + dispatch_proposal_draft: DispatchProposalDraft, + execution_report: ExecutionReport, + metering_record: MeteringRecord, + potential_assessment_request: PotentialAssessmentRequest, + potential_assessment_result: PotentialAssessmentResult, + dispatch_optimization_request: DispatchOptimizationRequest, + dispatch_optimization_result: DispatchOptimizationResult, + review_finding: ReviewFinding, + semantic_memory_entry: SemanticMemoryEntry, + envelope_change_request: EnvelopeChangeRequest, + insight_card: InsightCard, } diff --git a/packages/domain/src/proposal.ts b/packages/domain/src/proposal.ts index 1bacb45..db034b7 100644 --- a/packages/domain/src/proposal.ts +++ b/packages/domain/src/proposal.ts @@ -57,6 +57,22 @@ export const BidPayload = z.object({ }) export type BidPayload = z.infer +/** Dispatch plan: awarded energy decomposed to aggregation units (docs/04 §1: province → unit granularity only). */ +export const UnitAllocation = z.object({ + unit_id: Id, + target_mw: Curve96, +}) +export type UnitAllocation = z.infer + +export const DispatchPlanPayload = z.object({ + kind: z.literal('DISPATCH_PLAN'), + market_date: MarketDate, + award_ref: Id, + total_target_mw: Curve96, + allocations: z.array(UnitAllocation).min(1), +}) +export type DispatchPlanPayload = z.infer + const ProposalBase = z.object({ id: Id, timescale: Timescale, @@ -70,10 +86,11 @@ const ProposalBase = z.object({ }) /** - * M1 carries the BID variant only; INVITATION / CONTROL_PLAN / DISPATCH_PLAN - * payloads arrive with their milestones (ROADMAP M4 / phase 2). + * BID (M1) and DISPATCH_PLAN (M4). INVITATION / CONTROL_PLAN payloads are + * phase 2 (interaction and load-control agents). */ export const Proposal = z.discriminatedUnion('type', [ ProposalBase.extend({ type: z.literal('BID'), payload: BidPayload }), + ProposalBase.extend({ type: z.literal('DISPATCH_PLAN'), payload: DispatchPlanPayload }), ]) export type Proposal = z.infer diff --git a/packages/domain/src/review.ts b/packages/domain/src/review.ts new file mode 100644 index 0000000..c182359 --- /dev/null +++ b/packages/domain/src/review.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js' +import { LineageRef } from './chain.js' + +/** + * Review loop objects (docs/02 §4, docs/07 D+1): the structured lesson and + * the three writebacks it drives — semantic memory, resource reliability, + * envelope recommendations. Every number in a finding is a lineage reference + * or a value computed by the deterministic review service, never LLM text. + */ +export const DeviationKind = z.enum(['FORECAST', 'RESPONSE', 'TRADING', 'CONTROL']) + +export const Attribution = z.object({ + /** Bid vs award — market did not clear what we offered. */ + trading_mwh: DecimalString, + /** Award vs dispatch plan — decomposition could not cover the award. */ + control_mwh: DecimalString, + /** Dispatch plan vs execution — units under/over-delivered. */ + response_mwh: DecimalString, + /** Forecast P50 vs metered, price MAPE. */ + forecast_load_mape: DecimalString.nullable(), + forecast_price_mape: DecimalString.nullable(), + /** Metered vs planned, the settlement-relevant gap. */ + net_deviation_mwh: DecimalString, +}) + +export const Writeback = z.discriminatedUnion('target', [ + z.object({ target: z.literal('SEMANTIC_MEMORY'), topic: z.string().min(1), lesson: z.string().min(1) }), + z.object({ + target: z.literal('RESOURCE_PROFILE'), + resource_id: Id, + previous_reliability_score: DecimalString, + new_reliability_score: DecimalString, + fulfillment_ratio: DecimalString, + }), + z.object({ + target: z.literal('ENVELOPE_RECOMMENDATION'), + envelope_id: Id, + action: z.enum(['WIDEN', 'NARROW', 'SUSPEND', 'KEEP']), + bound: z.string().min(1), + current_value: DecimalString, + recommended_value: DecimalString, + basis: z.string().min(1), + }), +]) +export type Writeback = z.infer + +export const ReviewFinding = z.object({ + id: Id, + market_date: MarketDate, + kind: DeviationKind, + summary: z.string().min(1), + attribution: Attribution, + writebacks: z.array(Writeback), + evidence_refs: z.array(SnapshotRef), + /** Tool calls the numbers came from. */ + lineage_refs: z.array(LineageRef), + generated_at: IsoUtc, +}) +export type ReviewFinding = z.infer + +/** Semantic memory (docs/02 §3): versioned lessons, injected by templates, never auto-recalled into decisions. */ +export const SemanticMemoryEntry = z.object({ + id: Id, + topic: z.string().min(1), + lesson: z.string().min(1), + source_finding_id: Id, + evidence_refs: z.array(SnapshotRef), + created_at: IsoUtc, +}) +export type SemanticMemoryEntry = z.infer + +/** Request to change an envelope; approved by a human through the envelope-review workflow. */ +export const EnvelopeChangeRequest = z.object({ + id: Id, + envelope_id: Id, + action: z.enum(['WIDEN', 'NARROW', 'SUSPEND', 'REACTIVATE']), + bounds: z.record(z.string(), DecimalString), + basis: z.string().min(1), + source_finding_id: Id.nullable(), + requested_at: IsoUtc, +}) +export type EnvelopeChangeRequest = z.infer + +/** + * AI insight card (docs/02 §5, docs/06 §4): read-only projection embedded + * in an existing business page. Metrics are lineage references; the card + * expands to tool outputs → data sources. + */ +export const InsightCard = z.object({ + id: Id, + page: z.enum(['TRADING_DESK', 'RESOURCE_POOL', 'OPERATIONS_DASHBOARD']), + market_date: MarketDate, + title: z.string().min(1), + headline: z.string().min(1), + metrics: z.array(z.object({ name: z.string().min(1), value: DecimalString, unit: z.string().min(1), ref: LineageRef.nullable() })), + source: z.object({ kind: z.enum(['SITUATION_REPORT', 'REVIEW_FINDING', 'DECISION_CASE', 'POTENTIAL_ASSESSMENT']), id: Id, ref: SnapshotRef.nullable() }), + generated_at: IsoUtc, +}) +export type InsightCard = z.infer diff --git a/packages/evals/test/harness.test.ts b/packages/evals/test/harness.test.ts index 614d8c2..4a83499 100644 --- a/packages/evals/test/harness.test.ts +++ b/packages/evals/test/harness.test.ts @@ -68,6 +68,12 @@ class StubClient implements SkillClient { async report(_req: ReportRequest): Promise { throw new Error('not used') } + async assessPotential(): Promise { + throw new Error('not used') + } + async optimizeDispatch(): Promise { + throw new Error('not used') + } } const cfg = { ...DEFAULT_CONFIG, window: 7, holdoutFrom: 10, holdoutDays: 5 } diff --git a/packages/runtime/src/api.ts b/packages/runtime/src/api.ts index 684723d..3caf231 100644 --- a/packages/runtime/src/api.ts +++ b/packages/runtime/src/api.ts @@ -1,6 +1,8 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { z } from 'zod' +import { InsightCard } from '@vpp/domain' +import { insightCards } from './cards.js' import { LlmUnavailable } from './llm.js' import type { Runtime } from './runtime.js' import type { TriggerService } from './trigger.js' @@ -54,6 +56,17 @@ export function createApi(rt: Runtime, triggers: TriggerService): Server { 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 === 'GET' && parts[0] === 'cards' && parts[1]) return json(res, 200, insightCards(rt.ctx, InsightCard.shape.page.parse(parts[1]))) + if (req.method === 'GET' && url.pathname === '/memory') return json(res, 200, rt.ctx.review.memory.list().map((r) => r.value)) + if (req.method === 'GET' && url.pathname === '/envelopes') return json(res, 200, rt.ctx.envelopes.repo.list().map((r) => r.value)) + if (req.method === 'GET' && url.pathname === '/findings') return json(res, 200, rt.ctx.findings.list().map((r) => r.value)) + if (req.method === 'POST' && url.pathname === '/awards') return json(res, 200, await triggers.onAward((await readBody(req)) as never)) + if (req.method === 'POST' && url.pathname === '/execution-reports') { + const body = (await readBody(req)) as unknown[] + triggers.onExecutionReports(body as never) + return json(res, 200, { accepted: body.length }) + } + if (req.method === 'POST' && url.pathname === '/metering') return json(res, 200, await triggers.onMetering((await readBody(req)) as never)) if (req.method === 'POST' && url.pathname === '/tasks') { const body = TaskBody.parse(await readBody(req)) return json(res, 200, await triggers.manual(body.message)) diff --git a/packages/runtime/src/cards.ts b/packages/runtime/src/cards.ts new file mode 100644 index 0000000..e872d5e --- /dev/null +++ b/packages/runtime/src/cards.ts @@ -0,0 +1,86 @@ +import type { InsightCard, ReviewFinding, SituationReport, ToolCallRef } from '@vpp/domain' +import type { RuntimeContext } from './context.js' + +/** + * AI insight cards (docs/02 §5, docs/06 §4): read-only projections over the + * event log, findings and cases for embedding in existing business pages. + * Every metric that is a tool output carries a lineage ref; review-computed + * values carry null refs and name their finding as source instead. + */ +export function insightCards(ctx: RuntimeContext, page: InsightCard['page']): InsightCard[] { + const now = ctx.clock() + const cards: InsightCard[] = [] + const situations = ctx.events.list({ event_type: 'SituationPublished' }) + const latestSit = situations[situations.length - 1]?.payload as { report: SituationReport; report_ref: string; forecast_tool_calls: ToolCallRef[] } | undefined + const findings = ctx.findings.list().map((r) => r.value) + const latestFinding: ReviewFinding | undefined = findings[findings.length - 1] + + if (page === 'TRADING_DESK' && latestSit) { + const priceCall = latestSit.forecast_tool_calls.find((t) => t.tool === 'price-forecast') + const price = priceCall ? (ctx.snapshots.get(priceCall.outputs_ref) as { quantiles: { p50: { values: string[] } } } | undefined) : undefined + const metrics: InsightCard['metrics'] = [] + if (price && priceCall) { + const values = price.quantiles.p50.values + const idx = values.reduce((best, v, i) => (Number(v) > Number(values[best]) ? i : best), 0) + metrics.push({ name: 'peak_price_p50', value: values[idx]!, unit: 'yuan_per_mwh', ref: { tool_call_id: priceCall.tool_call_id, path: `quantiles.p50.values.${idx}` } }) + } + cards.push({ + id: `card-sit-${latestSit.report.id}`, + page, + market_date: latestSit.report.market_date, + title: 'Day-ahead situation', + headline: `Risk ${latestSit.report.risk_level}: ${latestSit.report.findings[0]?.summary ?? ''}`, + metrics, + source: { kind: 'SITUATION_REPORT', id: latestSit.report.id, ref: latestSit.report_ref }, + generated_at: now, + }) + } + if ((page === 'TRADING_DESK' || page === 'OPERATIONS_DASHBOARD') && latestFinding) { + cards.push({ + id: `card-rf-${latestFinding.id}`, + page, + market_date: latestFinding.market_date, + title: 'D+1 review', + headline: latestFinding.summary, + metrics: [ + { name: 'net_deviation_mwh', value: latestFinding.attribution.net_deviation_mwh, unit: 'mwh', ref: null }, + { name: 'response_mwh', value: latestFinding.attribution.response_mwh, unit: 'mwh', ref: null }, + ...(latestFinding.attribution.forecast_price_mape ? [{ name: 'forecast_price_mape', value: latestFinding.attribution.forecast_price_mape, unit: '1', ref: null }] : []), + ], + source: { kind: 'REVIEW_FINDING', id: latestFinding.id, ref: null }, + generated_at: now, + }) + } + if (page === 'RESOURCE_POOL') { + for (const r of ctx.resources.list()) { + const wb = latestFinding?.writebacks.find((w) => w.target === 'RESOURCE_PROFILE' && w.resource_id === r.key) + cards.push({ + id: `card-res-${r.key}`, + page, + market_date: latestFinding?.market_date ?? now.slice(0, 10), + title: r.value.name, + headline: wb && wb.target === 'RESOURCE_PROFILE' ? `Reliability ${wb.previous_reliability_score} → ${wb.new_reliability_score} (fulfilled ${wb.fulfillment_ratio})` : `Reliability ${r.value.reliability_score}`, + metrics: [ + { name: 'reliability_score', value: r.value.reliability_score, unit: '1', ref: null }, + { name: 'certified_adjustable_mw', value: r.value.certified_adjustable_mw, unit: 'mw', ref: null }, + ], + source: latestFinding ? { kind: 'REVIEW_FINDING', id: latestFinding.id, ref: null } : { kind: 'DECISION_CASE', id: 'none', ref: null }, + generated_at: now, + }) + } + } + if (page === 'OPERATIONS_DASHBOARD') { + const open = ctx.caseDesk.cases.list().map((c) => c.value).filter((c) => !c.status.startsWith('CLOSED')) + cards.push({ + id: 'card-ops-cases', + page, + market_date: now.slice(0, 10), + title: 'Open cases', + headline: `${open.length} open, ${ctx.caseDesk.inbox().filter((p) => p.run_id !== '').length} awaiting approval`, + metrics: [{ name: 'open_cases', value: String(open.length), unit: '1', ref: null }], + source: { kind: 'DECISION_CASE', id: open[0]?.id ?? 'none', ref: null }, + generated_at: now, + }) + } + return cards +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index 462ce9d..461700c 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -4,15 +4,20 @@ import type { EnvelopeService, EventBus, FileExportGateway, + GatewayPort, LedgerService, LineageRecorder, PolicyEngine, + Repository, ResourceRegistry, + ReviewService, + SimulationGateway, SimulationPort, SkillClient, SnapshotStore, TimeSeriesStore, } from '@vpp/services' +import type { AwardNotice, EnvelopeChangeRequest, ExecutionReport, MeteringRecord, Proposal, ReviewFinding } from '@vpp/domain' import type { LlmPort } from './llm.js' /** @@ -38,6 +43,12 @@ export interface RuntimeConfig { extremeDayPriceRatio: string /** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */ bidDeadlineLocal: string + /** Aggregation units → resource ids ('*' = all). OPEN-QUESTION D4 (topology). */ + units: Record + /** Review writeback parameters — OPEN-QUESTION B10 (reliability EWMA), B4/B9 (compliant days), B1 (widen step). */ + reliabilityAlpha: string + widenAfterCompliantDays: number + widenStepPct: string } export interface RuntimeContext { @@ -53,7 +64,16 @@ export interface RuntimeContext { envelopes: EnvelopeService authority: AuthorityService gateway: FileExportGateway - simulation: SimulationPort + /** Simulation and gateway are chosen by proposal type (ADR-0005: type-specific behaviour is data, not workflow structure). */ + simulationFor: (type: Proposal['type']) => SimulationPort + gatewayFor: (type: Proposal['type']) => GatewayPort + simulationGateway: SimulationGateway + review: ReviewService + awards: Repository + executionReports: Repository + metering: Repository + envelopeRequests: Repository + findings: Repository caseDesk: CaseDeskService skills: SkillClient lineage: LineageRecorder diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f555049..d4ccc9c 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -7,3 +7,7 @@ export * from './api.js' export * from './workflows/proposal-lifecycle.js' export * from './workflows/day-ahead-situation.js' export * from './workflows/day-ahead-bid.js' +export * from './cards.js' +export * from './workflows/award-decomposition.js' +export * from './workflows/review.js' +export * from './workflows/envelope-review.js' diff --git a/packages/runtime/src/llm.ts b/packages/runtime/src/llm.ts index 3d72268..44824ba 100644 --- a/packages/runtime/src/llm.ts +++ b/packages/runtime/src/llm.ts @@ -83,7 +83,7 @@ export class ScriptedLlm implements LlmPort { } } -/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */ +/** The five agents (docs/02 §2). M3–M4 wire router, analysis, trading, resource; interaction/load-control are phase 2. */ export const AGENT_SPECS: AgentSpec[] = [ { id: 'router-agent', @@ -99,6 +99,13 @@ export const AGENT_SPECS: AgentSpec[] = [ '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: 'resource-agent', + name: 'Resource dispatch', + instructions: + 'You decompose awarded energy to aggregation units. The dispatch-optimization tool has already computed the allocation; ' + + 'you reference its output by {tool_call_id, path} and explain unit choices and any capacity shortfall for the approver.', + }, { id: 'trading-agent', name: 'Trading', diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index 12559d3..ff0eefd 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -2,7 +2,7 @@ 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 { Approval, AwardNotice, DecisionCase, Envelope, EnvelopeChangeRequest, ExecutionPermit, ExecutionReceipt, ExecutionReport, MeteringRecord, Proposal, ResourceProfile, ReviewFinding, SemanticMemoryEntry } from '@vpp/domain' import { AuthorityService, CaseDeskService, @@ -18,14 +18,20 @@ import { MemoryTimeSeriesStore, PendingApprovalSchema, PolicyEngine, + PowerBalanceSimulator, RevenueScenarioSimulator, + ReviewService, + SimulationGateway, 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 { createAwardDecomposition } from './workflows/award-decomposition.js' import { createDayAheadBid } from './workflows/day-ahead-bid.js' +import { createEnvelopeReview } from './workflows/envelope-review.js' +import { createReview } from './workflows/review.js' import { createDayAheadSituation } from './workflows/day-ahead-situation.js' import { createProposalLifecycle } from './workflows/proposal-lifecycle.js' @@ -55,6 +61,10 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { forecastWindowDays: 28, extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7 bidDeadlineLocal: '09:00', // OPEN-QUESTION A1 + units: { 'agg-unit-01': ['*'] }, // OPEN-QUESTION D4 + reliabilityAlpha: '0.2', // OPEN-QUESTION B10 + widenAfterCompliantDays: 20, // OPEN-QUESTION B4/B9 + widenStepPct: '20', // OPEN-QUESTION B1 } export interface Runtime { @@ -89,6 +99,24 @@ export async function createRuntime(opts: RuntimeOptions): Promise { clock, }) const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock)) + const simulationGateway = new SimulationGateway(authority, new FsRepository(dir('sim-receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock)) + const resources = new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock) + const revenueSim = new RevenueScenarioSimulator() + const executionReports = new FsRepository(dir('execution-reports.json'), ExecutionReport, (r) => r.id, clock) + const powerSim = new PowerBalanceSimulator((marketDate) => { + // Unit limits = the potential assessment recorded for that day, if any; else no per-unit limit. + const limits: Record = {} + for (const evt of events.list({ event_type: 'PotentialAssessed' })) { + const p = evt.payload as { market_date: string; units: Record } + if (p.market_date === marketDate) Object.assign(limits, p.units) + } + return limits + }) + const review = new ReviewService(resources, envelopes, new FsRepository(dir('memory.json'), SemanticMemoryEntry, (m) => m.id, clock), { + reliabilityAlpha: config.reliabilityAlpha, + widenAfterCompliantDays: config.widenAfterCompliantDays, + widenStepPct: config.widenStepPct, + }) let llm: LlmPort let agents: Record | ReturnType = {} @@ -106,14 +134,22 @@ export async function createRuntime(opts: RuntimeOptions): Promise { newId, snapshots, timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock), - resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock), + resources, ledger, events, policy, envelopes, authority, gateway, - simulation: new RevenueScenarioSimulator(), + simulationFor: (type) => (type === 'DISPATCH_PLAN' ? powerSim : revenueSim), + gatewayFor: (type) => (type === 'DISPATCH_PLAN' ? simulationGateway : gateway), + simulationGateway, + review, + awards: new FsRepository(dir('awards.json'), AwardNotice, (a) => a.id, clock), + executionReports, + metering: new FsRepository(dir('metering.json'), MeteringRecord, (m) => m.id, clock), + envelopeRequests: new FsRepository(dir('envelope-requests.json'), EnvelopeChangeRequest, (r) => r.id, clock), + findings: new FsRepository(dir('findings.json'), ReviewFinding, (f) => f.id, clock), caseDesk, skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills, lineage: new LineageRecorder(), @@ -124,9 +160,17 @@ export async function createRuntime(opts: RuntimeOptions): Promise { // 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 envelopeReview = createEnvelopeReview(ctx) const mastra = new Mastra({ storage, - workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) }, + workflows: { + proposalLifecycle, + envelopeReview, + dayAheadSituation: createDayAheadSituation(ctx), + dayAheadBid: createDayAheadBid(ctx, proposalLifecycle), + awardDecomposition: createAwardDecomposition(ctx, proposalLifecycle), + review: createReview(ctx, envelopeReview), + }, agents, }) return { mastra, ctx, close: () => storage.close() } diff --git a/packages/runtime/src/trigger.ts b/packages/runtime/src/trigger.ts index 2fa7b32..54a6632 100644 --- a/packages/runtime/src/trigger.ts +++ b/packages/runtime/src/trigger.ts @@ -1,5 +1,6 @@ -import { RouterDecision } from '@vpp/domain' +import { AwardNotice, ExecutionReport, MeteringRecord, RouterDecision } from '@vpp/domain' import type { EventEnvelope } from '@vpp/domain' +import { ENVELOPE_REVIEW_STEP_ID } from './workflows/envelope-review.js' import { LlmUnavailable } from './llm.js' import type { Runtime } from './runtime.js' import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js' @@ -94,6 +95,38 @@ export class TriggerService { ) } + /** Trading-platform adapter stand-in: an award arrives → ledger + decomposition (docs/07 D-1 16:00). */ + async onAward(award: AwardNotice) { + const a = AwardNotice.parse(award) + this.rt.ctx.events.append({ event_type: 'AwardReceived', payload: a, correlation_id: a.id }) + const wf = this.rt.mastra.getWorkflow('awardDecomposition') + const run = await wf.createRun() + const result = await run.start({ inputData: { award: a } }) + if (result.status === 'failed') throw result.error + return { run_id: run.runId, result: result.status === 'success' ? result.result : null, status: result.status } + } + + /** Execution engine / edge feedback stand-in: execution reports are evidence for review and potential assessment. */ + onExecutionReports(reports: ExecutionReport[]) { + for (const r of reports) { + const parsed = ExecutionReport.parse(r) + this.rt.ctx.executionReports.put(parsed) + this.rt.ctx.events.append({ event_type: 'ExecutionReport', payload: parsed, correlation_id: parsed.dispatch_proposal_digest }) + } + } + + /** Metering adapter stand-in: D+1 metering arrives → review workflow (docs/07 D+1). */ + async onMetering(record: MeteringRecord) { + const m = MeteringRecord.parse(record) + this.rt.ctx.metering.put(m) + this.rt.ctx.events.append({ event_type: 'MeteringArrived', payload: m, correlation_id: m.id }) + const wf = this.rt.mastra.getWorkflow('review') + const run = await wf.createRun() + const result = await run.start({ inputData: { market_date: m.market_date } }) + if (result.status === 'failed') throw result.error + return { run_id: run.runId, result: result.status === 'success' ? result.result : null } + } + // ---- manual ------------------------------------------------------------ async route(message: string): Promise { @@ -126,6 +159,12 @@ export class TriggerService { // ---- approval = resume ------------------------------------------------- async decide(runId: string, decision: ResumeDecision) { + const pending = this.rt.ctx.caseDesk.pending.list().find((p) => p.value.run_id === runId)?.value + if (pending?.workflow === 'envelopeReview') { + const run = await this.rt.mastra.getWorkflow('envelopeReview').createRun({ runId }) + const res = await run.resume({ step: ENVELOPE_REVIEW_STEP_ID, resumeData: decision }) + return res.status === 'success' ? { status: 'success' as const, result: { outcome: res.result.applied ? ('RELEASED' as const) : ('REJECTED' as const), ...res.result } } : res + } const wf = this.rt.mastra.getWorkflow('proposalLifecycle') const run = await wf.createRun({ runId }) return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision }) diff --git a/packages/runtime/src/workflows/award-decomposition.ts b/packages/runtime/src/workflows/award-decomposition.ts new file mode 100644 index 0000000..e365ce1 --- /dev/null +++ b/packages/runtime/src/workflows/award-decomposition.ts @@ -0,0 +1,200 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import type { DispatchProposalDraft, Proposal, ToolCallRef } from '@vpp/domain' +import { AwardNotice, DispatchProposalDraft as DispatchProposalDraftSchema, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain' +import { Decimal, assembleDispatchProposal } from '@vpp/services' +import type { RuntimeContext } from '../context.js' +import { LlmUnavailable } from '../llm.js' +import { registerSkill } from '../skills.js' +import { LifecycleOutput } from './proposal-lifecycle.js' +import type { createProposalLifecycle } from './proposal-lifecycle.js' + +/** + * D-1 16:00 出清结果处理 (docs/07): award → ledger (DAY_AHEAD/AWARD) → + * resource agent: potential assessment (cites execution feedback) → + * dispatch optimization → draft of references → DISPATCH_PLAN proposal → + * lifecycle (power-balance simulation, envelope, permit, simulation gateway). + */ +export const AwardInput = z.object({ award: AwardNotice }) + +export const AwardOutput = 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(), + shortfall_mwh: z.string(), +}) + +const Context = z.object({ + market_date: z.string(), + award_id: z.string(), + case_id: z.string(), + tool_calls: z.array(ToolCallRefSchema), + data_refs: z.array(z.string()), + dispatch_call: ToolCallRefSchema, + dispatch_output: z.unknown(), + ledger_version: z.int(), + policy_pack_version: z.string(), +}) + +/** Units → resources mapping. '*' means every registered resource. OPEN-QUESTION D4 (aggregation topology). */ +export function unitResources(ctx: RuntimeContext): Record { + const all = ctx.resources.list().map((r) => r.key) + return Object.fromEntries(Object.entries(ctx.config.units).map(([u, rs]) => [u, rs.includes('*') ? all : rs])) +} + +export function createAwardDecomposition(ctx: RuntimeContext, lifecycle: ReturnType) { + const ingestAward = createStep({ + id: 'ingest-award', + inputSchema: AwardInput, + outputSchema: Context, + execute: async ({ inputData }) => { + const { award } = inputData + ctx.awards.put(award) + const energy = award.awarded_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)) + ctx.ledger.append({ + id: ctx.newId('pos'), + timescale: 'DAY_AHEAD', + period: award.market_date, + kind: 'AWARD', + energy_mwh: energy.toFixed(3), + curve: award.awarded_mwh, + source_ref: award.id, + expected_version: ctx.ledger.read().version, + }) + ctx.events.append({ event_type: 'AwardRecorded', payload: { award_id: award.id, market_date: award.market_date, energy_mwh: energy.toFixed(3), ledger_version: ctx.ledger.read().version }, correlation_id: award.id }) + const caseId = ctx.caseDesk.open({ + id: ctx.newId('case'), + kind: 'DAY_AHEAD_BID', + objective: `Decompose award ${award.id} for ${award.market_date}`, + owner: 'user-ops-lead', // OPEN-QUESTION B3 + deadline: `${award.market_date}T00:00:00Z`, + }).id + + return ctx.lineage.run(async () => { + const awardRef = ctx.snapshots.put(award) + ctx.lineage.addDataRef(awardRef) + const units = unitResources(ctx) + // Potential assessment cites recent execution feedback per resource (docs/02 §2.2 画像时效性). + const reports = ctx.executionReports.list().map((r) => r.value) + const resources = ctx.resources.list().map((r) => ({ + profile: r.value, + fulfillment: reports + .filter((rep) => (units[rep.unit_id] ?? []).includes(r.key)) + .map((rep) => ({ + market_date: rep.market_date, + planned_mwh: rep.planned_mw.values.reduce((s, v) => s + Number(v), 0).toFixed(3).replace(/^(\d+\.\d{3})$/, '$1'), + delivered_mwh: rep.actual_mw.values.reduce((s, v) => s + Number(v), 0).toFixed(3), + })), + })) + if (resources.length === 0) throw new Error('no resources registered — nothing to dispatch') + const potential = registerSkill( + { lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') }, + { id: 'potential-assessment', version: '1.0.0', invoke: (req: Parameters[0]) => ctx.skills.assessPotential(req) }, + ) + const { output: assessed, ref: potRef } = await potential.call({ market_date: award.market_date, resources, features_snapshot_ref: awardRef }) + + const unitInputs = Object.entries(units).map(([unit_id, rids]) => ({ + unit_id, + available_mw: { + interval_minutes: 15 as const, + date: award.market_date, + values: Array.from({ length: 96 }, (_, t) => + assessed.assessments.filter((a) => rids.includes(a.resource_id)).reduce((s, a) => s.add(new Decimal(a.adjustable_mw.values[t]!)), new Decimal(0)).toFixed(3), + ), + }, + cost_weight: '1.0', // OPEN-QUESTION B2: per-unit cost/wear weights + })) + ctx.events.append({ event_type: 'PotentialAssessed', payload: { market_date: award.market_date, tool_call_id: potRef.tool_call_id, units: Object.fromEntries(unitInputs.map((u) => [u.unit_id, u.available_mw.values])) }, correlation_id: award.id }) + const target = { interval_minutes: 15 as const, date: award.market_date, values: award.awarded_mwh.values.map((v) => new Decimal(v).div('0.25').toFixed(3)) } + const dispatch = registerSkill( + { lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') }, + { id: 'dispatch-optimization', version: '1.0.0', invoke: (req: Parameters[0]) => ctx.skills.optimizeDispatch(req) }, + ) + const { output, ref } = await dispatch.call({ market_date: award.market_date, target_mw: target, units: unitInputs }) + if (output.solver.status !== 'OPTIMAL') throw new Error(`dispatch optimization ${output.solver.status}`) + const lin = ctx.lineage.current() + ctx.caseDesk.addEvidence(caseId, lin.data_refs) + return { + market_date: award.market_date, + award_id: award.id, + case_id: caseId, + tool_calls: [potRef, ref], + data_refs: lin.data_refs, + dispatch_call: ref, + dispatch_output: output, + ledger_version: ctx.ledger.read().version, + policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version, + } + }) + }, + }) + + const resourceStrategy = createStep({ + id: 'resource-strategy', + inputSchema: Context, + outputSchema: Context.extend({ draft: DispatchProposalDraftSchema, llm_used: z.boolean() }), + execute: async ({ inputData }) => { + const tc = inputData.dispatch_call.tool_call_id + const out = inputData.dispatch_output as { shortfall_mwh: string; allocations: Array<{ unit_id: string }> } + const template: DispatchProposalDraft = { + market_date: inputData.market_date, + total_target_ref: { tool_call_id: tc, path: 'total_target_mw' }, + allocations_ref: { tool_call_id: tc, path: 'allocations' }, + rationale: `Dispatch ${tc}: ${out.allocations.length} unit(s), shortfall ${out.shortfall_mwh} MWh (template — LLM unavailable)`, + } + try { + const prompt = `Award ${inputData.award_id} for ${inputData.market_date}. Dispatch tool call ${tc} output has fields total_target_mw and allocations (units: ${out.allocations.map((a) => a.unit_id).join(', ')}; shortfall_mwh = ${out.shortfall_mwh}). Return a DispatchProposalDraft referencing those two fields plus a short rationale. No numbers beyond those quoted.` + const draft = await ctx.llm.structured('resource-agent', prompt, DispatchProposalDraftSchema) + 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: resourceStrategy.outputSchema, + outputSchema: AwardOutput, + execute: async ({ inputData }) => { + const proposal: Proposal = assembleDispatchProposal(inputData.draft, { + id: ctx.newId('prop'), + award_ref: inputData.award_id, + originator: { agent: 'resource-agent', trigger: 'EVENT', 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.caseDesk.recordProposal(inputData.case_id, proposal) + 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, + shortfall_mwh: (inputData.dispatch_output as { shortfall_mwh: string }).shortfall_mwh, + } + }, + }) + + return createWorkflow({ id: 'award-decomposition', inputSchema: AwardInput, outputSchema: AwardOutput }) + .then(ingestAward) + .then(resourceStrategy) + .then(assembleAndSubmit) + .commit() +} diff --git a/packages/runtime/src/workflows/envelope-review.ts b/packages/runtime/src/workflows/envelope-review.ts new file mode 100644 index 0000000..241eea1 --- /dev/null +++ b/packages/runtime/src/workflows/envelope-review.ts @@ -0,0 +1,57 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import { Envelope as EnvelopeSchema, EnvelopeChangeRequest as EnvelopeChangeRequestSchema } from '@vpp/domain' +import type { RuntimeContext } from '../context.js' +import { ResumeDecision } from './proposal-lifecycle.js' + +/** + * Envelope re-approval workflow (docs/03 §3, docs/07 D+1): the *only* path + * that changes an envelope's bounds or status. It always suspends for a + * human — widening autonomy is a governance act, never automatic. Shadow + * data may recommend; a person approves (docs/12 §3: 包络放宽仅凭 L4 数据审批). + */ +export const EnvelopeReviewInput = z.object({ request: EnvelopeChangeRequestSchema, case_id: z.string().min(1) }) + +export const EnvelopeReviewOutput = z.object({ + applied: z.boolean(), + request: EnvelopeChangeRequestSchema, + envelope: EnvelopeSchema.nullable(), + decided_by: z.string().nullable(), + reason: z.string(), +}) + +export const ENVELOPE_REVIEW_STEP_ID = 'envelope-review-gate' + +export function createEnvelopeReview(ctx: RuntimeContext) { + const gate = createStep({ + id: ENVELOPE_REVIEW_STEP_ID, + inputSchema: EnvelopeReviewInput, + outputSchema: EnvelopeReviewOutput, + suspendSchema: z.object({ reason: z.string(), request_id: z.string(), required_level: z.string() }), + resumeSchema: ResumeDecision, + execute: async ({ inputData, resumeData, suspend }) => { + const { request, case_id } = inputData + const now = ctx.clock() + if (!resumeData) { + return await suspend({ reason: `envelope ${request.envelope_id}: ${request.action} (${request.basis})`, request_id: request.id, required_level: 'L2' }) + } + // I1/I2: envelope approval needs an approver role; the review workflow and every agent are excluded. + if (!ctx.config.approverRoles.includes(resumeData.approver.role) || resumeData.approver.id.endsWith('-agent')) { + ctx.events.append({ event_type: 'ApprovalRefused', payload: { request_id: request.id, approver: resumeData.approver, reason: 'not an approver role (I1/I2)' }, correlation_id: case_id }) + return await suspend({ reason: `approval refused for ${resumeData.approver.id}`, request_id: request.id, required_level: 'L2' }) + } + ctx.caseDesk.clearPending(request.id) + if (resumeData.decision === 'reject') { + ctx.events.append({ event_type: 'EnvelopeChangeRejected', payload: { request_id: request.id, by: resumeData.approver.id, comment: resumeData.comment ?? null }, correlation_id: case_id }) + ctx.caseDesk.close(case_id, null, false) + return { applied: false, request, envelope: ctx.envelopes.get(request.envelope_id) ?? null, decided_by: resumeData.approver.id, reason: resumeData.comment ?? 'rejected' } + } + const envelope = ctx.envelopes.apply(request, [resumeData.approver.id]) + ctx.events.append({ event_type: 'EnvelopeChanged', payload: { request_id: request.id, envelope_id: envelope.id, action: request.action, bounds: envelope.bounds, status: envelope.status, by: resumeData.approver.id, at: now }, correlation_id: case_id }) + ctx.caseDesk.close(case_id, request.id, true) + return { applied: true, request, envelope, decided_by: resumeData.approver.id, reason: resumeData.comment ?? 'approved' } + }, + }) + + return createWorkflow({ id: 'envelope-review', inputSchema: EnvelopeReviewInput, outputSchema: EnvelopeReviewOutput }).then(gate).commit() +} diff --git a/packages/runtime/src/workflows/proposal-lifecycle.ts b/packages/runtime/src/workflows/proposal-lifecycle.ts index f49cb85..f548fba 100644 --- a/packages/runtime/src/workflows/proposal-lifecycle.ts +++ b/packages/runtime/src/workflows/proposal-lifecycle.ts @@ -99,7 +99,7 @@ export function createProposalLifecycle(ctx: RuntimeContext) { execute: async ({ inputData }) => { if (inputData.done) return inputData const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION') - const result = ctx.simulation.simulate(p, { + const result = ctx.simulationFor(p.type).simulate(p, { now: ctx.clock(), priceForecast: priceForecastFromLineage(ctx, p), marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh, @@ -205,19 +205,21 @@ export function createProposalLifecycle(ctx: RuntimeContext) { 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 receipt = ctx.gatewayFor(inputData.proposal.type).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, - }) + if (p.type === 'BID') { + ctx.ledger.append({ + id: ctx.newId('pos'), + timescale: 'DAY_AHEAD', + period: p.payload.market_date, + kind: 'BID_SUBMITTED', + energy_mwh: p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0'), + curve: p.payload.quantities_mwh, + 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) { diff --git a/packages/runtime/src/workflows/review.ts b/packages/runtime/src/workflows/review.ts new file mode 100644 index 0000000..a396bae --- /dev/null +++ b/packages/runtime/src/workflows/review.ts @@ -0,0 +1,162 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import type { EnvelopeChangeRequest, ForecastBundle, Proposal, ReviewFinding } from '@vpp/domain' +import { EnvelopeChangeRequest as EnvelopeChangeRequestSchema, ForecastBundle as ForecastBundleSchema, MarketDate, ReviewFinding as ReviewFindingSchema, SkillReport } from '@vpp/domain' +import { Decimal, contentRef, canonicalJson } from '@vpp/services' +import type { RuntimeContext } from '../context.js' +import { LlmUnavailable } from '../llm.js' +import { registerSkill } from '../skills.js' +import { unitResources } from './award-decomposition.js' +import type { createEnvelopeReview } from './envelope-review.js' + +/** + * D+1 偏差复盘 (docs/07 D+1, docs/02 §4, ADR-0007): analysis-agent-led + * template, not a sixth agent. Ledger plan-vs-actual per timescale → + * deterministic attribution → ReviewFinding → writebacks (semantic memory, + * reliability scores) applied directly; envelope recommendations become + * change requests that a human approves through envelope-review. The + * report-generator skill produces the numeric report; the LLM only narrates. + */ +export const ReviewInput = z.object({ market_date: MarketDate }) + +export const ReviewOutput = z.object({ + market_date: MarketDate, + findings: z.array(ReviewFindingSchema), + memory_entries: z.int(), + updated_profiles: z.array(z.object({ resource_id: z.string(), reliability_score: z.string() })), + envelope_suspended: z.array(z.string()), + envelope_requests: z.array(EnvelopeChangeRequestSchema), + envelope_review_run_ids: z.array(z.string()), + report_id: z.string().nullable(), + narrative: z.string(), + llm_used: z.boolean(), +}) + +const Narrative = z.object({ narrative: z.string().min(1) }) + +function forecastFromLineage(ctx: RuntimeContext, p: Proposal | null, tool: string): ForecastBundle | null { + if (!p) return null + for (const tc of p.lineage.tool_calls) { + if (tc.tool !== tool) continue + const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref)) + if (parsed.success) return parsed.data + } + return null +} + +export function createReview(ctx: RuntimeContext, envelopeReview: ReturnType) { + const attribute = createStep({ + id: 'attribute', + inputSchema: ReviewInput, + outputSchema: ReviewOutput.omit({ report_id: true, narrative: true, llm_used: true }), + execute: async ({ inputData }) => { + const date = inputData.market_date + const now = ctx.clock() + const released = ctx.caseDesk.proposals.list().map((r) => r.value).filter((p) => p.payload.market_date === date && p.status === 'RELEASED') + const bid = released.find((p) => p.type === 'BID') ?? null + const dispatch = released.find((p) => p.type === 'DISPATCH_PLAN') ?? null + const reports = ctx.executionReports.list().map((r) => r.value).filter((r) => r.market_date === date) + const metering = ctx.metering.list().map((r) => r.value).find((m) => m.market_date === date) ?? null + + // Envelope usage for the day: which envelopes auto-approved, and how far execution deviated from plan. + const planned = reports.reduce((s, r) => s.add(r.planned_mw.values.reduce((a, v) => a.add(new Decimal(v)), new Decimal(0))), new Decimal(0)) + const deviation = reports.reduce((s, r) => s.add(new Decimal(r.deviation_mwh)), new Decimal(0)).div('0.25') + const deviationPct = planned.gt(0) ? deviation.div(planned).mul(100).toFixed(2) : '0' + const envelopeUsage = released.filter((p) => p.envelope_ref).map((p) => ({ envelope_id: p.envelope_ref!, deviation_pct: deviationPct })) + + const findings = ctx.review.review({ + market_date: date, + ledger: ctx.ledger.read(), + bidProposal: bid, + dispatchProposal: dispatch, + executionReports: reports, + metering, + actualLoad: ctx.timeseries.latest('load:aggregate', date)?.curve ?? null, + forecasts: { load: forecastFromLineage(ctx, bid, 'load-forecast'), price: forecastFromLineage(ctx, bid, 'price-forecast') }, + unitResources: unitResources(ctx), + envelopeUsage, + now, + idGen: (p) => ctx.newId(p), + }) + + const suspended: string[] = [] + for (const u of envelopeUsage) { + const r = ctx.envelopes.recordExecution(u.envelope_id, u.deviation_pct, now) + ctx.events.append({ event_type: 'EnvelopeExecutionRecorded', payload: { envelope_id: u.envelope_id, deviation_pct: u.deviation_pct, streak: r.streak }, correlation_id: `review-${date}` }) + if (r.suspended) { + suspended.push(u.envelope_id) + ctx.events.append({ event_type: 'EnvelopeSuspended', payload: { envelope_id: u.envelope_id, streak: r.streak }, correlation_id: `review-${date}` }) + } + } + + let memoryCount = 0 + const profiles: Array<{ resource_id: string; reliability_score: string }> = [] + const requests: EnvelopeChangeRequest[] = [] + const runIds: string[] = [] + for (const f of findings) { + ctx.findings.put(f) + const ref = ctx.snapshots.put(f) + ctx.events.append({ event_type: 'ReviewFinding', payload: { finding_id: f.id, kind: f.kind, ref, writebacks: f.writebacks.length }, correlation_id: `review-${date}`, causation_id: f.id }) + const applied = ctx.review.apply(f, now) + memoryCount += applied.memory.length + for (const p of applied.profiles) { + profiles.push({ resource_id: p.resource_id, reliability_score: p.reliability_score }) + ctx.events.append({ event_type: 'ResourceProfileUpdated', payload: { resource_id: p.resource_id, reliability_score: p.reliability_score, finding_id: f.id }, correlation_id: `review-${date}`, causation_id: f.id }) + } + for (const req of applied.envelopeRequests) { + ctx.envelopeRequests.put(req) + requests.push(req) + const caseId = ctx.caseDesk.open({ id: ctx.newId('case'), kind: 'REVIEW', objective: `Re-approve envelope ${req.envelope_id}: ${req.action} ${Object.keys(req.bounds).join(',')}`, owner: 'user-ops-lead', deadline: now }).id + const run = await envelopeReview.createRun() + const res = await run.start({ inputData: { request: req, case_id: caseId } }) + if (res.status === 'suspended') { + ctx.caseDesk.requestApproval({ proposal_id: req.id, proposal_digest: contentRef(canonicalJson(req)), case_id: caseId, run_id: run.runId, required_level: 'L2', reasons: [req.basis], since: now, workflow: 'envelopeReview' }) + } + runIds.push(run.runId) + ctx.events.append({ event_type: 'EnvelopeChangeRequested', payload: { request_id: req.id, envelope_id: req.envelope_id, action: req.action, run_id: run.runId }, correlation_id: `review-${date}`, causation_id: f.id }) + } + } + return { market_date: date, findings, memory_entries: memoryCount, updated_profiles: profiles, envelope_suspended: suspended, envelope_requests: requests, envelope_review_run_ids: runIds } + }, + }) + + const narrate = createStep({ + id: 'narrate', + inputSchema: attribute.outputSchema, + outputSchema: ReviewOutput, + execute: async ({ inputData }) => { + let reportId: string | null = null + try { + const report = registerSkill( + { lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') }, + { id: 'report-generator', version: '1.0.0', invoke: (req: Parameters[0]) => ctx.skills.report(req) }, + ) + const { output } = await ctx.lineage.run(() => + report.call({ + kind: 'BID_BACKTEST', + market_date: inputData.market_date, + sources: inputData.findings.map((f: ReviewFinding) => ({ tool_call_id: f.id, tool: 'review-attribution', version: '1.0.0', output: { attribution: f.attribution } })), + }), + ) + reportId = SkillReport.parse(output).id + } catch (e) { + ctx.events.append({ event_type: 'ReviewReportFailed', payload: { error: (e as Error).message }, correlation_id: `review-${inputData.market_date}` }) + } + const f = inputData.findings[0] + const fallback = f ? `${f.kind}: ${f.summary}. Writebacks: ${f.writebacks.map((w) => w.target).join(', ') || 'none'} (template — LLM unavailable)` : 'no findings' + let narrative = fallback + let llmUsed = false + try { + const out = await ctx.llm.structured('analysis-agent', `Narrate this review for operators without adding numbers: ${JSON.stringify(inputData.findings.map((x: ReviewFinding) => ({ kind: x.kind, summary: x.summary, attribution: x.attribution })))}`, Narrative) + narrative = out.narrative + llmUsed = true + } catch (e) { + if (!(e instanceof LlmUnavailable)) throw e + } + ctx.events.append({ event_type: 'ReviewCompleted', payload: { market_date: inputData.market_date, findings: inputData.findings.map((x: ReviewFinding) => x.id), report_id: reportId }, correlation_id: `review-${inputData.market_date}` }) + return { ...inputData, report_id: reportId, narrative, llm_used: llmUsed } + }, + }) + + return createWorkflow({ id: 'review', inputSchema: ReviewInput, outputSchema: ReviewOutput }).then(attribute).then(narrate).commit() +} diff --git a/packages/runtime/test/helpers.ts b/packages/runtime/test/helpers.ts index 6581a5e..bceec4b 100644 --- a/packages/runtime/test/helpers.ts +++ b/packages/runtime/test/helpers.ts @@ -2,7 +2,7 @@ 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 type { BidOptimizationRequest, BidOptimizationResult, DispatchOptimizationRequest, DispatchOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, PotentialAssessmentRequest, PotentialAssessmentResult, ReportRequest, SkillReport } from '@vpp/domain' import { MemoryTimeSeriesStore } from '@vpp/services' import type { SkillClient } from '@vpp/services' import { createRuntime } from '../src/runtime.js' @@ -56,7 +56,38 @@ export class StubSkills implements SkillClient { 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 { throw new Error('not used') } + async report(r: ReportRequest): Promise { + this.calls.push('report') + return { id: `rep-${r.kind.toLowerCase()}-${r.market_date}`, kind: r.kind, market_date: r.market_date, sections: [], skill_version: '0.0.1', generated_at: '2026-03-16T03:00:00Z' } + } + async assessPotential(req: PotentialAssessmentRequest): Promise { + this.calls.push('potential') + const curveOf = (v: string) => ({ interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(v) as string[] }) + const assessments = req.resources.map((r) => { + const planned = r.fulfillment.reduce((s, f) => s + Number(f.planned_mwh), 0) + const delivered = r.fulfillment.reduce((s, f) => s + Number(f.delivered_mwh), 0) + const rate = planned > 0 ? Math.min(1, delivered / planned) : null + const mw = (Number(r.profile.certified_adjustable_mw) * (rate ?? 1)).toFixed(3) + return { resource_id: r.profile.resource_id, adjustable_mw: curveOf(mw), confidence: '0.900', fulfillment_rate: rate === null ? null : rate.toFixed(4), evidence_days: r.fulfillment.length } + }) + const total = assessments.reduce((s, a) => s + Number(a.adjustable_mw.values[0]), 0).toFixed(3) + return { market_date: req.market_date, assessments, total_adjustable_mw: curveOf(total), skill_version: '0.0.1' } + } + async optimizeDispatch(req: DispatchOptimizationRequest): Promise { + this.calls.push('dispatch') + // Greedy fill in unit order, per interval. + const remaining = req.target_mw.values.map(Number) + const allocations = req.units.map((u) => { + const values = u.available_mw.values.map((cap, t) => { + const x = Math.min(Number(cap), remaining[t]!) + remaining[t]! -= x + return x.toFixed(3) + }) + return { unit_id: u.unit_id, target_mw: { interval_minutes: 15 as const, date: req.market_date, values } } + }) + const shortfall = (remaining.reduce((s, v) => s + Math.max(0, v), 0) * 0.25).toFixed(3) + return { market_date: req.market_date, total_target_mw: req.target_mw, allocations, shortfall_mwh: shortfall, solver: { name: 'stub', version: '0', status: 'OPTIMAL', wall_time_ms: 0 }, skill_version: '0.0.1' } + } } export function seededTimeseries(clock: () => string) { diff --git a/packages/runtime/test/m4.test.ts b/packages/runtime/test/m4.test.ts new file mode 100644 index 0000000..785fcea --- /dev/null +++ b/packages/runtime/test/m4.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { AwardNotice, Envelope } from '@vpp/domain' +import { insightCards } from '../src/cards.js' +import { TriggerService } from '../src/trigger.js' +import { MARKET_DATE, envelope, harness } from './helpers.js' + +const APPROVER = { id: 'user-ops-lead', role: 'ops-lead' } +const flat = (v: string) => ({ interval_minutes: 15 as const, date: MARKET_DATE, values: Array(96).fill(v) as string[] }) + +const dispatchEnvelope = (bounds: Record): Envelope => + envelope(bounds, { id: 'env-dispatch-001', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' } }) + +/** Runs D-1 08:00 (bid) and returns the bid's daily energy so the award can answer it. */ +async function bidDay(h: Awaited>) { + h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun() + const res = await run.start({ inputData: { market_date: MARKET_DATE } }) + if (res.status !== 'success') throw new Error(res.status) + return res.result +} + +const awardFor = (digest: string, mwhPerInterval: string): AwardNotice => ({ + id: `award-${MARKET_DATE}`, + market_date: MARKET_DATE, + bid_proposal_digest: digest, + awarded_mwh: flat(mwhPerInterval), + clearing_price_yuan_per_mwh: flat('418.30'), + received_at: '2026-03-14T08:00:00Z', +}) + +describe('docs/07 D-1 16:00: award → resource agent → dispatch plan through the safety chain', () => { + it('records the award in the ledger and releases a dispatch plan to the simulation gateway', async () => { + const h = await harness({ llm: null }) + const bid = await bidDay(h) + h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' })) + const t = new TriggerService(h.rt) + const out = await t.onAward(awardFor(bid.proposal.digest, '3.0')) // 12 MW target, resource offers 24 MW + expect(out.status).toBe('success') + const r = out.result! + expect(r.lifecycle?.outcome).toBe('RELEASED') + expect(r.proposal.type).toBe('DISPATCH_PLAN') + expect(r.shortfall_mwh).toBe('0.000') + expect(h.rt.ctx.ledger.read().entries.map((e) => e.kind)).toEqual(['CONTRACT', 'BID_SUBMITTED', 'AWARD']) + expect(h.skills.calls.filter((c) => c === 'potential' || c === 'dispatch')).toEqual(['potential', 'dispatch']) + expect(h.rt.ctx.simulationGateway.receipts.list()).toHaveLength(1) + // P2 for dispatch numbers: allocations are byte-identical to the tool output. + const lin = h.rt.ctx.caseDesk.expandLineage(r.proposal_id) + expect(lin.tool_calls.map((c) => c.tool)).toEqual(['potential-assessment', 'dispatch-optimization']) + expect((lin.tool_calls[1]!.outputs as { allocations: unknown }).allocations).toEqual(r.proposal.type === 'DISPATCH_PLAN' ? r.proposal.payload.allocations : null) + await h.rt.close() + }) + + it('a shortfall (award beyond assessed capacity) alerts in power-balance simulation and goes to a human', async () => { + const h = await harness({ llm: null }) + const bid = await bidDay(h) + h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '100.0' })) + const out = await new TriggerService(h.rt).onAward(awardFor(bid.proposal.digest, '10.0')) // 40 MW target > 24 MW + expect(out.status).toBe('success') + expect(out.result!.lifecycle_status).toBe('suspended') + expect(Number(out.result!.shortfall_mwh)).toBeGreaterThan(0) + expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/short/) + await h.rt.close() + }) +}) + +describe('docs/07 D+1: execution feedback → review → writebacks', () => { + async function throughExecution(h: Awaited>, fulfilment: number) { + const bid = await bidDay(h) + h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' })) + const t = new TriggerService(h.rt) + const out = await t.onAward(awardFor(bid.proposal.digest, '3.0')) + const digest = out.result!.proposal.digest + t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, { 'agg-unit-01': fulfilment }, '2026-03-16T01:00:00Z')) + const review = await t.onMetering({ id: `meter-${MARKET_DATE}`, market_date: MARKET_DATE, metered_mw: flat((12 * fulfilment).toFixed(3)), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' }) + return { t, review: review.result!, bid } + } + + it('a ReviewFinding measurably updates the resource reliability score and writes semantic memory', async () => { + const h = await harness({ llm: null }) + expect(h.rt.ctx.resources.get('res-storage-01')!.value.reliability_score).toBe('0.9') + const { review } = await throughExecution(h, 0.8) + expect(review.findings).toHaveLength(1) + const f = review.findings[0]! + expect(f.kind).toBe('RESPONSE') + expect(review.updated_profiles).toEqual([{ resource_id: 'res-storage-01', reliability_score: '0.880' }]) + expect(h.rt.ctx.resources.get('res-storage-01')!.value.reliability_score).toBe('0.880') + expect(review.memory_entries).toBeGreaterThanOrEqual(1) + expect(h.rt.ctx.review.memory.list().some((m) => m.value.topic === 'response:agg-unit-01')).toBe(true) + expect(review.report_id).toMatch(/^rep-bid_backtest/) + expect(review.llm_used).toBe(false) + expect(h.rt.ctx.events.list({ event_type: 'ResourceProfileUpdated' })).toHaveLength(1) + // The next potential assessment cites this execution feedback (画像时效性). + const t = new TriggerService(h.rt) + h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' })) + const again = await t.onAward({ ...awardFor(h.rt.ctx.caseDesk.proposals.list()[0]!.value.digest, '2.0'), id: 'award-2', market_date: '2026-03-16' }) + void again + const pot = h.rt.ctx.events.list({ event_type: 'PotentialAssessed' }).at(-1)!.payload as { units: Record } + expect(pot.units['agg-unit-01']![0]).toBe('19.200') // 24 MW × 0.8 fulfilment + await h.rt.close() + }) + + it('envelope suspension triggers on a seeded deviation streak (N=3, threshold 10%)', async () => { + const h = await harness({ llm: null }) + const { t } = await throughExecution(h, 0.8) // 20% deviation → streak 1 + const digest = h.rt.ctx.caseDesk.proposals.list().find((p) => p.value.type === 'DISPATCH_PLAN')!.value.digest + for (let i = 0; i < 2; i++) { + t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, { 'agg-unit-01': 0.8 }, '2026-03-16T01:00:00Z')) + await t.onMetering({ id: `meter-${i}`, market_date: MARKET_DATE, metered_mw: flat('9.600'), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' }) + } + expect(h.rt.ctx.envelopes.get('env-bid-001')!.status).toBe('SUSPENDED') + expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.status).toBe('SUSPENDED') + expect(h.rt.ctx.events.list({ event_type: 'EnvelopeSuspended' })).toHaveLength(2) + // Autonomy has returned to humans: the next bid waits for approval. + const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun() + const res = await run.start({ inputData: { market_date: '2026-03-16' } }) + expect(res.status === 'success' && res.result.lifecycle_status).toBe('suspended') + await h.rt.close() + }) + + it('a compliant streak produces a WIDEN request that only a human can apply (envelope-review)', async () => { + const h = await harness({ llm: null, config: { widenAfterCompliantDays: 2 } }) + const { t, review } = await throughExecution(h, 1.0) + expect(review.envelope_requests).toHaveLength(0) + const digest = h.rt.ctx.caseDesk.proposals.list().find((p) => p.value.type === 'DISPATCH_PLAN')!.value.digest + t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, {}, '2026-03-16T01:00:00Z')) + const second = (await t.onMetering({ id: 'meter-2', market_date: MARKET_DATE, metered_mw: flat('12.000'), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' })).result! + expect(second.envelope_requests.map((r: { envelope_id: string }) => r.envelope_id).sort()).toEqual(['env-bid-001', 'env-dispatch-001']) + expect(second.envelope_review_run_ids).toHaveLength(2) + const inbox = h.rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== '') + expect(inbox.every((p) => p.workflow === 'envelopeReview')).toBe(true) + expect(h.rt.ctx.envelopes.get('env-bid-001')!.bounds['price_deviation_pct']).toBeUndefined() // bid envelope had only max_energy + + const bidReq = second.envelope_requests.find((r: { envelope_id: string }) => r.envelope_id === 'env-dispatch-001')! + const runId = inbox.find((p) => p.proposal_id === bidReq.id)!.run_id + const refused = await t.decide(runId, { decision: 'approve', approver: { id: 'resource-agent', role: 'ops-lead' } }) + expect(refused.status).toBe('suspended') + const ok = await t.decide(runId, { decision: 'approve', approver: APPROVER, comment: 'shadow data supports widening' }) + expect(ok.status).toBe('success') + expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.bounds['max_total_mw']).toBe('36.00') + expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.approval.approved_by).toEqual(['user-ops-lead']) + expect(h.rt.ctx.events.list({ event_type: 'EnvelopeChanged' })).toHaveLength(1) + await h.rt.close() + }) + + it('insight cards project situation, review and resource state with lineage refs', async () => { + const h = await harness({ llm: null }) + const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun() + await sit.start({ inputData: { market_date: MARKET_DATE } }) + await throughExecution(h, 0.8) + const trading = insightCards(h.rt.ctx, 'TRADING_DESK') + expect(trading.map((c) => c.title)).toEqual(['Day-ahead situation', 'D+1 review']) + expect(trading[0]!.metrics[0]!.ref?.path).toMatch(/^quantiles\.p50\.values\.\d+$/) + const pool = insightCards(h.rt.ctx, 'RESOURCE_POOL') + expect(pool[0]!.headline).toMatch(/Reliability 0\.900 → 0\.880/) + const ops = insightCards(h.rt.ctx, 'OPERATIONS_DASHBOARD') + expect(ops.find((c) => c.id === 'card-ops-cases')).toBeDefined() + await h.rt.close() + }) +}) diff --git a/packages/services/src/casedesk.ts b/packages/services/src/casedesk.ts index 707afb2..3e04956 100644 --- a/packages/services/src/casedesk.ts +++ b/packages/services/src/casedesk.ts @@ -21,6 +21,8 @@ export interface PendingApproval { required_level: string reasons: string[] since: string + /** Which workflow's run to resume: 'proposalLifecycle' (default) or 'envelopeReview'. */ + workflow?: string | undefined } export interface CaseView { @@ -177,4 +179,5 @@ export const PendingApprovalSchema = z.object({ required_level: z.string().min(1), reasons: z.array(z.string()), since: z.string().min(1), + workflow: z.string().optional(), }) diff --git a/packages/services/src/envelope.ts b/packages/services/src/envelope.ts index 53f2c4a..336b9c8 100644 --- a/packages/services/src/envelope.ts +++ b/packages/services/src/envelope.ts @@ -27,6 +27,48 @@ export class EnvelopeService { this.repo.put(envelope) } + get(id: string): Envelope | undefined { + return this.repo.get(id)?.value + } + + private readonly streaks = new Map() + + /** + * Escalation rule (docs/03 §3): N consecutive within-envelope executions + * whose deviation exceeds the threshold suspend the envelope — autonomy + * returns to humans until a re-approval. N and threshold live on the + * envelope (OPEN-QUESTION B4 values). + */ + recordExecution(envelopeId: string, deviationPct: string, now: string): { streak: number; suspended: boolean } { + const row = this.repo.get(envelopeId) + if (!row) throw new Error(`envelope ${envelopeId} not found`) + const env = row.value + const exceeded = new Decimal(deviationPct).gt(env.escalation.deviation_threshold_pct) + const streak = exceeded ? (this.streaks.get(envelopeId) ?? 0) + 1 : 0 + this.streaks.set(envelopeId, streak) + if (exceeded && streak >= env.escalation.max_consecutive_deviations && env.status === 'ACTIVE') { + this.repo.put({ ...env, status: 'SUSPENDED' }, row.version) + void now + return { streak, suspended: true } + } + return { streak, suspended: false } + } + + /** Applied only by the envelope-review workflow after a human approval. */ + apply(change: { envelope_id: string; action: 'WIDEN' | 'NARROW' | 'SUSPEND' | 'REACTIVATE'; bounds: Record }, approvedBy: string[]): Envelope { + const row = this.repo.get(change.envelope_id) + if (!row) throw new Error(`envelope ${change.envelope_id} not found`) + const env = row.value + const next: Envelope = { + ...env, + bounds: change.action === 'SUSPEND' ? env.bounds : { ...env.bounds, ...change.bounds }, + status: change.action === 'SUSPEND' ? 'SUSPENDED' : 'ACTIVE', + approval: { ...env.approval, approved_by: approvedBy }, + } + this.streaks.set(env.id, 0) + return this.repo.put(next, row.version).value + } + match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch { const fallback = ctx.fallbackLevel ?? 'L2' const candidates = this.repo @@ -70,6 +112,7 @@ export class EnvelopeService { } private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] { + if (proposal.type === 'DISPATCH_PLAN') return this.checkDispatchBounds(proposal, env) if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1'] const reasons: string[] = [] const maxEnergy = env.bounds['max_energy_mwh'] @@ -104,4 +147,27 @@ export class EnvelopeService { } return reasons } + + /** CONTROL/DISPATCH bounds (docs/03 §3): max_total_mw, max_unit_mw. Values OPEN-QUESTION B2. */ + private checkDispatchBounds(proposal: Proposal & { type: 'DISPATCH_PLAN' }, env: Envelope): string[] { + const reasons: string[] = [] + const maxTotal = env.bounds['max_total_mw'] + const maxUnit = env.bounds['max_unit_mw'] + for (let t = 0; t < 96; t++) { + let total = new Decimal(0) + for (const a of proposal.payload.allocations) { + const v = new Decimal(a.target_mw.values[t]!) + total = total.add(v) + if (maxUnit !== undefined && v.gt(maxUnit)) { + reasons.push(`interval ${t}: unit ${a.unit_id} target ${v.toString()} MW > max_unit_mw ${maxUnit}`) + return reasons + } + } + if (maxTotal !== undefined && total.gt(maxTotal)) { + reasons.push(`interval ${t}: total ${total.toString()} MW > max_total_mw ${maxTotal}`) + return reasons + } + } + return reasons + } } diff --git a/packages/services/src/gateway.ts b/packages/services/src/gateway.ts index 8dc7988..48784fe 100644 --- a/packages/services/src/gateway.ts +++ b/packages/services/src/gateway.ts @@ -1,6 +1,6 @@ import { mkdirSync, writeFileSync, existsSync } from 'node:fs' import { join } from 'node:path' -import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain' +import type { BidExportFile, ExecutionPermit, ExecutionReceipt, ExecutionReport, 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' @@ -73,3 +73,63 @@ export class FileExportGateway implements GatewayPort { return receipt } } + +/** + * Simulation gateway (docs/08 shadow mode, ROADMAP M5): accepts released + * DISPATCH_PLAN proposals the same way the execution engine will — permit + * only, idempotent — and can later "execute" them with seeded deviations to + * produce ExecutionReports for the review loop. + */ +export class SimulationGateway implements GatewayPort { + readonly orders = new Map() + + constructor( + private readonly authority: AuthorityService, + readonly receipts: Repository = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key), + ) {} + + 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 !== 'DISPATCH_PLAN') throw new GatewayRejected([`simulation gateway accepts DISPATCH_PLAN only, got ${proposal.type}`]) + this.orders.set(proposal.digest, { proposal, permit }) + const receipt: ExecutionReceipt = { + receipt_id: `rcpt-${proposal.id}-${permit.id}`, + proposal_digest: proposal.digest, + permit_id: permit.id, + channel: 'SIMULATION_GATEWAY', + idempotency_key: key, + artifact_ref: `sim://dispatch/${proposal.digest}`, + accepted_at: now, + } + this.receipts.put(receipt) + return receipt + } + + /** Seeded execution: each unit delivers `fulfillment[unit]` (default 1.0) of its plan. */ + execute(proposalDigest: string, fulfillment: Record, now: string): ExecutionReport[] { + const order = this.orders.get(proposalDigest) + if (!order || order.proposal.type !== 'DISPATCH_PLAN') throw new Error(`no dispatch order for ${proposalDigest.slice(0, 8)}…`) + const p = order.proposal + return p.payload.allocations.map((a) => { + const f = fulfillment[a.unit_id] ?? 1 + const actual = a.target_mw.values.map((v) => (Number(v) * f).toFixed(3)) + const planned = a.target_mw.values.map(Number).reduce((s, v) => s + v, 0) * 0.25 + const dev = a.target_mw.values.reduce((s, v, i) => s + Math.abs(Number(v) - Number(actual[i])), 0) * 0.25 + return { + id: `exec-${p.payload.market_date}-${a.unit_id}`, + market_date: p.payload.market_date, + unit_id: a.unit_id, + dispatch_proposal_digest: p.digest, + planned_mw: a.target_mw, + actual_mw: { ...a.target_mw, values: actual }, + deviation_mwh: dev.toFixed(3), + fulfillment_ratio: planned === 0 ? '1.000' : Math.min(1, (planned - dev) / planned).toFixed(3), + reported_at: now, + } + }) + } +} diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index bf257bf..639ff46 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -15,3 +15,4 @@ export * from './lineage.js' export * from './simulation.js' export * from './casedesk.js' export * from './skillclient.js' +export * from './review.js' diff --git a/packages/services/src/lineage.ts b/packages/services/src/lineage.ts index 1262276..5e39c83 100644 --- a/packages/services/src/lineage.ts +++ b/packages/services/src/lineage.ts @@ -1,6 +1,6 @@ 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 type { BidPayload, BidProposalDraft, DispatchPlanPayload, DispatchProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain' +import { BidPayload as BidPayloadSchema, Curve96, DecimalString, DispatchPlanPayload as DispatchPlanPayloadSchema } from '@vpp/domain' import { proposalDigest } from './digest.js' import type { SnapshotStore } from './snapshot.js' @@ -118,3 +118,38 @@ export function assembleBidProposal( } return { ...base, digest: proposalDigest(base) } } + +/** DISPATCH_PLAN assembler: same discipline, resource-agent originator, EVENT trigger. */ +export function assembleDispatchProposal( + draft: DispatchProposalDraft, + ctx: { + id: string + award_ref: string + originator: Proposal['originator'] + lineage: { tool_calls: ToolCallRef[]; data_refs: string[] } + ledger_version: number + policy_pack_version: string + snapshots: SnapshotStore + now: string + }, +): Proposal { + const payload: DispatchPlanPayload = DispatchPlanPayloadSchema.parse({ + kind: 'DISPATCH_PLAN', + market_date: draft.market_date, + award_ref: ctx.award_ref, + total_target_mw: Curve96.parse(resolveRef(draft.total_target_ref, ctx.lineage.tool_calls, ctx.snapshots)), + allocations: resolveRef(draft.allocations_ref, ctx.lineage.tool_calls, ctx.snapshots), + }) + const base = { + id: ctx.id, + type: 'DISPATCH_PLAN' as const, + timescale: 'DAY_AHEAD' as const, + originator: ctx.originator, + payload, + 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 }, + envelope_ref: null, + status: 'DRAFT' as const, + created_at: ctx.now, + } + return { ...base, digest: proposalDigest(base) } +} diff --git a/packages/services/src/policy.ts b/packages/services/src/policy.ts index ade79bf..216c664 100644 --- a/packages/services/src/policy.ts +++ b/packages/services/src/policy.ts @@ -1,5 +1,6 @@ import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain' import { digestMatches } from './digest.js' +import { canonicalJson } from './snapshot.js' import type { LedgerService } from './ledger.js' import type { SnapshotStore } from './snapshot.js' import { Decimal } from './decimal.js' @@ -89,12 +90,15 @@ export interface HubeiBidPackConfig { priceCapYuanPerMwh: string /** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */ originators: Record + dispatchBalanceToleranceMw: string } export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = { priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5 priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5 - originators: { BID: ['trading-agent', 'operator'] }, + originators: { BID: ['trading-agent', 'operator'], DISPATCH_PLAN: ['resource-agent', 'operator'] }, + /** Allowed |Σ allocations − target| per interval, MW. OPEN-QUESTION B2. */ + dispatchBalanceToleranceMw: '0.001', } const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message }) @@ -178,8 +182,16 @@ export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): 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')] + if (p.type === 'DISPATCH_PLAN') { + const outputs = p.lineage.tool_calls.map((tc) => ctx.snapshots.get(tc.outputs_ref)).filter((o): o is Record => !!o && typeof o === 'object') + const eq = (a: unknown, b: unknown) => canonicalJson(a) === canonicalJson(b) + const out: Violation[] = [] + if (!outputs.some((o) => eq((o['total_target_mw'] as { values?: unknown } | undefined)?.values, p.payload.total_target_mw.values))) out.push(reject('lineage-integrity', 'total_target_mw not traceable to a tool output')) + if (!outputs.some((o) => eq(o['allocations'], p.payload.allocations))) out.push(reject('lineage-integrity', 'allocations not traceable to a tool output')) + return out + } + if (p.type !== 'BID') return [] const outputs: Record[] = [] for (const tc of p.lineage.tool_calls) { const out = ctx.snapshots.get(tc.outputs_ref) @@ -196,6 +208,33 @@ export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): return out }, }, + { + id: 'dispatch-balance', + description: 'allocations never exceed the awarded target in any interval; a shortfall is a simulation alert, not a rule breach (docs/04 §2)', + source_refs: ['docs/04 §2', 'B2'], + check: (p) => { + if (p.type !== 'DISPATCH_PLAN') return [] + const tol = new Decimal(cfg.dispatchBalanceToleranceMw) + for (let t = 0; t < 96; t++) { + const total = p.payload.allocations.reduce((s, a) => s.add(new Decimal(a.target_mw.values[t]!)), new Decimal(0)) + const target = new Decimal(p.payload.total_target_mw.values[t]!) + if (total.sub(target).gt(tol)) return [reject('dispatch-balance', `interval ${t}: allocations ${total.toString()} MW exceed target ${target.toString()} MW`)] + } + return [] + }, + }, + { + id: 'dispatch-award-anchor', + description: 'a dispatch plan cites an AWARD position in the current ledger for its market date (P7)', + source_refs: ['docs/04 §4'], + check: (p, ctx) => { + if (p.type !== 'DISPATCH_PLAN') return [] + const award = ctx.ledger.entries.find((e) => e.kind === 'AWARD' && e.timescale === 'DAY_AHEAD' && e.period === p.payload.market_date) + if (!award) return [reject('dispatch-award-anchor', `no AWARD position for ${p.payload.market_date} in ledger v${ctx.ledger.version}`)] + if (p.lineage.ledger_version !== ctx.ledger.version) return [reject('dispatch-award-anchor', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`)] + return [] + }, + }, { id: 'originator-permission', description: 'originating agent is allowed to originate this proposal type (I2)', diff --git a/packages/services/src/review.ts b/packages/services/src/review.ts new file mode 100644 index 0000000..71c3072 --- /dev/null +++ b/packages/services/src/review.ts @@ -0,0 +1,201 @@ +import type { + Curve96, + Envelope, + EnvelopeChangeRequest, + ExecutionReport, + ForecastBundle, + LedgerView, + MeteringRecord, + Proposal, + ResourceProfile, + ReviewFinding, + SemanticMemoryEntry, + Writeback, +} from '@vpp/domain' +import { SemanticMemoryEntry as SemanticMemoryEntrySchema } from '@vpp/domain' +import { Decimal } from './decimal.js' +import type { EnvelopeService } from './envelope.js' +import { MemoryRepository } from './relational.js' +import type { Repository, ResourceRegistry } from './relational.js' + +/** + * Review loop (docs/02 §4, docs/07 D+1): deterministic plan-vs-actual per + * timescale from the ledger and execution/metering facts, attribution into + * trading / control / response / forecast components, and the three + * writebacks. Everything here is arithmetic on recorded objects — the + * analysis agent may narrate a finding but never produces one. + */ +export interface ReviewInputs { + market_date: string + ledger: LedgerView + bidProposal: Proposal | null + dispatchProposal: Proposal | null + executionReports: ExecutionReport[] + metering: MeteringRecord | null + /** Actual aggregate load telemetry for the day (time-series store) — what the load forecast is judged against. */ + actualLoad: Curve96 | null + forecasts: { load: ForecastBundle | null; price: ForecastBundle | null } + /** Unit → resources it aggregates (for reliability writebacks). */ + unitResources: Record + /** Envelopes used on this day and the day's deviation vs threshold. */ + envelopeUsage: Array<{ envelope_id: string; deviation_pct: string }> + now: string + idGen: (prefix: string) => string +} + +export interface ReviewConfig { + /** EWMA weight of the new observation in the reliability score. OPEN-QUESTION B10. */ + reliabilityAlpha: string + /** Consecutive compliant days before recommending a wider envelope. OPEN-QUESTION B4/B9. */ + widenAfterCompliantDays: number + /** Relative widening step for a WIDEN recommendation. OPEN-QUESTION B1. */ + widenStepPct: string +} + +export const DEFAULT_REVIEW_CONFIG: ReviewConfig = { + reliabilityAlpha: '0.2', + widenAfterCompliantDays: 20, + widenStepPct: '20', +} + +const D = (v: string | number) => new Decimal(v) +const energy = (values: string[]) => values.reduce((s, v) => s.add(D(v)), D(0)).mul('0.25') +const mape = (actual: string[], pred: string[]): string | null => { + let n = 0 + let sum = D(0) + actual.forEach((a, i) => { + if (D(a).eq(0)) return + sum = sum.add(D(pred[i]!).sub(D(a)).abs().div(D(a))) + n++ + }) + return n === 0 ? null : sum.div(n).toFixed(4) +} + +export class ReviewService { + private readonly compliantDays = new Map() + + constructor( + private readonly resources: ResourceRegistry, + private readonly envelopes: EnvelopeService, + readonly memory: Repository = new MemoryRepository(SemanticMemoryEntrySchema, (m) => m.id), + private readonly cfg: ReviewConfig = DEFAULT_REVIEW_CONFIG, + ) {} + + /** Plan-vs-actual per timescale → attribution → finding with writebacks (not yet applied). */ + review(input: ReviewInputs): ReviewFinding[] { + const findings: ReviewFinding[] = [] + const bid = input.bidProposal?.type === 'BID' ? input.bidProposal.payload : null + const award = input.ledger.entries.find((e) => e.kind === 'AWARD' && e.period === input.market_date) + const dispatch = input.dispatchProposal?.type === 'DISPATCH_PLAN' ? input.dispatchProposal.payload : null + + const bidMwh = bid ? bid.quantities_mwh.values.reduce((s, v) => s.add(D(v)), D(0)) : D(0) + const awardMwh = award ? D(award.energy_mwh) : D(0) + const planMwh = dispatch ? energy(dispatch.total_target_mw.values) : D(0) + const deliveredMwh = input.executionReports.reduce((s, r) => s.add(energy(r.actual_mw.values)), D(0)) + const meteredMwh = input.metering ? energy(input.metering.metered_mw.values) : deliveredMwh + + const attribution = { + trading_mwh: bidMwh.sub(awardMwh).abs().toFixed(3), + control_mwh: awardMwh.sub(planMwh).abs().toFixed(3), + response_mwh: input.executionReports.reduce((s, r) => s.add(D(r.deviation_mwh)), D(0)).toFixed(3), + forecast_load_mape: input.forecasts.load && input.actualLoad ? mape(input.actualLoad.values, input.forecasts.load.quantiles.p50.values) : null, + forecast_price_mape: input.forecasts.price && input.metering ? mape(input.metering.actual_price_yuan_per_mwh.values, input.forecasts.price.quantiles.p50.values) : null, + net_deviation_mwh: meteredMwh.sub(planMwh.gt(0) ? planMwh : awardMwh).toFixed(3), + } + const evidence = [ + ...(input.bidProposal ? [input.bidProposal.digest] : []), + ...(input.dispatchProposal ? [input.dispatchProposal.digest] : []), + ] + + // ---- RESPONSE: per-unit fulfillment → resource reliability writebacks + const writebacks: Writeback[] = [] + for (const r of input.executionReports) { + for (const rid of input.unitResources[r.unit_id] ?? []) { + const row = this.resources.get(rid) + if (!row) continue + const prev = D(row.value.reliability_score) + const alpha = D(this.cfg.reliabilityAlpha) + const next = prev.mul(D(1).sub(alpha)).add(D(r.fulfillment_ratio).mul(alpha)) + writebacks.push({ target: 'RESOURCE_PROFILE', resource_id: rid, previous_reliability_score: prev.toFixed(3), new_reliability_score: next.toFixed(3), fulfillment_ratio: r.fulfillment_ratio }) + } + if (D(r.fulfillment_ratio).lt('0.9')) { + writebacks.push({ target: 'SEMANTIC_MEMORY', topic: `response:${r.unit_id}`, lesson: `${r.unit_id} delivered ${D(r.fulfillment_ratio).mul(100).toFixed(1)}% of plan on ${input.market_date} (deviation ${r.deviation_mwh} MWh)` }) + } + } + // ---- ENVELOPE recommendations from compliance streaks + for (const u of input.envelopeUsage) { + const env = this.envelopes.get(u.envelope_id) + if (!env) continue + const compliant = D(u.deviation_pct).lte(env.escalation.deviation_threshold_pct) + const days = compliant ? (this.compliantDays.get(env.id) ?? 0) + 1 : 0 + this.compliantDays.set(env.id, days) + writebacks.push(this.envelopeRecommendation(env, days, compliant)) + } + // ---- FORECAST lesson + if (attribution.forecast_load_mape !== null && D(attribution.forecast_load_mape).gt('0.08')) { + writebacks.push({ target: 'SEMANTIC_MEMORY', topic: 'forecast:load', lesson: `Load MAPE ${D(attribution.forecast_load_mape).mul(100).toFixed(1)}% on ${input.market_date} exceeds the 8% KPI; feature-engineering candidate` }) + } + + const kind = D(attribution.response_mwh).gt(0) ? 'RESPONSE' : D(attribution.trading_mwh).gt(0) ? 'TRADING' : D(attribution.control_mwh).gt(0) ? 'CONTROL' : 'FORECAST' + findings.push({ + id: input.idGen('rf'), + market_date: input.market_date, + kind, + summary: `bid ${bidMwh.toFixed(3)} / award ${awardMwh.toFixed(3)} / plan ${planMwh.toFixed(3)} / delivered ${deliveredMwh.toFixed(3)} / metered ${meteredMwh.toFixed(3)} MWh; ${writebacks.length} writebacks`, + attribution, + writebacks, + evidence_refs: evidence, + lineage_refs: [], + generated_at: input.now, + }) + return findings + } + + private envelopeRecommendation(env: Envelope, compliantDays: number, compliant: boolean): Writeback { + const bound = env.scope.proposal_type === 'BID' ? 'price_deviation_pct' : 'max_total_mw' + const current = env.bounds[bound] ?? '0' + if (!compliant) { + return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: env.status === 'SUSPENDED' ? 'SUSPEND' : 'KEEP', bound, current_value: current, recommended_value: current, basis: `deviation above threshold on this day (streak reset)` } + } + if (compliantDays >= this.cfg.widenAfterCompliantDays) { + const recommended = D(current).mul(D(1).add(D(this.cfg.widenStepPct).div(100))).toFixed(2) + return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: 'WIDEN', bound, current_value: current, recommended_value: recommended, basis: `${compliantDays}/${compliantDays} within-envelope days under threshold` } + } + return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: 'KEEP', bound, current_value: current, recommended_value: current, basis: `${compliantDays} compliant days (widen at ${this.cfg.widenAfterCompliantDays})` } + } + + /** + * Apply the writebacks a finding carries. Semantic memory and reliability + * scores are written directly (they are review's own stores, docs/02 §4). + * Envelope changes are NOT applied here — they become EnvelopeChangeRequests + * for a human (docs/07 D+1: 触发包络重审工作流,人工批准生效). + */ + apply(finding: ReviewFinding, now: string): { memory: SemanticMemoryEntry[]; profiles: ResourceProfile[]; envelopeRequests: EnvelopeChangeRequest[] } { + const memory: SemanticMemoryEntry[] = [] + const profiles: ResourceProfile[] = [] + const envelopeRequests: EnvelopeChangeRequest[] = [] + for (const w of finding.writebacks) { + if (w.target === 'SEMANTIC_MEMORY') { + const entry: SemanticMemoryEntry = { id: `mem-${finding.id}-${memory.length + 1}`, topic: w.topic, lesson: w.lesson, source_finding_id: finding.id, evidence_refs: finding.evidence_refs, created_at: now } + this.memory.put(entry) + memory.push(entry) + } else if (w.target === 'RESOURCE_PROFILE') { + const row = this.resources.get(w.resource_id) + if (!row) continue + const updated = this.resources.put({ ...row.value, reliability_score: w.new_reliability_score, updated_at: now }, row.version).value + profiles.push(updated) + } else if (w.target === 'ENVELOPE_RECOMMENDATION' && (w.action === 'WIDEN' || w.action === 'NARROW')) { + envelopeRequests.push({ + id: `ecr-${finding.id}-${w.envelope_id}`, + envelope_id: w.envelope_id, + action: w.action, + bounds: { [w.bound]: w.recommended_value }, + basis: `ReviewFinding ${finding.id}: ${w.basis}`, + source_finding_id: finding.id, + requested_at: now, + }) + } + } + return { memory, profiles, envelopeRequests } + } +} diff --git a/packages/services/src/simulation.ts b/packages/services/src/simulation.ts index e21b646..8cda4b1 100644 --- a/packages/services/src/simulation.ts +++ b/packages/services/src/simulation.ts @@ -103,3 +103,38 @@ export class RevenueScenarioSimulator implements SimulationPort { } } } + +/** + * Power-balance check for dispatch plans (docs/03 §2 控制类走功率平衡): the + * decomposition must cover the target in every interval and no unit may be + * asked for more than its available capacity. Any breach is an alert. + */ +export class PowerBalanceSimulator implements SimulationPort { + static readonly NAME = 'power-balance' + static readonly VERSION = '1.0.0' + + constructor(private readonly unitLimits: (marketDate: string) => Record) {} + + simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult { + const base = { proposal_digest: proposal.digest, kind: 'POWER_BALANCE' as const, simulator: { name: PowerBalanceSimulator.NAME, version: PowerBalanceSimulator.VERSION, seed: 0 }, simulated_at: ctx.now } + if (proposal.type !== 'DISPATCH_PLAN') return { ...base, alert: true, alerts: ['power-balance simulation only supports DISPATCH_PLAN'], metrics: {}, scenario_count: 0 } + const limits = this.unitLimits(proposal.payload.market_date) + const alerts: string[] = [] + let shortfall = 0 + let overload = 0 + for (let t = 0; t < 96; t++) { + let total = 0 + for (const a of proposal.payload.allocations) { + const v = Number(a.target_mw.values[t]) + total += v + const lim = limits[a.unit_id]?.[t] + if (lim !== undefined && v > Number(lim) + 1e-9) overload += (v - Number(lim)) * 0.25 + } + const gap = Number(proposal.payload.total_target_mw.values[t]) - total + if (gap > 1e-6) shortfall += gap * 0.25 + } + if (shortfall > 0) alerts.push(`decomposition short by ${shortfall.toFixed(3)} MWh over the day`) + if (overload > 0) alerts.push(`units asked beyond available capacity: ${overload.toFixed(3)} MWh`) + return { ...base, alert: alerts.length > 0, alerts, metrics: { shortfall_mwh: shortfall.toFixed(3), overload_mwh: overload.toFixed(3) }, scenario_count: 1 } + } +} diff --git a/packages/services/src/skillclient.ts b/packages/services/src/skillclient.ts index 72a2940..b996aca 100644 --- a/packages/services/src/skillclient.ts +++ b/packages/services/src/skillclient.ts @@ -1,8 +1,12 @@ import { BidOptimizationRequest, BidOptimizationResult, + DispatchOptimizationRequest, + DispatchOptimizationResult, ForecastBundle, ForecastRequest, + PotentialAssessmentRequest, + PotentialAssessmentResult, ReportRequest, SkillReport, } from '@vpp/domain' @@ -19,6 +23,8 @@ export interface SkillClient { forecast(kind: ForecastKind, req: ForecastRequest): Promise optimizeBid(req: BidOptimizationRequest): Promise report(req: ReportRequest): Promise + assessPotential(req: PotentialAssessmentRequest): Promise + optimizeDispatch(req: DispatchOptimizationRequest): Promise } export class SkillHttpError extends Error { @@ -72,4 +78,12 @@ export class HttpSkillClient implements SkillClient { report(req: ReportRequest) { return this.post('/v1/report', ReportRequest.parse(req), (x) => SkillReport.parse(x)) } + + assessPotential(req: PotentialAssessmentRequest) { + return this.post('/v1/assess/potential', PotentialAssessmentRequest.parse(req), (x) => PotentialAssessmentResult.parse(x)) + } + + optimizeDispatch(req: DispatchOptimizationRequest) { + return this.post('/v1/optimize/dispatch', DispatchOptimizationRequest.parse(req), (x) => DispatchOptimizationResult.parse(x)) + } } diff --git a/packages/services/test/chain.test.ts b/packages/services/test/chain.test.ts index e35fff3..bf61ec0 100644 --- a/packages/services/test/chain.test.ts +++ b/packages/services/test/chain.test.ts @@ -3,6 +3,7 @@ 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 type { Proposal } from '@vpp/domain' import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js' import { CaseDeskService } from '../src/casedesk.js' import { proposalDigest } from '../src/digest.js' @@ -31,7 +32,7 @@ describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fai 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 tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } } as Proposal 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') }) @@ -56,7 +57,7 @@ describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fai 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') } } + const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } } as Proposal 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') diff --git a/packages/services/test/helpers.ts b/packages/services/test/helpers.ts index d05c4b0..f3b4cc4 100644 --- a/packages/services/test/helpers.ts +++ b/packages/services/test/helpers.ts @@ -49,7 +49,7 @@ export function world(opts: { qty?: string; price?: string; agent?: string } = { })) const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast()) - const proposal = lineage.run(() => { + const proposal: Proposal = lineage.run(() => { forecastTool({ kind: 'PRICE' }) const { call } = milp({ market_date: DATE }) return assembleBidProposal( diff --git a/packages/services/test/m4.test.ts b/packages/services/test/m4.test.ts new file mode 100644 index 0000000..96bcc1f --- /dev/null +++ b/packages/services/test/m4.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionReport, Proposal, ReviewFinding } from '@vpp/domain' +import { isStaleDenial } from '../src/authority.js' +import { EnvelopeService } from '../src/envelope.js' +import { GatewayRejected, SimulationGateway } from '../src/gateway.js' +import { LineageRecorder, assembleDispatchProposal, withLineage } from '../src/lineage.js' +import { createResourceRegistry } from '../src/relational.js' +import { ReviewService } from '../src/review.js' +import { PowerBalanceSimulator } from '../src/simulation.js' +import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, world } from './helpers.js' + +/** Dispatch world: an AWARD in the ledger, a recorded dispatch-optimization output, a DISPATCH_PLAN proposal from refs. */ +function dispatchWorld(opts: { total?: string; alloc?: string[] } = {}) { + const w = world() + w.ledger.append({ id: 'award', timescale: 'DAY_AHEAD', period: DATE, kind: 'AWARD', energy_mwh: '1152.000', curve: curve('12.0'), source_ref: 'award-1', expected_version: 1 }) + const lineage = new LineageRecorder() + let n = 0 + const total = opts.total ?? '48.000' + const allocs = opts.alloc ?? ['30.000', '18.000'] + const dispatch = withLineage(lineage, w.snapshots, { tool: 'dispatch-optimization', version: '1.0.0', idGen: () => `tcd-${++n}` }, (_i: unknown) => ({ + market_date: DATE, + total_target_mw: curve(total), + allocations: allocs.map((v, i) => ({ unit_id: `u${i + 1}`, target_mw: curve(v) })), + shortfall_mwh: '0.000', + })) + const proposal: Proposal = lineage.run(() => { + const { call } = dispatch({}) + return assembleDispatchProposal( + { market_date: DATE, total_target_ref: { tool_call_id: call.tool_call_id, path: 'total_target_mw' }, allocations_ref: { tool_call_id: call.tool_call_id, path: 'allocations' }, rationale: 'test' }, + { id: 'prop-d1', award_ref: 'award-1', originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: 'case-d' }, lineage: lineage.current(), ledger_version: w.ledger.read().version, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW }, + ) + }) + return { ...w, proposal } +} + +describe('dispatch plans in the policy pack', () => { + it('a balanced, anchored, traceable plan passes', () => { + const d = dispatchWorld() + const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) + expect(r.ok, JSON.stringify(r.violations)).toBe(true) + expect(r.rules_evaluated).toContain('dispatch-balance') + }) + + it('dispatch-balance rejects over-allocation (shortfall is left to the simulation alert)', () => { + const short = dispatchWorld({ alloc: ['30.000', '17.000'] }) + expect(short.engine.check(short.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, short.ruleCtx()).ok).toBe(true) + const d = dispatchWorld({ alloc: ['30.000', '19.000'] }) + const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) + expect(r.violations.map((v) => v.rule_id)).toContain('dispatch-balance') + }) + + it('dispatch-award-anchor rejects a plan without an AWARD in the ledger', () => { + const w = world() + const d = dispatchWorld() + const r = w.engine.check({ ...d.proposal, lineage: { ...d.proposal.lineage, ledger_version: 1 } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, { ...w.ruleCtx(), snapshots: d.snapshots }) + expect(r.violations.some((v) => v.rule_id === 'dispatch-award-anchor')).toBe(true) + }) + + it('originator-permission: the trading agent may not originate a dispatch plan', () => { + const d = dispatchWorld() + const r = d.engine.check({ ...d.proposal, originator: { ...d.proposal.originator, agent: 'trading-agent' } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx()) + expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission') + }) +}) + +describe('power-balance simulation', () => { + it('passes a covered plan and alerts on shortfall or overload', () => { + const d = dispatchWorld() + const sim = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('30.000'), u2: Array(96).fill('30.000') })) + const ok = sim.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }) + expect(ok.alert).toBe(false) + const short = dispatchWorld({ alloc: ['30.000', '10.000'] }) + expect(sim.simulate(short.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/short/) + const over = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('20.000') })) + expect(over.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/beyond available/) + }) +}) + +describe('envelope: dispatch bounds and deviation-streak suspension', () => { + it('max_total_mw / max_unit_mw bound dispatch plans', () => { + const d = dispatchWorld() + const svc = new EnvelopeService() + svc.register(envelope({ max_total_mw: '50.0', max_unit_mw: '35.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } })) + expect(svc.match(d.proposal, { now: NOW, priceBaseline: null }).within).toBe(true) + const tight = new EnvelopeService() + tight.register(envelope({ max_unit_mw: '25.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } })) + expect(tight.match(d.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_unit_mw/) + }) + + it('N consecutive over-threshold executions suspend the envelope; a compliant day resets the streak', () => { + const svc = new EnvelopeService() + svc.register(envelope({ max_energy_mwh: '1500.0' })) // N=3, threshold 10% + expect(svc.recordExecution('env-bid-001', '12.0', NOW)).toEqual({ streak: 1, suspended: false }) + expect(svc.recordExecution('env-bid-001', '5.0', NOW)).toEqual({ streak: 0, suspended: false }) + svc.recordExecution('env-bid-001', '15.0', NOW) + svc.recordExecution('env-bid-001', '15.0', NOW) + expect(svc.recordExecution('env-bid-001', '15.0', NOW)).toEqual({ streak: 3, suspended: true }) + expect(svc.get('env-bid-001')?.status).toBe('SUSPENDED') + const w = world() + expect(svc.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false) + }) + + it('apply() is the only way bounds change, and it records who approved', () => { + const svc = new EnvelopeService() + svc.register(envelope({ price_deviation_pct: '5.0' }, { status: 'SUSPENDED' })) + const e = svc.apply({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.0' } }, ['user-ops-lead', 'user-risk-officer']) + expect(e.status).toBe('ACTIVE') + expect(e.bounds['price_deviation_pct']).toBe('6.0') + expect(e.approval.approved_by).toEqual(['user-ops-lead', 'user-risk-officer']) + }) +}) + +describe('simulation gateway (dispatch release channel)', () => { + it('accepts only permitted dispatch plans, idempotently, and can execute them with seeded fulfilment', () => { + const d = dispatchWorld() + const gw = new SimulationGateway(d.authority) + const permit = d.authority.authorize(d.proposal, { approvals: [approvalFor(d.proposal, 2, { scope: { effect_type: 'DISPATCH_PLAN', limits: {} } })], envelopeMatch: null }, { now: NOW, ledger: d.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000 }) + if (isStaleDenial(permit)) throw new Error(permit.reasons.join()) + const r1 = gw.dispatch(d.proposal, permit, NOW) + expect(gw.dispatch(d.proposal, permit, NOW)).toEqual(r1) + expect(r1.channel).toBe('SIMULATION_GATEWAY') + const w = world() + expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(GatewayRejected) + const reports = gw.execute(d.proposal.digest, { u1: 0.8 }, '2026-03-16T01:00:00Z') + expect(reports.map((r) => r.fulfillment_ratio)).toEqual(['0.800', '1.000']) + expect(reports[0]!.deviation_mwh).toBe((30 * 0.2 * 96 * 0.25).toFixed(3)) + }) +}) + +describe('review service: attribution and writebacks', () => { + const report = (unit: string, planned: string, actual: string): ExecutionReport => ({ + id: `exec-${unit}`, market_date: DATE, unit_id: unit, dispatch_proposal_digest: 'c'.repeat(64), planned_mw: curve(planned), actual_mw: curve(actual), + deviation_mwh: ((Number(planned) - Number(actual)) * 96 * 0.25).toFixed(3), fulfillment_ratio: (Number(actual) / Number(planned)).toFixed(3), reported_at: '2026-03-16T01:00:00Z', + }) + + function setup(cfg = { reliabilityAlpha: '0.2', widenAfterCompliantDays: 2, widenStepPct: '20' }) { + const resources = createResourceRegistry(() => NOW) + resources.put({ resource_id: 'res-a', name: 'a', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '30.0', confidence: '0.9', reliability_score: '0.900', constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW }) + const envelopes = new EnvelopeService() + envelopes.register(envelope({ price_deviation_pct: '5.0' })) + const svc = new ReviewService(resources, envelopes, undefined, cfg) + const d = dispatchWorld() + const input = (reports: ExecutionReport[], deviationPct: string) => ({ + market_date: DATE, ledger: d.ledger.read(), bidProposal: d.proposal.type === 'BID' ? d.proposal : world().proposal, dispatchProposal: d.proposal, executionReports: reports, metering: null, actualLoad: null, + forecasts: { load: null, price: null }, unitResources: { u1: ['res-a'], u2: [] }, envelopeUsage: [{ envelope_id: 'env-bid-001', deviation_pct: deviationPct }], now: '2026-03-16T03:00:00Z', idGen: (p: string) => `${p}-1`, + }) + return { resources, envelopes, svc, input } + } + + it('attributes deviation per timescale and writes back a measurably updated reliability score', () => { + const { resources, svc, input } = setup() + const [finding] = svc.review(input([report('u1', '30.000', '24.000')], '20.0')) as [ReviewFinding] + expect(finding.kind).toBe('RESPONSE') + expect(finding.attribution.trading_mwh).toBe('48.000') // bid 1200 vs award 1152 + expect(finding.attribution.response_mwh).toBe('144.000') + const wb = finding.writebacks.find((w) => w.target === 'RESOURCE_PROFILE') + expect(wb).toMatchObject({ resource_id: 'res-a', previous_reliability_score: '0.900', new_reliability_score: '0.880', fulfillment_ratio: '0.800' }) + expect(finding.writebacks.find((w) => w.target === 'SEMANTIC_MEMORY')?.target).toBe('SEMANTIC_MEMORY') + const applied = svc.apply(finding, '2026-03-16T03:00:00Z') + expect(resources.get('res-a')?.value.reliability_score).toBe('0.880') + expect(applied.memory).toHaveLength(1) + expect(svc.memory.list()).toHaveLength(1) + expect(applied.envelopeRequests).toHaveLength(0) // deviation above threshold → no widening + }) + + it('recommends widening after the configured run of compliant days, as a request (not applied)', () => { + const { svc, input, envelopes } = setup() + svc.review(input([report('u1', '30.000', '29.000')], '3.0')) + const [f2] = svc.review(input([report('u1', '30.000', '29.000')], '3.0')) as [ReviewFinding] + const rec = f2.writebacks.find((w) => w.target === 'ENVELOPE_RECOMMENDATION') + expect(rec).toMatchObject({ action: 'WIDEN', bound: 'price_deviation_pct', current_value: '5.0', recommended_value: '6.00' }) + const applied = svc.apply(f2, '2026-03-16T03:00:00Z') + expect(applied.envelopeRequests[0]).toMatchObject({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.00' } }) + expect(envelopes.get('env-bid-001')?.bounds['price_deviation_pct']).toBe('5.0') // untouched until a human approves + }) +}) diff --git a/skills-py/tests/test_potential_dispatch.py b/skills-py/tests/test_potential_dispatch.py new file mode 100644 index 0000000..b5bb8d1 --- /dev/null +++ b/skills-py/tests/test_potential_dispatch.py @@ -0,0 +1,96 @@ +"""Potential assessment + dispatch optimization skills (M4).""" + +from __future__ import annotations + +from decimal import Decimal + +import numpy as np +from fastapi.testclient import TestClient + +from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest +from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest +from vpp_skills.app import app +from vpp_skills.dispatch_opt import optimize_dispatch +from vpp_skills.potential import assess_potential + +from .conftest import REF_A, curve_of + +DATE = "2026-03-15" + + +def _profile(rid: str, certified: str, reliability: str = "0.9", confidence: str = "0.9") -> dict: + return { + "resource_id": rid, "name": rid, "type": "STORAGE", "rated_power_mw": "30.0", "certified_adjustable_mw": certified, + "confidence": confidence, "reliability_score": reliability, + "constraints": {"min_duration_min": 60, "recovery_rate_mw_per_min": "0.5"}, "evidence_refs": [], "updated_at": "2026-03-14T06:00:00Z", + } + + +def _potential(resources: list[dict]) -> PotentialAssessmentRequest: + return PotentialAssessmentRequest.model_validate({"market_date": DATE, "resources": resources, "features_snapshot_ref": REF_A}) + + +def test_potential_derates_by_fulfillment_and_sums(): + req = _potential( + [ + {"profile": _profile("res-a", "20.0"), "fulfillment": [{"market_date": "2026-03-13", "planned_mwh": "100.0", "delivered_mwh": "80.0"}] * 3}, + {"profile": _profile("res-b", "10.0"), "fulfillment": []}, + ] + ) + res = assess_potential(req) + a, b = res.assessments + assert a.fulfillment_rate == "0.8000" and a.evidence_days == 3 + assert a.adjustable_mw.values[0].root == "16.000" # 20 × 0.8 + assert b.fulfillment_rate is None and b.evidence_days == 0 + assert b.adjustable_mw.values[0].root == "10.000" # no history → certified, lower confidence + assert Decimal(b.confidence) < Decimal(a.confidence) + assert res.total_adjustable_mw.values[0].root == "26.000" + + +def test_potential_never_exceeds_certified(): + req = _potential([{"profile": _profile("res-a", "20.0"), "fulfillment": [{"market_date": "2026-03-13", "planned_mwh": "10.0", "delivered_mwh": "15.0"}]}]) + res = assess_potential(req) + assert res.assessments[0].fulfillment_rate == "1.0000" + assert res.assessments[0].adjustable_mw.values[0].root == "20.000" + + +def _dispatch(target: np.ndarray, units: list[tuple[str, np.ndarray, str]]) -> DispatchOptimizationRequest: + q = lambda arr: [f"{float(v):.3f}" for v in arr] # noqa: E731 + return DispatchOptimizationRequest.model_validate( + {"market_date": DATE, "target_mw": curve_of(q(target), DATE), "units": [{"unit_id": u, "available_mw": curve_of(q(a), DATE), "cost_weight": w} for u, a, w in units]} + ) + + +def test_dispatch_fills_cheapest_unit_first_and_balances(): + target = np.full(96, 48.0) + res = optimize_dispatch(_dispatch(target, [("u1", np.full(96, 30.0), "1.0"), ("u2", np.full(96, 30.0), "1.2")])) + assert res.solver.status.value == "OPTIMAL" + by = {a.unit_id: [float(v.root) for v in a.target_mw.values] for a in res.allocations} + assert by["u1"][0] == 30.0 and by["u2"][0] == 18.0 + assert res.shortfall_mwh == "0.000" + for t in range(96): + assert abs(by["u1"][t] + by["u2"][t] - 48.0) < 1e-6 + + +def test_dispatch_reports_shortfall_instead_of_hiding_it(): + target = np.full(96, 100.0) + res = optimize_dispatch(_dispatch(target, [("u1", np.full(96, 30.0), "1.0")])) + assert res.solver.status.value == "OPTIMAL" + assert Decimal(res.shortfall_mwh) == Decimal("70") * 96 * Decimal("0.25") + + +def test_dispatch_rejects_bad_input(): + import pytest + + with pytest.raises(ValueError, match="duplicate"): + optimize_dispatch(_dispatch(np.full(96, 1.0), [("u1", np.full(96, 1.0), "1"), ("u1", np.full(96, 1.0), "1")])) + + +def test_http_routes(): + client = TestClient(app) + ids = {s["id"] for s in client.get("/v1/skills").json()} + assert {"potential-assessment", "dispatch-optimization"} <= ids + r = client.post("/v1/optimize/dispatch", json=_dispatch(np.full(96, 5.0), [("u1", np.full(96, 10.0), "1")]).model_dump(mode="json")) + assert r.status_code == 200, r.text + r = client.post("/v1/assess/potential", json=_potential([{"profile": _profile("r", "1.0"), "fulfillment": []}]).model_dump(mode="json")) + assert r.status_code == 200, r.text diff --git a/skills-py/vpp_contracts/award_notice.py b/skills-py/vpp_contracts/award_notice.py new file mode 100644 index 0000000..e1fc0eb --- /dev/null +++ b/skills-py/vpp_contracts/award_notice.py @@ -0,0 +1,42 @@ +# generated by datamodel-codegen: +# filename: award_notice.json + +from __future__ import annotations + +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class AwardedMwh(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class ClearingPriceYuanPerMwh(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class AwardNotice(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + bid_proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + awarded_mwh: AwardedMwh + clearing_price_yuan_per_mwh: ClearingPriceYuanPerMwh + received_at: AwareDatetime diff --git a/skills-py/vpp_contracts/dispatch_optimization_request.py b/skills-py/vpp_contracts/dispatch_optimization_request.py new file mode 100644 index 0000000..2349f9f --- /dev/null +++ b/skills-py/vpp_contracts/dispatch_optimization_request.py @@ -0,0 +1,48 @@ +# generated by datamodel-codegen: +# filename: dispatch_optimization_request.json + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class TargetMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class AvailableMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class Unit(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + unit_id: constr(min_length=1) + available_mw: AvailableMw + cost_weight: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class DispatchOptimizationRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + target_mw: TargetMw + units: list[Unit] = Field(..., min_length=1) diff --git a/skills-py/vpp_contracts/dispatch_optimization_result.py b/skills-py/vpp_contracts/dispatch_optimization_result.py new file mode 100644 index 0000000..f11846a --- /dev/null +++ b/skills-py/vpp_contracts/dispatch_optimization_result.py @@ -0,0 +1,67 @@ +# generated by datamodel-codegen: +# filename: dispatch_optimization_result.json + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, RootModel, conint, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class TotalTargetMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class TargetMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class Allocation(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + unit_id: constr(min_length=1) + target_mw: TargetMw + + +class Status(StrEnum): + OPTIMAL = 'OPTIMAL' + INFEASIBLE = 'INFEASIBLE' + ERROR = 'ERROR' + + +class Solver(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: constr(min_length=1) + version: constr(min_length=1) + status: Status + wall_time_ms: conint(ge=0, le=9007199254740991) + + +class DispatchOptimizationResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + total_target_mw: TotalTargetMw + allocations: list[Allocation] = Field(..., min_length=1) + shortfall_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + solver: Solver + skill_version: constr(pattern=r'^\d+\.\d+\.\d+$') diff --git a/skills-py/vpp_contracts/dispatch_proposal_draft.py b/skills-py/vpp_contracts/dispatch_proposal_draft.py new file mode 100644 index 0000000..202f005 --- /dev/null +++ b/skills-py/vpp_contracts/dispatch_proposal_draft.py @@ -0,0 +1,32 @@ +# generated by datamodel-codegen: +# filename: dispatch_proposal_draft.json + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, constr + + +class TotalTargetRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class AllocationsRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class DispatchProposalDraft(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + total_target_ref: TotalTargetRef + allocations_ref: AllocationsRef + rationale: constr(min_length=1) diff --git a/skills-py/vpp_contracts/envelope_change_request.py b/skills-py/vpp_contracts/envelope_change_request.py new file mode 100644 index 0000000..30dfd3e --- /dev/null +++ b/skills-py/vpp_contracts/envelope_change_request.py @@ -0,0 +1,28 @@ +# generated by datamodel-codegen: +# filename: envelope_change_request.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class Action(StrEnum): + WIDEN = 'WIDEN' + NARROW = 'NARROW' + SUSPEND = 'SUSPEND' + REACTIVATE = 'REACTIVATE' + + +class EnvelopeChangeRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + envelope_id: constr(min_length=1) + action: Action + bounds: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')] + basis: constr(min_length=1) + source_finding_id: constr(min_length=1) | None + requested_at: AwareDatetime diff --git a/skills-py/vpp_contracts/execution_report.py b/skills-py/vpp_contracts/execution_report.py new file mode 100644 index 0000000..fdf31dc --- /dev/null +++ b/skills-py/vpp_contracts/execution_report.py @@ -0,0 +1,45 @@ +# generated by datamodel-codegen: +# filename: execution_report.json + +from __future__ import annotations + +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class PlannedMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class ActualMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class ExecutionReport(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + unit_id: constr(min_length=1) + dispatch_proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + planned_mw: PlannedMw + actual_mw: ActualMw + deviation_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + fulfillment_ratio: constr(pattern=r'^-?\d+(\.\d+)?$') + reported_at: AwareDatetime diff --git a/skills-py/vpp_contracts/insight_card.py b/skills-py/vpp_contracts/insight_card.py new file mode 100644 index 0000000..bd6c929 --- /dev/null +++ b/skills-py/vpp_contracts/insight_card.py @@ -0,0 +1,62 @@ +# generated by datamodel-codegen: +# filename: insight_card.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class Page(StrEnum): + TRADING_DESK = 'TRADING_DESK' + RESOURCE_POOL = 'RESOURCE_POOL' + OPERATIONS_DASHBOARD = 'OPERATIONS_DASHBOARD' + + +class Ref(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class Metric(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: constr(min_length=1) + value: constr(pattern=r'^-?\d+(\.\d+)?$') + unit: constr(min_length=1) + ref: Ref | None + + +class Kind(StrEnum): + SITUATION_REPORT = 'SITUATION_REPORT' + REVIEW_FINDING = 'REVIEW_FINDING' + DECISION_CASE = 'DECISION_CASE' + POTENTIAL_ASSESSMENT = 'POTENTIAL_ASSESSMENT' + + +class Source(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + kind: Kind + id: constr(min_length=1) + ref: constr(pattern=r'^[0-9a-f]{64}$') | None + + +class InsightCard(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + page: Page + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + title: constr(min_length=1) + headline: constr(min_length=1) + metrics: list[Metric] + source: Source + generated_at: AwareDatetime diff --git a/skills-py/vpp_contracts/metering_record.py b/skills-py/vpp_contracts/metering_record.py new file mode 100644 index 0000000..746fc55 --- /dev/null +++ b/skills-py/vpp_contracts/metering_record.py @@ -0,0 +1,41 @@ +# generated by datamodel-codegen: +# filename: metering_record.json + +from __future__ import annotations + +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class MeteredMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class ActualPriceYuanPerMwh(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class MeteringRecord(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + metered_mw: MeteredMw + actual_price_yuan_per_mwh: ActualPriceYuanPerMwh + received_at: AwareDatetime diff --git a/skills-py/vpp_contracts/potential_assessment_request.py b/skills-py/vpp_contracts/potential_assessment_request.py new file mode 100644 index 0000000..4e477c9 --- /dev/null +++ b/skills-py/vpp_contracts/potential_assessment_request.py @@ -0,0 +1,68 @@ +# generated by datamodel-codegen: +# filename: potential_assessment_request.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, conint, constr + + +class Type(StrEnum): + STORAGE = 'STORAGE' + PV = 'PV' + EV_CHARGER = 'EV_CHARGER' + HVAC = 'HVAC' + INDUSTRIAL_LOAD = 'INDUSTRIAL_LOAD' + MICROGRID = 'MICROGRID' + BASE_STATION = 'BASE_STATION' + + +class Constraints(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + min_duration_min: conint(le=9007199254740991, gt=0) | None + recovery_rate_mw_per_min: constr(pattern=r'^-?\d+(\.\d+)?$') | None + + +class Profile(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + resource_id: constr(min_length=1) + name: constr(min_length=1) + type: Type + rated_power_mw: constr(pattern=r'^-?\d+(\.\d+)?$') + certified_adjustable_mw: constr(pattern=r'^-?\d+(\.\d+)?$') + confidence: constr(pattern=r'^-?\d+(\.\d+)?$') + reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$') + constraints: Constraints + evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')] + updated_at: AwareDatetime + + +class FulfillmentItem(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + planned_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + delivered_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class Resource(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + profile: Profile + fulfillment: list[FulfillmentItem] + + +class PotentialAssessmentRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + resources: list[Resource] = Field(..., min_length=1) + features_snapshot_ref: constr(pattern=r'^[0-9a-f]{64}$') diff --git a/skills-py/vpp_contracts/potential_assessment_result.py b/skills-py/vpp_contracts/potential_assessment_result.py new file mode 100644 index 0000000..eb3833d --- /dev/null +++ b/skills-py/vpp_contracts/potential_assessment_result.py @@ -0,0 +1,51 @@ +# generated by datamodel-codegen: +# filename: potential_assessment_result.json + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, RootModel, conint, constr + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class AdjustableMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class Assessment(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + resource_id: constr(min_length=1) + adjustable_mw: AdjustableMw + confidence: constr(pattern=r'^-?\d+(\.\d+)?$') + fulfillment_rate: constr(pattern=r'^-?\d+(\.\d+)?$') | None + evidence_days: conint(ge=0, le=9007199254740991) + + +class TotalAdjustableMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class PotentialAssessmentResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + assessments: list[Assessment] + total_adjustable_mw: TotalAdjustableMw + skill_version: constr(pattern=r'^\d+\.\d+\.\d+$') diff --git a/skills-py/vpp_contracts/proposal.py b/skills-py/vpp_contracts/proposal.py index b7d104e..0c57bff 100644 --- a/skills-py/vpp_contracts/proposal.py +++ b/skills-py/vpp_contracts/proposal.py @@ -128,5 +128,77 @@ class Proposal1(BaseModel): payload: Payload -class Proposal(RootModel[Proposal1]): - root: Proposal1 = Field(..., title='Proposal') +class Originator1(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + agent: constr(min_length=1) + trigger: Trigger + task_id: constr(min_length=1) + + +class Lineage1(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_calls: list[ToolCall] + data_refs: list[constr(pattern=r'^[0-9a-f]{64}$')] + ledger_version: conint(ge=0, le=9007199254740991) + policy_pack_version: constr(min_length=1) + + +class TotalTargetMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class TargetMw(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class Allocation(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + unit_id: constr(min_length=1) + target_mw: TargetMw + + +class Payload1(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + kind: Literal['DISPATCH_PLAN'] + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + award_ref: constr(min_length=1) + total_target_mw: TotalTargetMw + allocations: list[Allocation] = Field(..., min_length=1) + + +class Proposal2(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + timescale: Timescale + originator: Originator1 + lineage: Lineage1 + envelope_ref: constr(min_length=1) | None + digest: constr(pattern=r'^[0-9a-f]{64}$') + status: Status + created_at: AwareDatetime + type: Literal['DISPATCH_PLAN'] + payload: Payload1 + + +class Proposal(RootModel[Proposal1 | Proposal2]): + root: Proposal1 | Proposal2 = Field(..., title='Proposal') diff --git a/skills-py/vpp_contracts/review_finding.py b/skills-py/vpp_contracts/review_finding.py new file mode 100644 index 0000000..25870a8 --- /dev/null +++ b/skills-py/vpp_contracts/review_finding.py @@ -0,0 +1,91 @@ +# generated by datamodel-codegen: +# filename: review_finding.json + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class Kind(StrEnum): + FORECAST = 'FORECAST' + RESPONSE = 'RESPONSE' + TRADING = 'TRADING' + CONTROL = 'CONTROL' + + +class Attribution(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + trading_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + control_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + response_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + forecast_load_mape: constr(pattern=r'^-?\d+(\.\d+)?$') | None + forecast_price_mape: constr(pattern=r'^-?\d+(\.\d+)?$') | None + net_deviation_mwh: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class Writebacks(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + target: Literal['SEMANTIC_MEMORY'] + topic: constr(min_length=1) + lesson: constr(min_length=1) + + +class Writebacks1(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + target: Literal['RESOURCE_PROFILE'] + resource_id: constr(min_length=1) + previous_reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$') + new_reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$') + fulfillment_ratio: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class Action(StrEnum): + WIDEN = 'WIDEN' + NARROW = 'NARROW' + SUSPEND = 'SUSPEND' + KEEP = 'KEEP' + + +class Writebacks2(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + target: Literal['ENVELOPE_RECOMMENDATION'] + envelope_id: constr(min_length=1) + action: Action + bound: constr(min_length=1) + current_value: constr(pattern=r'^-?\d+(\.\d+)?$') + recommended_value: constr(pattern=r'^-?\d+(\.\d+)?$') + basis: constr(min_length=1) + + +class LineageRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class ReviewFinding(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + kind: Kind + summary: constr(min_length=1) + attribution: Attribution + writebacks: list[Writebacks | Writebacks1 | Writebacks2] + evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')] + lineage_refs: list[LineageRef] + generated_at: AwareDatetime diff --git a/skills-py/vpp_contracts/semantic_memory_entry.py b/skills-py/vpp_contracts/semantic_memory_entry.py new file mode 100644 index 0000000..f2cef15 --- /dev/null +++ b/skills-py/vpp_contracts/semantic_memory_entry.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: semantic_memory_entry.json + +from __future__ import annotations + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class SemanticMemoryEntry(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + topic: constr(min_length=1) + lesson: constr(min_length=1) + source_finding_id: constr(min_length=1) + evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')] + created_at: AwareDatetime diff --git a/skills-py/vpp_skills/__init__.py b/skills-py/vpp_skills/__init__.py index 05c9b7d..8e30558 100644 --- a/skills-py/vpp_skills/__init__.py +++ b/skills-py/vpp_skills/__init__.py @@ -12,4 +12,6 @@ SKILL_VERSIONS: dict[str, str] = { "price-forecast": "1.0.0", "bid-optimization-milp": "1.0.0", "report-generator": "1.0.0", + "potential-assessment": "1.0.0", + "dispatch-optimization": "1.0.0", } diff --git a/skills-py/vpp_skills/app.py b/skills-py/vpp_skills/app.py index 0e04af8..e98e0f0 100644 --- a/skills-py/vpp_skills/app.py +++ b/skills-py/vpp_skills/app.py @@ -13,6 +13,10 @@ from fastapi.responses import JSONResponse from vpp_contracts.bid_optimization_request import BidOptimizationRequest from vpp_contracts.bid_optimization_result import BidOptimizationResult +from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest +from vpp_contracts.dispatch_optimization_result import DispatchOptimizationResult +from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest +from vpp_contracts.potential_assessment_result import PotentialAssessmentResult from vpp_contracts.forecast_bundle import ForecastBundle from vpp_contracts.forecast_request import ForecastRequest from vpp_contracts.report_request import ReportRequest @@ -20,6 +24,8 @@ from vpp_contracts.skill_report import SkillReport from . import SKILL_VERSIONS from .bid_milp import optimize_bid +from .dispatch_opt import optimize_dispatch +from .potential import assess_potential from .forecast import forecast from .report import generate_report @@ -31,6 +37,8 @@ ROUTES = { "price-forecast": "/v1/forecast/price", "bid-optimization-milp": "/v1/optimize/bid", "report-generator": "/v1/report", + "potential-assessment": "/v1/assess/potential", + "dispatch-optimization": "/v1/optimize/dispatch", } @@ -79,3 +87,13 @@ def optimize(req: BidOptimizationRequest) -> BidOptimizationResult: @app.post(ROUTES["report-generator"], response_model=SkillReport) def report(req: ReportRequest) -> SkillReport: return generate_report(req) + + +@app.post(ROUTES["potential-assessment"], response_model=PotentialAssessmentResult) +def potential(req: PotentialAssessmentRequest) -> PotentialAssessmentResult: + return assess_potential(req) + + +@app.post(ROUTES["dispatch-optimization"], response_model=DispatchOptimizationResult) +def dispatch(req: DispatchOptimizationRequest) -> DispatchOptimizationResult: + return optimize_dispatch(req) diff --git a/skills-py/vpp_skills/dispatch_opt.py b/skills-py/vpp_skills/dispatch_opt.py new file mode 100644 index 0000000..7f61948 --- /dev/null +++ b/skills-py/vpp_skills/dispatch_opt.py @@ -0,0 +1,88 @@ +"""Aggregation/dispatch optimization skill v1 (docs/05 §2.2 聚合/调度优化). + +Decompose a province-level target curve to aggregation units (docs/04 §1: +never below unit granularity). Per interval, an LP: + + minimise Σ_u w_u · x_{u,t} + M · s_t + s.t. Σ_u x_{u,t} + s_t = target_t (balance, s_t = shortfall) + 0 ≤ x_{u,t} ≤ available_{u,t} + +Intervals are independent in v1 (no ramp/energy coupling); the whole day is +one sparse LP solved by HiGHS. Shortfall is reported, never hidden — a +non-zero shortfall is what the power-balance simulation escalates on. +""" + +from __future__ import annotations + +import time +from decimal import Decimal + +import numpy as np +import scipy +from scipy.optimize import Bounds, LinearConstraint, milp +from scipy.sparse import csr_matrix, hstack, identity, kron + +from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest +from vpp_contracts.dispatch_optimization_result import DispatchOptimizationResult + +from . import SKILL_VERSIONS +from .numeric import INTERVALS, SCALE_MW, SCALE_MWH, curve, quantize, to_array + +SKILL = "dispatch-optimization" +SHORTFALL_PENALTY = 1e6 + + +def optimize_dispatch(req: DispatchOptimizationRequest) -> DispatchOptimizationResult: + if req.target_mw.date != req.market_date or any(u.available_mw.date != req.market_date for u in req.units): + raise ValueError("all curves must be for market_date") + ids = [u.unit_id for u in req.units] + if len(set(ids)) != len(ids): + raise ValueError("duplicate unit_id") + + target = to_array(v.root for v in req.target_mw.values) + avail = np.stack([to_array(v.root for v in u.available_mw.values) for u in req.units]) # (U, T) + weights = np.array([float(Decimal(u.cost_weight)) for u in req.units]) + if np.any(target < 0) or np.any(avail < 0) or np.any(weights < 0): + raise ValueError("target, availability and cost weights must be non-negative") + + n_u, n_t = avail.shape + # Variables: x (U·T, unit-major) then s (T). + c = np.concatenate([np.repeat(weights, n_t), np.full(n_t, SHORTFALL_PENALTY)]) + ub = np.concatenate([avail.ravel(), target]) + bounds = Bounds(np.zeros(n_u * n_t + n_t), ub) + # Balance: for each t, Σ_u x_{u,t} + s_t = target_t + a_x = kron(csr_matrix(np.ones((1, n_u))), identity(n_t, format="csr")) + a = hstack([a_x, identity(n_t, format="csr")]) + constraints = [LinearConstraint(a, target, target)] + + t0 = time.perf_counter() + res = milp(c, constraints=constraints, bounds=bounds) + wall_ms = int(round((time.perf_counter() - t0) * 1000)) + status = {0: "OPTIMAL", 2: "INFEASIBLE", 3: "INFEASIBLE"}.get(int(res.status), "ERROR") + + if res.x is None: + x = np.zeros((n_u, n_t)) + shortfall = target.copy() + else: + x = np.clip(res.x[: n_u * n_t].reshape(n_u, n_t), 0.0, None) + shortfall = np.clip(target - x.sum(axis=0), 0.0, None) + + allocations = [{"unit_id": uid, "target_mw": curve(x[i], req.market_date, SCALE_MW)} for i, uid in enumerate(ids)] + # Recompute shortfall from the *quantized* allocations so the headline is consistent with the curves. + alloc_sum = np.zeros(n_t) + for a_ in allocations: + alloc_sum += to_array(a_["target_mw"]["values"]) + short_mwh = float(np.clip(target - alloc_sum, 0.0, None).sum() * 0.25) + if short_mwh < 1e-3: + short_mwh = 0.0 + + return DispatchOptimizationResult.model_validate( + { + "market_date": req.market_date, + "total_target_mw": curve([v.root for v in req.target_mw.values], req.market_date, SCALE_MW), + "allocations": allocations, + "shortfall_mwh": quantize(short_mwh, SCALE_MWH), + "solver": {"name": "highs", "version": f"scipy-{scipy.__version__}", "status": status, "wall_time_ms": wall_ms}, + "skill_version": SKILL_VERSIONS[SKILL], + } + ) diff --git a/skills-py/vpp_skills/potential.py b/skills-py/vpp_skills/potential.py new file mode 100644 index 0000000..e986176 --- /dev/null +++ b/skills-py/vpp_skills/potential.py @@ -0,0 +1,73 @@ +"""Adjustable-potential assessment skill v1 (docs/05 §2.2 可调潜力辨识). + +For each resource: adjustable capacity = certified capacity × rolling +fulfillment rate (delivered / planned over the evidence window), with +confidence from the profile's own confidence, the reliability score and the +amount of evidence. A resource with no execution history keeps its certified +figure at the profile's confidence — the "画像时效性" risk (docs/13 §0) is +made visible through `evidence_days` rather than hidden. + +The de-rating exponent and evidence saturation are placeholders: OPEN-QUESTION +B10 (可靠性/潜力辨识口径). The target accuracy (≥ 90%) is judged by the eval +harness on real execution feedback (docs/12 L2). +""" + +from __future__ import annotations + +from decimal import Decimal + +import numpy as np + +from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest +from vpp_contracts.potential_assessment_result import PotentialAssessmentResult + +from . import SKILL_VERSIONS +from .numeric import INTERVALS, SCALE_MW, curve, quantize + +SKILL = "potential-assessment" + +EVIDENCE_WINDOW_DAYS = 14 # OPEN-QUESTION B10 +EVIDENCE_SATURATION_DAYS = 7 # OPEN-QUESTION B10: confidence saturates after this many days + + +def assess_potential(req: PotentialAssessmentRequest) -> PotentialAssessmentResult: + assessments = [] + total = np.zeros(INTERVALS) + for r in req.resources: + profile = r.profile + certified = float(Decimal(profile.certified_adjustable_mw)) + recent = sorted(r.fulfillment, key=lambda f: f.market_date)[-EVIDENCE_WINDOW_DAYS:] + planned = sum(Decimal(f.planned_mwh) for f in recent) + delivered = sum(Decimal(f.delivered_mwh) for f in recent) + if planned > 0: + rate = min(Decimal(1), delivered / planned) + evidence_days = len(recent) + else: + rate = None + evidence_days = 0 + + derate = float(rate) if rate is not None else 1.0 + adjustable = np.full(INTERVALS, certified * derate) + evidence_factor = min(1.0, evidence_days / EVIDENCE_SATURATION_DAYS) + base_conf = float(Decimal(profile.confidence)) + reliability = float(Decimal(profile.reliability_score)) + confidence = base_conf * (0.5 + 0.5 * evidence_factor) * (0.5 + 0.5 * reliability) + + total += adjustable + assessments.append( + { + "resource_id": profile.resource_id, + "adjustable_mw": curve(adjustable, req.market_date, SCALE_MW), + "confidence": quantize(confidence, 3), + "fulfillment_rate": quantize(rate, 4) if rate is not None else None, + "evidence_days": evidence_days, + } + ) + return PotentialAssessmentResult.model_validate( + { + "market_date": req.market_date, + "assessments": assessments, + "total_adjustable_mw": curve(total, req.market_date, SCALE_MW), + "skill_version": SKILL_VERSIONS[SKILL], + } + )