M3: Mastra runtime, safety chain, two agents, Case Desk v1
- packages/domain: safety-chain objects (ValidationResult, SimulationResult, EnvelopeMatch, StaleDenial, ExecutionReceipt, LineageRef/BidProposalDraft, RouterDecision, BidExportFile) + fixtures on both sides. - packages/services: proposal digest; PolicyEngine + hubei-spot-bidding pack (digest-valid, bid-format, price-limits, quantity-non-negative, ledger-consistency, lineage-integrity, originator-permission — each with pass/fail tests); EnvelopeService; AuthorityService (fresh check, permits, revoke, gateway validate); FileExportGateway (idempotent receipts); Memory/File EventBus; LineageRecorder + P2 assembler; RevenueScenario simulator; CaseDeskService; FsRepository; skill HTTP client moved here. - packages/runtime: createRuntime (LibSQL storage, per-runtime workflow factories), proposal-lifecycle (rule check → simulation → envelope gate with suspend/resume → fresh check + permit → release), day-ahead-situation, day-ahead-bid, TriggerService (scheduled/event/manual), LlmPort (Mastra/Scripted/Null), Case Desk HTTP API, dev entry point. - Tests: all eight docs/01 invariants, docs/07 06:00→08:30 end to end with LLM down, restart survival of a suspended approval, permit expiry and revocation, replay of a released proposal, trigger scheduling. 141 TS + 60 Python tests. - Known gaps: ledger not yet persisted (replayed on restart); STALE ends the run instead of looping to rule check; synthetic data stands in for historical replay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
This commit is contained in:
parent
8796faca63
commit
1cc21e0dc6
18
CLAUDE.md
18
CLAUDE.md
@ -6,13 +6,17 @@ docs win — or write an ADR changing the doc first.
|
||||
|
||||
## Current phase
|
||||
|
||||
Docs complete; M1 and M2 implemented (schemas + contracts pipeline; ledger,
|
||||
snapshot, time-series, relational, ingestion services; Python skill service
|
||||
with forecast/MILP/report skills; L2 eval harness with committed baseline).
|
||||
Storage is in-memory reference semantics — persistent adapters arrive with M3.
|
||||
The eval dataset is synthetic until historical data lands. Next is M3. 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`).
|
||||
Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
|
||||
services (ledger, stores, policy engine, envelope, authority/permits, gateway,
|
||||
events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval
|
||||
harness with committed baseline (synthetic data); Mastra runtime with the
|
||||
proposal-lifecycle safety chain (durable suspend/resume), situation and bid
|
||||
workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage
|
||||
is file-backed reference semantics — Postgres/Timescale adapters later. Next is
|
||||
M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
|
||||
must update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`).
|
||||
Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never
|
||||
module singletons, or a second Mastra instance steals their context.
|
||||
Do not start a milestone's work before its predecessor's acceptance criteria are
|
||||
testable, and do not build phase-2 items (edge control links, federation,
|
||||
interaction/load-control agents) unless explicitly asked.
|
||||
|
||||
33
README.md
33
README.md
@ -6,16 +6,28 @@ 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 and M2 implemented.** Design docs 00–13 are complete;
|
||||
implementation follows [ROADMAP.md](ROADMAP.md). Present today: domain schemas
|
||||
(`packages/domain`), the TS↔Python contract pipeline (`contracts/`,
|
||||
`skills-py/vpp_contracts`), ledger/snapshot/time-series/relational/ingestion
|
||||
services (`packages/services`), the Python skill service — load/PV/price
|
||||
forecasts with calibrated quantiles, bid-optimization MILP, report generator
|
||||
(`skills-py/vpp_skills`) — and the L2 eval harness with a committed baseline
|
||||
(`packages/evals`). The eval dataset is **synthetic** (no historical Hubei data
|
||||
yet); baselines on it measure the harness, not the KPI. M3 (runtime, agents,
|
||||
safety chain) is next.
|
||||
**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation
|
||||
follows [ROADMAP.md](ROADMAP.md). Present today:
|
||||
|
||||
- `packages/domain` — zod schemas for every business and safety-chain object, exported to
|
||||
`contracts/` and regenerated as pydantic models (`skills-py/vpp_contracts`).
|
||||
- `packages/services` — deterministic services: ledger (with the P7 cascade), snapshot,
|
||||
time-series, relational and ingestion stores; policy engine + `hubei-spot-bidding` pack;
|
||||
envelope gate; authority (fresh check, permits, revocation); file-export gateway;
|
||||
event store/bus; lineage recorder + P2 assembler; revenue-scenario simulation; Case Desk.
|
||||
- `skills-py/vpp_skills` — Python skill service: load/PV/price forecasts with calibrated
|
||||
quantiles, bid-optimization MILP, report generator.
|
||||
- `packages/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset).
|
||||
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
|
||||
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
|
||||
trigger service (scheduled / event / manual via router agent); provider-abstracted LLM
|
||||
port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual
|
||||
upload (degraded channel by design).
|
||||
|
||||
All eight docs/01 invariants have automated tests (`packages/services/test/chain.test.ts`,
|
||||
`packages/runtime/test/lifecycle.test.ts`). Storage is file-backed reference semantics;
|
||||
Postgres/Timescale adapters and the programmatic trading-platform channel are later work.
|
||||
M4 (resource agent, envelopes live, review loop) is next.
|
||||
|
||||
## Development
|
||||
|
||||
@ -26,6 +38,7 @@ pydantic models use `StrEnum` and PEP 604 unions).
|
||||
npm ci
|
||||
npm run check # typecheck, re-export contracts, run TS tests
|
||||
npm run eval -w @vpp/evals -- --check # L2 harness vs baseline (needs the skill service below)
|
||||
npm run start -w @vpp/runtime # runtime + Case Desk API on :4100 (VPP_LLM_MODEL unset = LLM-down mode)
|
||||
|
||||
cd skills-py
|
||||
uv venv --python 3.11 .venv && uv pip install -r requirements.txt # or python3.11 -m venv
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "prices_yuan_per_mwh"
|
||||
},
|
||||
"quantities_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "quantities_mwh"
|
||||
},
|
||||
"expected_revenue_ref": "510600.00",
|
||||
"rationale": "x"
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"workflow_id": "transfer-funds",
|
||||
"params": {
|
||||
"market_date": "2026-03-15"
|
||||
},
|
||||
"confidence": "HIGH"
|
||||
}
|
||||
213
contracts/fixtures/bid_export_file/basic.json
Normal file
213
contracts/fixtures/bid_export_file/basic.json
Normal file
@ -0,0 +1,213 @@
|
||||
{
|
||||
"format_version": "1.0.0",
|
||||
"proposal_id": "prop-001",
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"permit_id": "permit-001",
|
||||
"proposal_type": "BID",
|
||||
"market_date": "2026-03-15",
|
||||
"prices_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
"0.00"
|
||||
]
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5",
|
||||
"12.5"
|
||||
]
|
||||
},
|
||||
"exported_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
16
contracts/fixtures/bid_proposal_draft/basic.json
Normal file
16
contracts/fixtures/bid_proposal_draft/basic.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"prices_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "prices_yuan_per_mwh"
|
||||
},
|
||||
"quantities_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "quantities_mwh"
|
||||
},
|
||||
"expected_revenue_ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "expected_revenue_yuan"
|
||||
},
|
||||
"rationale": "Evening peak priced above P50; allocate bounded energy to 18:00–20:00."
|
||||
}
|
||||
10
contracts/fixtures/envelope_match/outside.json
Normal file
10
contracts/fixtures/envelope_match/outside.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"within": false,
|
||||
"envelope_id": null,
|
||||
"required_level": "L2",
|
||||
"reasons": [
|
||||
"no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
8
contracts/fixtures/envelope_match/within.json
Normal file
8
contracts/fixtures/envelope_match/within.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"within": true,
|
||||
"envelope_id": "env-bid-001",
|
||||
"required_level": "L1",
|
||||
"reasons": [],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
9
contracts/fixtures/execution_receipt/file-export.json
Normal file
9
contracts/fixtures/execution_receipt/file-export.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"receipt_id": "rcpt-001",
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"permit_id": "permit-001",
|
||||
"channel": "FILE_EXPORT",
|
||||
"idempotency_key": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:permit-001",
|
||||
"artifact_ref": "exports/bid-2026-03-15-prop-001.json",
|
||||
"accepted_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
7
contracts/fixtures/router_decision/day-ahead-bid.json
Normal file
7
contracts/fixtures/router_decision/day-ahead-bid.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"workflow_id": "day-ahead-bid",
|
||||
"params": {
|
||||
"market_date": "2026-03-15"
|
||||
},
|
||||
"confidence": "HIGH"
|
||||
}
|
||||
18
contracts/fixtures/simulation_result/revenue-ok.json
Normal file
18
contracts/fixtures/simulation_result/revenue-ok.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"kind": "REVENUE_SCENARIOS",
|
||||
"alert": false,
|
||||
"alerts": [],
|
||||
"metrics": {
|
||||
"revenue_p05_yuan": "410200.00",
|
||||
"revenue_p50_yuan": "510600.00",
|
||||
"worst_loss_yuan": "0.00"
|
||||
},
|
||||
"scenario_count": 1000,
|
||||
"simulator": {
|
||||
"name": "revenue-scenarios",
|
||||
"version": "1.0.0",
|
||||
"seed": 20260315
|
||||
},
|
||||
"simulated_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
7
contracts/fixtures/stale_denial/ledger-moved.json
Normal file
7
contracts/fixtures/stale_denial/ledger-moved.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"reasons": [
|
||||
"ledger version 42 at approval, now 43"
|
||||
],
|
||||
"checked_at": "2026-03-15T23:59:59Z"
|
||||
}
|
||||
16
contracts/fixtures/validation_result/pass.json
Normal file
16
contracts/fixtures/validation_result/pass.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"policy_pack": {
|
||||
"id": "hubei-spot-bidding",
|
||||
"version": "2026.03"
|
||||
},
|
||||
"ok": true,
|
||||
"violations": [],
|
||||
"rules_evaluated": [
|
||||
"bid-format",
|
||||
"price-limits",
|
||||
"ledger-consistency",
|
||||
"lineage-integrity"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
20
contracts/fixtures/validation_result/reject-price.json
Normal file
20
contracts/fixtures/validation_result/reject-price.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"policy_pack": {
|
||||
"id": "hubei-spot-bidding",
|
||||
"version": "2026.03"
|
||||
},
|
||||
"ok": false,
|
||||
"violations": [
|
||||
{
|
||||
"rule_id": "price-limits",
|
||||
"severity": "REJECT",
|
||||
"message": "price 1500.00 above cap"
|
||||
}
|
||||
],
|
||||
"rules_evaluated": [
|
||||
"bid-format",
|
||||
"price-limits"
|
||||
],
|
||||
"checked_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
110
contracts/schema/bid_export_file.json
Normal file
110
contracts/schema/bid_export_file.json
Normal file
@ -0,0 +1,110 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"type": "string",
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"proposal_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"permit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"BID",
|
||||
"INVITATION",
|
||||
"CONTROL_PLAN",
|
||||
"DISPATCH_PLAN"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"prices_yuan_per_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"quantities_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"format_version",
|
||||
"proposal_id",
|
||||
"proposal_digest",
|
||||
"permit_id",
|
||||
"proposal_type",
|
||||
"market_date",
|
||||
"prices_yuan_per_mwh",
|
||||
"quantities_mwh",
|
||||
"exported_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_export_file.json",
|
||||
"title": "BidExportFile"
|
||||
}
|
||||
78
contracts/schema/bid_proposal_draft.json
Normal file
78
contracts/schema/bid_proposal_draft.json
Normal file
@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"prices_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"quantities_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"expected_revenue_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"prices_ref",
|
||||
"quantities_ref",
|
||||
"expected_revenue_ref",
|
||||
"rationale"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/bid_proposal_draft.json",
|
||||
"title": "BidProposalDraft"
|
||||
}
|
||||
54
contracts/schema/envelope_match.json
Normal file
54
contracts/schema/envelope_match.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"within": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"envelope_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required_level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"L1",
|
||||
"L2",
|
||||
"L3"
|
||||
]
|
||||
},
|
||||
"reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"within",
|
||||
"envelope_id",
|
||||
"required_level",
|
||||
"reasons",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/envelope_match.json",
|
||||
"title": "EnvelopeMatch"
|
||||
}
|
||||
51
contracts/schema/execution_receipt.json
Normal file
51
contracts/schema/execution_receipt.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"receipt_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"permit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FILE_EXPORT",
|
||||
"TRADING_PLATFORM_API",
|
||||
"SIMULATION_GATEWAY"
|
||||
]
|
||||
},
|
||||
"idempotency_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"artifact_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"accepted_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"receipt_id",
|
||||
"proposal_digest",
|
||||
"permit_id",
|
||||
"channel",
|
||||
"idempotency_key",
|
||||
"artifact_ref",
|
||||
"accepted_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/execution_receipt.json",
|
||||
"title": "ExecutionReceipt"
|
||||
}
|
||||
43
contracts/schema/router_decision.json
Normal file
43
contracts/schema/router_decision.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"day-ahead-situation",
|
||||
"day-ahead-bid",
|
||||
"adhoc-analysis"
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"HIGH",
|
||||
"MEDIUM",
|
||||
"LOW"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workflow_id",
|
||||
"params",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/router_decision.json",
|
||||
"title": "RouterDecision"
|
||||
}
|
||||
83
contracts/schema/simulation_result.json
Normal file
83
contracts/schema/simulation_result.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REVENUE_SCENARIOS",
|
||||
"POWER_BALANCE"
|
||||
]
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"alerts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"scenario_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"simulator": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version",
|
||||
"seed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"simulated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"kind",
|
||||
"alert",
|
||||
"alerts",
|
||||
"metrics",
|
||||
"scenario_count",
|
||||
"simulator",
|
||||
"simulated_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/simulation_result.json",
|
||||
"title": "SimulationResult"
|
||||
}
|
||||
31
contracts/schema/stale_denial.json
Normal file
31
contracts/schema/stale_denial.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"reasons": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"reasons",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/stale_denial.json",
|
||||
"title": "StaleDenial"
|
||||
}
|
||||
83
contracts/schema/validation_result.json
Normal file
83
contracts/schema/validation_result.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"policy_pack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"violations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REJECT",
|
||||
"WARN"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rule_id",
|
||||
"severity",
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"rules_evaluated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"proposal_digest",
|
||||
"policy_pack",
|
||||
"ok",
|
||||
"violations",
|
||||
"rules_evaluated",
|
||||
"checked_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/validation_result.json",
|
||||
"title": "ValidationResult"
|
||||
}
|
||||
2410
package-lock.json
generated
2410
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -248,6 +248,101 @@ const valid: Record<string, Record<string, unknown>> = {
|
||||
generated_at: T1,
|
||||
},
|
||||
},
|
||||
validation_result: {
|
||||
pass: {
|
||||
proposal_digest: REF_B,
|
||||
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
|
||||
ok: true,
|
||||
violations: [],
|
||||
rules_evaluated: ['bid-format', 'price-limits', 'ledger-consistency', 'lineage-integrity'],
|
||||
checked_at: T1,
|
||||
},
|
||||
'reject-price': {
|
||||
proposal_digest: REF_B,
|
||||
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
|
||||
ok: false,
|
||||
violations: [{ rule_id: 'price-limits', severity: 'REJECT', message: 'price 1500.00 above cap' }],
|
||||
rules_evaluated: ['bid-format', 'price-limits'],
|
||||
checked_at: T1,
|
||||
},
|
||||
},
|
||||
simulation_result: {
|
||||
'revenue-ok': {
|
||||
proposal_digest: REF_B,
|
||||
kind: 'REVENUE_SCENARIOS',
|
||||
alert: false,
|
||||
alerts: [],
|
||||
metrics: { revenue_p05_yuan: '410200.00', revenue_p50_yuan: '510600.00', worst_loss_yuan: '0.00' },
|
||||
scenario_count: 1000,
|
||||
simulator: { name: 'revenue-scenarios', version: '1.0.0', seed: 20260315 },
|
||||
simulated_at: T1,
|
||||
},
|
||||
},
|
||||
envelope_match: {
|
||||
within: {
|
||||
proposal_digest: REF_B,
|
||||
within: true,
|
||||
envelope_id: 'env-bid-001',
|
||||
required_level: 'L1',
|
||||
reasons: [],
|
||||
checked_at: T1,
|
||||
},
|
||||
outside: {
|
||||
proposal_digest: REF_B,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: 'L2',
|
||||
reasons: ['no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01'],
|
||||
checked_at: T1,
|
||||
},
|
||||
},
|
||||
stale_denial: {
|
||||
'ledger-moved': {
|
||||
proposal_digest: REF_B,
|
||||
reasons: ['ledger version 42 at approval, now 43'],
|
||||
checked_at: T2,
|
||||
},
|
||||
},
|
||||
execution_receipt: {
|
||||
'file-export': {
|
||||
receipt_id: 'rcpt-001',
|
||||
proposal_digest: REF_B,
|
||||
permit_id: 'permit-001',
|
||||
channel: 'FILE_EXPORT',
|
||||
idempotency_key: `${REF_B}:permit-001`,
|
||||
artifact_ref: 'exports/bid-2026-03-15-prop-001.json',
|
||||
accepted_at: T1,
|
||||
},
|
||||
},
|
||||
bid_proposal_draft: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: 'tc-001', path: 'expected_revenue_yuan' },
|
||||
rationale: 'Evening peak priced above P50; allocate bounded energy to 18:00–20:00.',
|
||||
},
|
||||
},
|
||||
router_decision: {
|
||||
'day-ahead-bid': {
|
||||
workflow_id: 'day-ahead-bid',
|
||||
params: { market_date: DATE },
|
||||
confidence: 'HIGH',
|
||||
},
|
||||
},
|
||||
bid_export_file: {
|
||||
basic: {
|
||||
format_version: '1.0.0',
|
||||
proposal_id: 'prop-001',
|
||||
proposal_digest: REF_B,
|
||||
permit_id: 'permit-001',
|
||||
proposal_type: 'BID',
|
||||
market_date: DATE,
|
||||
prices_yuan_per_mwh: curve('0.00'),
|
||||
quantities_mwh: curve('12.5'),
|
||||
exported_at: T1,
|
||||
},
|
||||
},
|
||||
situation_report: {
|
||||
'normal-day': {
|
||||
id: 'sit-001',
|
||||
@ -333,6 +428,20 @@ const invalid: Record<string, Record<string, unknown>> = {
|
||||
return o
|
||||
})(),
|
||||
},
|
||||
bid_proposal_draft: {
|
||||
// a literal number where only a lineage reference is allowed (I1/P2)
|
||||
'literal-number': {
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
|
||||
expected_revenue_ref: '510600.00',
|
||||
rationale: 'x',
|
||||
},
|
||||
},
|
||||
router_decision: {
|
||||
// a workflow id that is not a registered template
|
||||
'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' },
|
||||
},
|
||||
position_update: {
|
||||
// missing optimistic-concurrency field
|
||||
'missing-version': (() => {
|
||||
|
||||
@ -3,6 +3,7 @@ import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
|
||||
import { ProposalType } from './proposal.js'
|
||||
|
||||
export const ApprovalLevel = z.enum(['L1', 'L2', 'L3'])
|
||||
export type ApprovalLevel = z.infer<typeof ApprovalLevel>
|
||||
|
||||
/** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */
|
||||
export const Approval = z.object({
|
||||
|
||||
@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { Id, IsoUtc } from './common.js'
|
||||
|
||||
export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS'])
|
||||
export type CaseKind = z.infer<typeof CaseKind>
|
||||
|
||||
/**
|
||||
* Operator's unit of accountability (docs/02 §5, ADR-0008): one objective,
|
||||
|
||||
119
packages/domain/src/chain.ts
Normal file
119
packages/domain/src/chain.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { z } from 'zod'
|
||||
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
import { ApprovalLevel } from './approval.js'
|
||||
import { ProposalType } from './proposal.js'
|
||||
|
||||
/**
|
||||
* Safety-chain objects (docs/03, docs/11 §2): the outputs of rule check,
|
||||
* simulation, envelope gate and gateway. All are snapshotted into lineage so a
|
||||
* decision can be replayed (I7).
|
||||
*/
|
||||
|
||||
export const PolicyPackRef = z.object({
|
||||
id: Id,
|
||||
version: z.string().min(1),
|
||||
})
|
||||
export type PolicyPackRef = z.infer<typeof PolicyPackRef>
|
||||
|
||||
export const RuleViolation = z.object({
|
||||
rule_id: Id,
|
||||
severity: z.enum(['REJECT', 'WARN']),
|
||||
message: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ValidationResult = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
policy_pack: PolicyPackRef,
|
||||
ok: z.boolean(),
|
||||
violations: z.array(RuleViolation),
|
||||
rules_evaluated: z.array(Id),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type ValidationResult = z.infer<typeof ValidationResult>
|
||||
|
||||
/** Revenue scenario backtest (bids) or power-balance sim (control) — docs/03 §2. */
|
||||
export const SimulationResult = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
kind: z.enum(['REVENUE_SCENARIOS', 'POWER_BALANCE']),
|
||||
/** Any alert escalates to a human — one vote, no exceptions (docs/03 §2). */
|
||||
alert: z.boolean(),
|
||||
alerts: z.array(z.string()),
|
||||
metrics: z.record(z.string(), DecimalString),
|
||||
scenario_count: z.int().nonnegative(),
|
||||
simulator: z.object({ name: z.string().min(1), version: z.string().min(1), seed: z.int() }),
|
||||
simulated_at: IsoUtc,
|
||||
})
|
||||
export type SimulationResult = z.infer<typeof SimulationResult>
|
||||
|
||||
export const EnvelopeMatch = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
within: z.boolean(),
|
||||
envelope_id: Id.nullable(),
|
||||
required_level: ApprovalLevel,
|
||||
reasons: z.array(z.string()),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type EnvelopeMatch = z.infer<typeof EnvelopeMatch>
|
||||
|
||||
export const StaleDenial = z.object({
|
||||
proposal_digest: SnapshotRef,
|
||||
reasons: z.array(z.string().min(1)).min(1),
|
||||
checked_at: IsoUtc,
|
||||
})
|
||||
export type StaleDenial = z.infer<typeof StaleDenial>
|
||||
|
||||
/** Idempotent effect receipt (I5). Same (digest, permit) → same receipt. */
|
||||
export const ExecutionReceipt = z.object({
|
||||
receipt_id: Id,
|
||||
proposal_digest: SnapshotRef,
|
||||
permit_id: Id,
|
||||
channel: z.enum(['FILE_EXPORT', 'TRADING_PLATFORM_API', 'SIMULATION_GATEWAY']),
|
||||
idempotency_key: z.string().min(1),
|
||||
artifact_ref: z.string().min(1),
|
||||
accepted_at: IsoUtc,
|
||||
})
|
||||
export type ExecutionReceipt = z.infer<typeof ExecutionReceipt>
|
||||
|
||||
/** Reference into recorded tool-call lineage (I1/P2): the only way a number enters a payload. */
|
||||
export const LineageRef = z.object({
|
||||
tool_call_id: Id,
|
||||
path: z.string().min(1),
|
||||
})
|
||||
export type LineageRef = z.infer<typeof LineageRef>
|
||||
|
||||
/**
|
||||
* What the trading agent's LLM step is allowed to emit (docs/09 §3): refs and
|
||||
* prose, never numbers. The assembler dereferences refs into a BidPayload.
|
||||
*/
|
||||
export const BidProposalDraft = z.object({
|
||||
market_date: MarketDate,
|
||||
prices_ref: LineageRef,
|
||||
quantities_ref: LineageRef,
|
||||
expected_revenue_ref: LineageRef,
|
||||
rationale: z.string().min(1),
|
||||
})
|
||||
export type BidProposalDraft = z.infer<typeof BidProposalDraft>
|
||||
|
||||
/** Router agent output (docs/09 §1): choose a registered template, never invent one. */
|
||||
export const WorkflowTemplateId = z.enum(['day-ahead-situation', 'day-ahead-bid', 'adhoc-analysis'])
|
||||
|
||||
export const RouterDecision = z.object({
|
||||
workflow_id: WorkflowTemplateId,
|
||||
params: z.object({ market_date: MarketDate }),
|
||||
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
|
||||
})
|
||||
export type RouterDecision = z.infer<typeof RouterDecision>
|
||||
|
||||
/** Bid file handed to the manual-upload channel (docs/06 degraded channel; M3 release path). */
|
||||
export const BidExportFile = z.object({
|
||||
format_version: z.literal('1.0.0'),
|
||||
proposal_id: Id,
|
||||
proposal_digest: SnapshotRef,
|
||||
permit_id: Id,
|
||||
proposal_type: ProposalType,
|
||||
market_date: MarketDate,
|
||||
prices_yuan_per_mwh: Curve96,
|
||||
quantities_mwh: Curve96,
|
||||
exported_at: IsoUtc,
|
||||
})
|
||||
export type BidExportFile = z.infer<typeof BidExportFile>
|
||||
@ -15,6 +15,16 @@ import {
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from './skill.js'
|
||||
import {
|
||||
BidExportFile,
|
||||
BidProposalDraft,
|
||||
EnvelopeMatch,
|
||||
ExecutionReceipt,
|
||||
RouterDecision,
|
||||
SimulationResult,
|
||||
StaleDenial,
|
||||
ValidationResult,
|
||||
} from './chain.js'
|
||||
|
||||
export * from './common.js'
|
||||
export * from './proposal.js'
|
||||
@ -27,6 +37,7 @@ export * from './resource.js'
|
||||
export * from './case.js'
|
||||
export * from './event.js'
|
||||
export * from './skill.js'
|
||||
export * from './chain.js'
|
||||
|
||||
/**
|
||||
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
||||
@ -50,4 +61,12 @@ export const schemaRegistry: Record<string, z.ZodType> = {
|
||||
bid_optimization_result: BidOptimizationResult,
|
||||
report_request: ReportRequest,
|
||||
skill_report: SkillReport,
|
||||
validation_result: ValidationResult,
|
||||
simulation_result: SimulationResult,
|
||||
envelope_match: EnvelopeMatch,
|
||||
stale_denial: StaleDenial,
|
||||
execution_receipt: ExecutionReceipt,
|
||||
bid_proposal_draft: BidProposalDraft,
|
||||
router_decision: RouterDecision,
|
||||
bid_export_file: BidExportFile,
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ export const ProposalStatus = z.enum([
|
||||
'ROLLED_BACK',
|
||||
'REJECTED',
|
||||
])
|
||||
export type ProposalStatus = z.infer<typeof ProposalStatus>
|
||||
|
||||
export const ToolCallRef = z.object({
|
||||
tool_call_id: Id,
|
||||
@ -32,6 +33,7 @@ export const ToolCallRef = z.object({
|
||||
inputs_ref: SnapshotRef,
|
||||
outputs_ref: SnapshotRef,
|
||||
})
|
||||
export type ToolCallRef = z.infer<typeof ToolCallRef>
|
||||
|
||||
/**
|
||||
* Provenance attached to every proposal (docs/03 §1). Rule check enforces that
|
||||
@ -43,6 +45,7 @@ export const Lineage = z.object({
|
||||
ledger_version: z.int().nonnegative(),
|
||||
policy_pack_version: z.string().min(1),
|
||||
})
|
||||
export type Lineage = z.infer<typeof Lineage>
|
||||
|
||||
/** Day-ahead bid: per-interval price/quantity pairs. */
|
||||
export const BidPayload = z.object({
|
||||
@ -52,6 +55,7 @@ export const BidPayload = z.object({
|
||||
quantities_mwh: Curve96,
|
||||
expected_revenue_yuan: DecimalString,
|
||||
})
|
||||
export type BidPayload = z.infer<typeof BidPayload>
|
||||
|
||||
const ProposalBase = z.object({
|
||||
id: Id,
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { HttpSkillClient } from './client.js'
|
||||
import { HttpSkillClient } from '@vpp/services'
|
||||
import { loadDataset } from './dataset.js'
|
||||
import { DEFAULT_CONFIG, runL2, stableJson } from './harness.js'
|
||||
import type { EvalRun } from './harness.js'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain'
|
||||
import { Decimal, LedgerService } from '@vpp/services'
|
||||
import type { SkillClient } from './client.js'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import type { Dataset, DatasetDay } from './dataset.js'
|
||||
import { nums, toCurve } from './dataset.js'
|
||||
import {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from './metrics.js'
|
||||
export * from './dataset.js'
|
||||
export * from './client.js'
|
||||
export { HttpSkillClient, SkillHttpError } from '@vpp/services'
|
||||
export type { SkillClient } from '@vpp/services'
|
||||
export * from './harness.js'
|
||||
|
||||
@ -9,7 +9,7 @@ import type {
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from '@vpp/domain'
|
||||
import type { SkillClient } from '../src/client.js'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import { loadDataset } from '../src/dataset.js'
|
||||
import { DEFAULT_CONFIG, runL2 } from '../src/harness.js'
|
||||
|
||||
|
||||
27
packages/runtime/package.json
Normal file
27
packages/runtime/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@vpp/runtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"start": "tsx src/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mastra/core": "^1.63.2",
|
||||
"@mastra/libsql": "^1.22.2",
|
||||
"@vpp/domain": "*",
|
||||
"@vpp/services": "*",
|
||||
"zod": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
68
packages/runtime/src/api.ts
Normal file
68
packages/runtime/src/api.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { z } from 'zod'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import type { TriggerService } from './trigger.js'
|
||||
import { ResumeDecision } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Case Desk v1 HTTP API (docs/02 §5, docs/11 §1.1 CaseDeskPort). Inbox, case
|
||||
* view, lineage expansion, and the approve/reject endpoint that resumes the
|
||||
* lifecycle run. Identity comes from the existing permission system
|
||||
* (docs/06 §4) — here as trusted headers behind that system's gateway:
|
||||
* x-user-id / x-user-role. The approver identity is what I1/I2 checks.
|
||||
*/
|
||||
const DecideBody = z.object({ decision: z.enum(['approve', 'reject']), comment: z.string().optional() })
|
||||
const TaskBody = z.object({ message: z.string().min(1) })
|
||||
|
||||
const json = (res: ServerResponse, status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const readBody = (req: IncomingMessage): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
req.on('data', (c) => (data += c))
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {})
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export function createApi(rt: Runtime, triggers: TriggerService): Server {
|
||||
return createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
const user = { id: req.headers['x-user-id'], role: req.headers['x-user-role'] }
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { status: 'ok', llm: rt.ctx.llm.backend })
|
||||
if (req.method === 'GET' && url.pathname === '/inbox') return json(res, 200, rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== ''))
|
||||
if (req.method === 'GET' && url.pathname === '/cases') return json(res, 200, rt.ctx.caseDesk.cases.list().map((r) => r.value))
|
||||
if (req.method === 'GET' && parts[0] === 'cases' && parts[1] && parts.length === 2) return json(res, 200, rt.ctx.caseDesk.read(parts[1]))
|
||||
if (req.method === 'GET' && parts[0] === 'proposals' && parts[1] && parts[2] === 'lineage') return json(res, 200, rt.ctx.caseDesk.expandLineage(parts[1]))
|
||||
if (req.method === 'GET' && url.pathname === '/events') return json(res, 200, rt.ctx.events.list(url.searchParams.get('correlation_id') ? { correlation_id: url.searchParams.get('correlation_id')! } : {}))
|
||||
|
||||
if (req.method === 'POST' && parts[0] === 'approvals' && parts[1] && parts[2] === 'decide') {
|
||||
if (typeof user.id !== 'string' || typeof user.role !== 'string') return json(res, 401, { error: 'x-user-id and x-user-role required' })
|
||||
const body = DecideBody.parse(await readBody(req))
|
||||
const decision = ResumeDecision.parse({ ...body, approver: { id: user.id, role: user.role } })
|
||||
const result = await triggers.decide(parts[1], decision)
|
||||
return json(res, 200, { run_id: parts[1], status: result.status, outcome: result.status === 'success' ? result.result.outcome : null })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/tasks') {
|
||||
const body = TaskBody.parse(await readBody(req))
|
||||
return json(res, 200, await triggers.manual(body.message))
|
||||
}
|
||||
return json(res, 404, { error: 'not found' })
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) return json(res, 400, { error: e.issues })
|
||||
if (e instanceof LlmUnavailable) return json(res, 503, { error: e.message })
|
||||
return json(res, 500, { error: (e as Error).message })
|
||||
}
|
||||
})
|
||||
}
|
||||
61
packages/runtime/src/context.ts
Normal file
61
packages/runtime/src/context.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type {
|
||||
AuthorityService,
|
||||
CaseDeskService,
|
||||
EnvelopeService,
|
||||
EventBus,
|
||||
FileExportGateway,
|
||||
LedgerService,
|
||||
LineageRecorder,
|
||||
PolicyEngine,
|
||||
ResourceRegistry,
|
||||
SimulationPort,
|
||||
SkillClient,
|
||||
SnapshotStore,
|
||||
TimeSeriesStore,
|
||||
} from '@vpp/services'
|
||||
import type { LlmPort } from './llm.js'
|
||||
|
||||
/**
|
||||
* Everything a workflow step may touch. Deterministic services are plain
|
||||
* objects imported by steps (docs/09 §7: never exposed to the LLM); the LLM
|
||||
* port is the one non-deterministic dependency and is isolated behind
|
||||
* `llm`. Workflows are created per runtime and close over their context, so
|
||||
* two runtimes can coexist in one process — which is what the restart test does.
|
||||
*/
|
||||
export interface RuntimeConfig {
|
||||
policyPackId: string
|
||||
/** OPEN-QUESTION B3: which roles may approve; placeholder list. */
|
||||
approverRoles: string[]
|
||||
/** OPEN-QUESTION A1: bid permit lifetime — until the market deadline. Placeholder. */
|
||||
bidPermitTtlMs: number
|
||||
/** OPEN-QUESTION B6 / A6 / A4 placeholders passed to the optimizer. */
|
||||
risk: { risk_aversion: string; commitment_buffer_k: string; min_block_mwh: string; marginal_cost_yuan_per_mwh: string }
|
||||
/** OPEN-QUESTION B5: worst-case loss budget for the simulation alert. Placeholder. */
|
||||
worstCaseLossBudgetYuan: string
|
||||
/** History window (days) handed to forecast skills. */
|
||||
forecastWindowDays: number
|
||||
/** OPEN-QUESTION B7: price-spike ratio (p90/p50) above which the day is EXTREME. Placeholder. */
|
||||
extremeDayPriceRatio: string
|
||||
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
|
||||
bidDeadlineLocal: string
|
||||
}
|
||||
|
||||
export interface RuntimeContext {
|
||||
config: RuntimeConfig
|
||||
clock: () => string
|
||||
newId: (prefix: string) => string
|
||||
snapshots: SnapshotStore
|
||||
timeseries: TimeSeriesStore
|
||||
resources: ResourceRegistry
|
||||
ledger: LedgerService
|
||||
events: EventBus
|
||||
policy: PolicyEngine
|
||||
envelopes: EnvelopeService
|
||||
authority: AuthorityService
|
||||
gateway: FileExportGateway
|
||||
simulation: SimulationPort
|
||||
caseDesk: CaseDeskService
|
||||
skills: SkillClient
|
||||
lineage: LineageRecorder
|
||||
llm: LlmPort
|
||||
}
|
||||
9
packages/runtime/src/index.ts
Normal file
9
packages/runtime/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from './llm.js'
|
||||
export * from './skills.js'
|
||||
export * from './context.js'
|
||||
export * from './runtime.js'
|
||||
export * from './trigger.js'
|
||||
export * from './api.js'
|
||||
export * from './workflows/proposal-lifecycle.js'
|
||||
export * from './workflows/day-ahead-situation.js'
|
||||
export * from './workflows/day-ahead-bid.js'
|
||||
109
packages/runtime/src/llm.ts
Normal file
109
packages/runtime/src/llm.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Agent } from '@mastra/core/agent'
|
||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||
import type { z } from 'zod'
|
||||
|
||||
/**
|
||||
* LLM provider abstraction (P6, ADR-0003). Agent code programs against
|
||||
* `LlmPort.structured()` — "give me an object of this schema" — never a vendor
|
||||
* API. Backends: a Mastra Agent over a router model id (dev: commercial API;
|
||||
* prod: the GuangMing OpenAI-compatible adapter), a scripted provider for
|
||||
* tests, and the null provider that models an LLM outage. Every caller must
|
||||
* survive `LlmUnavailable` (I6) — the deterministic fallback is part of the
|
||||
* step, not an afterthought.
|
||||
*/
|
||||
export class LlmUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`LLM unavailable: ${reason}`)
|
||||
this.name = 'LlmUnavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LlmPort {
|
||||
readonly backend: string
|
||||
structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T>
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string
|
||||
}
|
||||
|
||||
/** Real backend: one Mastra Agent per spec, all on the same routed model. */
|
||||
export class MastraLlm implements LlmPort {
|
||||
readonly backend: string
|
||||
private readonly agents = new Map<string, Agent>()
|
||||
|
||||
constructor(model: MastraModelConfig, specs: AgentSpec[], backend = 'mastra') {
|
||||
this.backend = backend
|
||||
for (const s of specs) this.agents.set(s.id, new Agent({ id: s.id, name: s.name, instructions: s.instructions, model }))
|
||||
}
|
||||
|
||||
mastraAgents(): Record<string, Agent> {
|
||||
return Object.fromEntries(this.agents)
|
||||
}
|
||||
|
||||
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
|
||||
const agent = this.agents.get(agentId)
|
||||
if (!agent) throw new Error(`agent ${agentId} not registered`)
|
||||
let res: { object?: unknown }
|
||||
try {
|
||||
res = await agent.generate(prompt, { structuredOutput: { schema: schema as never } })
|
||||
} catch (e) {
|
||||
throw new LlmUnavailable((e as Error).message)
|
||||
}
|
||||
const parsed = schema.safeParse(res.object)
|
||||
if (!parsed.success) throw new LlmUnavailable(`structured output failed schema: ${parsed.error.message}`)
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM outage (or simply "no model configured"): every call fails fast. */
|
||||
export class NullLlm implements LlmPort {
|
||||
readonly backend = 'null'
|
||||
async structured<T>(): Promise<T> {
|
||||
throw new LlmUnavailable('no LLM backend configured')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test backend: canned answers keyed by agent id, optionally a function of the prompt. */
|
||||
export class ScriptedLlm implements LlmPort {
|
||||
readonly backend = 'scripted'
|
||||
readonly prompts: Array<{ agentId: string; prompt: string }> = []
|
||||
constructor(private readonly answers: Record<string, unknown | ((prompt: string) => unknown)>) {}
|
||||
|
||||
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
|
||||
this.prompts.push({ agentId, prompt })
|
||||
const a = this.answers[agentId]
|
||||
if (a === undefined) throw new LlmUnavailable(`no scripted answer for ${agentId}`)
|
||||
const raw = typeof a === 'function' ? (a as (p: string) => unknown)(prompt) : a
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (!parsed.success) throw new LlmUnavailable(`scripted output failed schema: ${parsed.error.message}`)
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */
|
||||
export const AGENT_SPECS: AgentSpec[] = [
|
||||
{
|
||||
id: 'router-agent',
|
||||
name: 'Router',
|
||||
instructions:
|
||||
'You route an operator request to exactly one registered workflow template and extract its parameters. ' +
|
||||
'You never invent workflows. If the request is ambiguous, choose adhoc-analysis with LOW confidence.',
|
||||
},
|
||||
{
|
||||
id: 'analysis-agent',
|
||||
name: 'Analysis',
|
||||
instructions:
|
||||
'You are the situational-awareness analyst for a virtual power plant. Given forecast bundles (already computed by tools), ' +
|
||||
'write short findings. Never state a number that is not in the provided tool outputs; cite snapshot refs.',
|
||||
},
|
||||
{
|
||||
id: 'trading-agent',
|
||||
name: 'Trading',
|
||||
instructions:
|
||||
'You draft day-ahead bid proposals. All numbers come from the bid-optimization tool output you are given; ' +
|
||||
'you reference them by {tool_call_id, path} and explain the strategy in one paragraph for the approver.',
|
||||
},
|
||||
]
|
||||
24
packages/runtime/src/main.ts
Normal file
24
packages/runtime/src/main.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Dev/shadow entry point: `npm run start -w @vpp/runtime`.
|
||||
* VPP_DATA_DIR durable state (default ./data)
|
||||
* VPP_SKILLS_URL Python skill service (default http://127.0.0.1:8000)
|
||||
* VPP_LLM_MODEL Mastra router model id, e.g. openai/gpt-... ; unset = LLM down (I6 mode)
|
||||
* VPP_API_PORT Case Desk API port (default 4100)
|
||||
* Scheduled triggers (D-1 06:00 situation, 08:00 bid, Asia/Shanghai) run in-process.
|
||||
*/
|
||||
import { createApi } from './api.js'
|
||||
import { createRuntime } from './runtime.js'
|
||||
import { TriggerService } from './trigger.js'
|
||||
|
||||
const rt = await createRuntime({
|
||||
dataDir: process.env['VPP_DATA_DIR'] ?? './data',
|
||||
skills: process.env['VPP_SKILLS_URL'] ?? 'http://127.0.0.1:8000',
|
||||
llm: process.env['VPP_LLM_MODEL'] ? (process.env['VPP_LLM_MODEL'] as `${string}/${string}`) : null,
|
||||
})
|
||||
const triggers = new TriggerService(rt)
|
||||
triggers.startEventConsumers()
|
||||
triggers.startScheduler()
|
||||
const port = Number(process.env['VPP_API_PORT'] ?? 4100)
|
||||
createApi(rt, triggers).listen(port, () => {
|
||||
console.log(`vpp runtime: api on :${port}, llm=${rt.ctx.llm.backend}, data=${process.env['VPP_DATA_DIR'] ?? './data'}`)
|
||||
})
|
||||
133
packages/runtime/src/runtime.ts
Normal file
133
packages/runtime/src/runtime.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { join } from 'node:path'
|
||||
import { Mastra } from '@mastra/core'
|
||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||
import { LibSQLStore } from '@mastra/libsql'
|
||||
import { Approval, DecisionCase, Envelope, ExecutionPermit, ExecutionReceipt, Proposal, ResourceProfile } from '@vpp/domain'
|
||||
import {
|
||||
AuthorityService,
|
||||
CaseDeskService,
|
||||
EnvelopeService,
|
||||
FileEventBus,
|
||||
FileExportGateway,
|
||||
FsRepository,
|
||||
FsSnapshotStore,
|
||||
HUBEI_BID_PACK_PLACEHOLDER,
|
||||
HttpSkillClient,
|
||||
LedgerService,
|
||||
LineageRecorder,
|
||||
MemoryTimeSeriesStore,
|
||||
PendingApprovalSchema,
|
||||
PolicyEngine,
|
||||
RevenueScenarioSimulator,
|
||||
hubeiSpotBiddingPack,
|
||||
} from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import type { RuntimeConfig, RuntimeContext } from './context.js'
|
||||
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
|
||||
import type { LlmPort } from './llm.js'
|
||||
import { createDayAheadBid } from './workflows/day-ahead-bid.js'
|
||||
import { createDayAheadSituation } from './workflows/day-ahead-situation.js'
|
||||
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Runtime = thin shell over Mastra + the self-built services (docs/09 §0).
|
||||
* `createRuntime` wires storage, workflows and agents; everything durable
|
||||
* lives under `dataDir` so a second `createRuntime` on the same directory
|
||||
* *is* a process restart for the purposes of the resumability tests.
|
||||
*/
|
||||
export interface RuntimeOptions {
|
||||
dataDir: string
|
||||
/** Router model id (e.g. 'openai/gpt-...'), an OpenAI-compatible config, or an LlmPort for tests; null = LLM down. */
|
||||
llm: MastraModelConfig | LlmPort | null
|
||||
skills: SkillClient | string
|
||||
clock?: () => string
|
||||
config?: Partial<RuntimeConfig>
|
||||
/** Optional in-memory overrides for tests (time series is in-memory in phase 1 regardless). */
|
||||
timeseries?: RuntimeContext['timeseries']
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
|
||||
policyPackId: 'hubei-spot-bidding',
|
||||
approverRoles: ['senior-trader', 'ops-lead', 'risk-officer'], // OPEN-QUESTION B3
|
||||
bidPermitTtlMs: 6 * 3_600_000, // OPEN-QUESTION A1
|
||||
risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '0' }, // B6 / A6 / A4
|
||||
worstCaseLossBudgetYuan: '100000', // OPEN-QUESTION B5
|
||||
forecastWindowDays: 28,
|
||||
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
|
||||
bidDeadlineLocal: '09:00', // OPEN-QUESTION A1
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
mastra: Mastra
|
||||
ctx: RuntimeContext
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const isLlmPort = (x: unknown): x is LlmPort => !!x && typeof x === 'object' && 'structured' in (x as object) && 'backend' in (x as object)
|
||||
|
||||
export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
||||
const config = { ...DEFAULT_RUNTIME_CONFIG, ...opts.config }
|
||||
const clock = opts.clock ?? (() => new Date().toISOString())
|
||||
let seq = 0
|
||||
const newId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${(++seq).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const dir = (name: string) => join(opts.dataDir, name)
|
||||
|
||||
const snapshots = new FsSnapshotStore(dir('snapshots'))
|
||||
const events = new FileEventBus(dir('events.jsonl'), clock)
|
||||
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock }) // OPEN-QUESTION A5
|
||||
const authority = new AuthorityService(new FsRepository(dir('permits.json'), ExecutionPermit, (p) => p.id, clock), () => newId('permit'))
|
||||
const policy = new PolicyEngine([hubeiSpotBiddingPack('2026.03', HUBEI_BID_PACK_PLACEHOLDER)])
|
||||
const envelopes = new EnvelopeService(new FsRepository(dir('envelopes.json'), Envelope, (e) => e.id, clock))
|
||||
const caseDesk = new CaseDeskService({
|
||||
cases: new FsRepository(dir('cases.json'), DecisionCase, (c) => c.id, clock),
|
||||
proposals: new FsRepository(dir('proposals.json'), Proposal, (p) => p.id, clock),
|
||||
approvals: new FsRepository(dir('approvals.json'), Approval, (a) => a.id, clock),
|
||||
pending: new FsRepository(dir('pending.json'), PendingApprovalSchema, (p) => p.proposal_id, clock),
|
||||
authority,
|
||||
snapshots,
|
||||
events,
|
||||
clock,
|
||||
})
|
||||
const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock))
|
||||
|
||||
let llm: LlmPort
|
||||
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
||||
if (opts.llm === null) llm = new NullLlm()
|
||||
else if (isLlmPort(opts.llm)) llm = opts.llm
|
||||
else {
|
||||
const m = new MastraLlm(opts.llm, AGENT_SPECS)
|
||||
llm = m
|
||||
agents = m.mastraAgents()
|
||||
}
|
||||
|
||||
const ctx: RuntimeContext = {
|
||||
config,
|
||||
clock,
|
||||
newId,
|
||||
snapshots,
|
||||
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
|
||||
resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock),
|
||||
ledger,
|
||||
events,
|
||||
policy,
|
||||
envelopes,
|
||||
authority,
|
||||
gateway,
|
||||
simulation: new RevenueScenarioSimulator(),
|
||||
caseDesk,
|
||||
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
|
||||
lineage: new LineageRecorder(),
|
||||
llm,
|
||||
}
|
||||
|
||||
const storage = new LibSQLStore({ id: 'vpp-runtime', url: `file:${dir('mastra.db')}` })
|
||||
// Workflows are built per runtime so their steps close over *this* context;
|
||||
// module-level singletons would bind to whichever Mastra registered last.
|
||||
const proposalLifecycle = createProposalLifecycle(ctx)
|
||||
const mastra = new Mastra({
|
||||
storage,
|
||||
workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) },
|
||||
agents,
|
||||
})
|
||||
return { mastra, ctx, close: () => storage.close() }
|
||||
}
|
||||
33
packages/runtime/src/skills.ts
Normal file
33
packages/runtime/src/skills.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ToolCallRef } from '@vpp/domain'
|
||||
import type { LineageRecorder, SnapshotStore } from '@vpp/services'
|
||||
|
||||
/**
|
||||
* Skill registry wrapper (docs/09 §4): every skill call snapshots its input
|
||||
* and output and records a ToolCallRef into the run's lineage buffer. The
|
||||
* Mastra Tool surface (for agent-initiated calls) is layered on top in a
|
||||
* later milestone; M3 workflows call skills deterministically from steps,
|
||||
* which is the docs/09 §3 discipline ("context assembled by upstream steps").
|
||||
*/
|
||||
export interface RegisteredSkill<I, O> {
|
||||
id: string
|
||||
version: string
|
||||
call(input: I): Promise<{ output: O; ref: ToolCallRef }>
|
||||
}
|
||||
|
||||
export function registerSkill<I, O>(
|
||||
deps: { lineage: LineageRecorder; snapshots: SnapshotStore; idGen: () => string },
|
||||
def: { id: string; version: string; invoke: (input: I) => Promise<O> },
|
||||
): RegisteredSkill<I, O> {
|
||||
return {
|
||||
id: def.id,
|
||||
version: def.version,
|
||||
async call(input: I) {
|
||||
const inputs_ref = deps.snapshots.put(input)
|
||||
const output = await def.invoke(input)
|
||||
const outputs_ref = deps.snapshots.put(output)
|
||||
const ref: ToolCallRef = { tool_call_id: deps.idGen(), tool: def.id, version: def.version, inputs_ref, outputs_ref }
|
||||
deps.lineage.record(ref)
|
||||
return { output, ref }
|
||||
},
|
||||
}
|
||||
}
|
||||
133
packages/runtime/src/trigger.ts
Normal file
133
packages/runtime/src/trigger.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { RouterDecision } from '@vpp/domain'
|
||||
import type { EventEnvelope } from '@vpp/domain'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js'
|
||||
import type { ResumeDecision } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Trigger service (docs/02 §1, docs/09 §1): three sources, one shape —
|
||||
* "instantiate template X with params Y".
|
||||
* scheduled: cron entries (D-1 06:00 situation, 08:00 bid) — no LLM in the path
|
||||
* event: bus consumers with deterministic rules
|
||||
* manual: the router agent turns free text into a RouterDecision
|
||||
* (a template id + params, never a new flow); on LLM outage the
|
||||
* operator picks the template explicitly.
|
||||
* Approval is a resume on the lifecycle run (docs/09 §2).
|
||||
*/
|
||||
export interface ScheduleEntry {
|
||||
id: string
|
||||
/** 'HH:MM' Asia/Shanghai on D-1 */
|
||||
localTime: string
|
||||
workflow: 'dayAheadSituation' | 'dayAheadBid'
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULE: ScheduleEntry[] = [
|
||||
{ id: 'situation-0600', localTime: '06:00', workflow: 'dayAheadSituation' }, // docs/07 D-1 06:00
|
||||
{ id: 'bid-0800', localTime: '08:00', workflow: 'dayAheadBid' }, // docs/07 D-1 08:00; window is OPEN-QUESTION A1
|
||||
]
|
||||
|
||||
export const nextMarketDate = (nowIso: string): string => {
|
||||
const shanghai = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
|
||||
shanghai.setUTCDate(shanghai.getUTCDate() + 1)
|
||||
return shanghai.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export class TriggerService {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private readonly firedToday = new Set<string>()
|
||||
private readonly unsubscribe: Array<() => void> = []
|
||||
|
||||
constructor(
|
||||
private readonly rt: Runtime,
|
||||
private readonly schedule: ScheduleEntry[] = DEFAULT_SCHEDULE,
|
||||
) {}
|
||||
|
||||
// ---- scheduled ---------------------------------------------------------
|
||||
|
||||
/** Polls the clock; fires each entry once per local day. Deterministic, LLM-free. */
|
||||
startScheduler(pollMs = 30_000): void {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(() => void this.tick(), pollMs)
|
||||
this.timer.unref()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
this.timer = null
|
||||
for (const u of this.unsubscribe) u()
|
||||
}
|
||||
|
||||
async tick(nowIso = this.rt.ctx.clock()): Promise<string[]> {
|
||||
const local = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
|
||||
const hhmm = local.toISOString().slice(11, 16)
|
||||
const day = local.toISOString().slice(0, 10)
|
||||
const fired: string[] = []
|
||||
for (const entry of this.schedule) {
|
||||
const key = `${day}:${entry.id}`
|
||||
if (hhmm >= entry.localTime && !this.firedToday.has(key)) {
|
||||
this.firedToday.add(key)
|
||||
await this.startTemplate(entry.workflow, { market_date: nextMarketDate(nowIso) }, 'SCHEDULED', entry.id)
|
||||
fired.push(entry.id)
|
||||
}
|
||||
}
|
||||
return fired
|
||||
}
|
||||
|
||||
// ---- event -------------------------------------------------------------
|
||||
|
||||
/** Deterministic event rules. M3: a SituationPublished with EXTREME risk opens an abnormal-day case (docs/13 §1). */
|
||||
startEventConsumers(): void {
|
||||
this.unsubscribe.push(
|
||||
this.rt.ctx.events.subscribe('SituationPublished', (evt: EventEnvelope) => {
|
||||
const payload = evt.payload as { report: { market_date: string; risk_level: string; id: string } }
|
||||
if (payload.report.risk_level === 'EXTREME') {
|
||||
this.rt.ctx.caseDesk.open({
|
||||
id: this.rt.ctx.newId('case'),
|
||||
kind: 'ADHOC_ANALYSIS',
|
||||
objective: `Abnormal-day protocol review for ${payload.report.market_date} (situation ${payload.report.id})`,
|
||||
owner: 'user-ops-lead', // OPEN-QUESTION B8
|
||||
deadline: `${payload.report.market_date}T00:00:00Z`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- manual ------------------------------------------------------------
|
||||
|
||||
async route(message: string): Promise<RouterDecision> {
|
||||
const prompt = `Operator request: "${message}". Today (UTC) is ${this.rt.ctx.clock()}. Default market_date is ${nextMarketDate(this.rt.ctx.clock())}.`
|
||||
try {
|
||||
return await this.rt.ctx.llm.structured('router-agent', prompt, RouterDecision)
|
||||
} catch (e) {
|
||||
if (e instanceof LlmUnavailable) throw new LlmUnavailable('router needs an LLM; pick a template explicitly via startTemplate')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async manual(message: string): Promise<{ decision: RouterDecision; run_id: string | null }> {
|
||||
const decision = await this.route(message)
|
||||
if (decision.workflow_id === 'adhoc-analysis') return { decision, run_id: null }
|
||||
const run_id = await this.startTemplate(decision.workflow_id === 'day-ahead-bid' ? 'dayAheadBid' : 'dayAheadSituation', decision.params, 'MANUAL', 'router')
|
||||
return { decision, run_id }
|
||||
}
|
||||
|
||||
async startTemplate(workflow: 'dayAheadSituation' | 'dayAheadBid', params: { market_date: string }, trigger: 'SCHEDULED' | 'EVENT' | 'MANUAL', source: string): Promise<string> {
|
||||
const wf = this.rt.mastra.getWorkflow(workflow)
|
||||
const run = await wf.createRun()
|
||||
this.rt.ctx.events.append({ event_type: 'WorkflowTriggered', payload: { workflow, params, trigger, source, run_id: run.runId }, correlation_id: run.runId })
|
||||
const result = await run.start({ inputData: params })
|
||||
this.rt.ctx.events.append({ event_type: 'WorkflowFinished', payload: { workflow, run_id: run.runId, status: result.status }, correlation_id: run.runId })
|
||||
if (result.status === 'failed') throw result.error
|
||||
return run.runId
|
||||
}
|
||||
|
||||
// ---- approval = resume -------------------------------------------------
|
||||
|
||||
async decide(runId: string, decision: ResumeDecision) {
|
||||
const wf = this.rt.mastra.getWorkflow('proposalLifecycle')
|
||||
const run = await wf.createRun({ runId })
|
||||
return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision })
|
||||
}
|
||||
}
|
||||
167
packages/runtime/src/workflows/day-ahead-bid.ts
Normal file
167
packages/runtime/src/workflows/day-ahead-bid.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { BidProposalDraft, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidProposalDraft as BidProposalDraftSchema, Curve96, MarketDate, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
|
||||
import { Decimal, assembleBidProposal } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
import { runForecasts } from './day-ahead-situation.js'
|
||||
import { LifecycleOutput } from './proposal-lifecycle.js'
|
||||
import type { createProposalLifecycle } from './proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* D-1 08:00 申报策略与报价优化 (docs/07, docs/09 §3): deterministic context
|
||||
* assembly (ledger bounds, capacity, price forecast) → MILP tool → the
|
||||
* trading agent drafts a proposal made of *references* → assembler builds
|
||||
* the Proposal from lineage → submit to the lifecycle workflow.
|
||||
* With the LLM down the draft is templated; the numbers are identical.
|
||||
*/
|
||||
export const BidInput = z.object({ market_date: MarketDate, case_id: z.string().min(1).optional() })
|
||||
|
||||
export const BidOutput = z.object({
|
||||
case_id: z.string(),
|
||||
proposal_id: z.string(),
|
||||
proposal: ProposalSchema,
|
||||
lifecycle_run_id: z.string(),
|
||||
lifecycle: LifecycleOutput.nullable(),
|
||||
lifecycle_status: z.enum(['success', 'suspended', 'failed']),
|
||||
llm_used: z.boolean(),
|
||||
})
|
||||
|
||||
const Context = z.object({
|
||||
market_date: MarketDate,
|
||||
case_id: z.string(),
|
||||
tool_calls: z.array(ToolCallRefSchema),
|
||||
data_refs: z.array(z.string()),
|
||||
milp_call: ToolCallRefSchema,
|
||||
milp_output: z.unknown(),
|
||||
ledger_version: z.int(),
|
||||
policy_pack_version: z.string(),
|
||||
capacity: Curve96,
|
||||
})
|
||||
|
||||
function capacityCurve(ctx: RuntimeContext, marketDate: string) {
|
||||
const total = ctx.resources.list().reduce((s, r) => s.add(new Decimal(r.value.certified_adjustable_mw)), new Decimal(0))
|
||||
return { interval_minutes: 15 as const, date: marketDate, values: Array.from({ length: 96 }, () => total.toFixed(3)) }
|
||||
}
|
||||
|
||||
export function createDayAheadBid(ctx: RuntimeContext, lifecycle: ReturnType<typeof createProposalLifecycle>) {
|
||||
const fetchContext = createStep({
|
||||
id: 'fetch-context',
|
||||
inputSchema: BidInput,
|
||||
outputSchema: Context,
|
||||
execute: async ({ inputData }) => {
|
||||
const caseId =
|
||||
inputData.case_id ??
|
||||
ctx.caseDesk.open({
|
||||
id: ctx.newId('case'),
|
||||
kind: 'DAY_AHEAD_BID',
|
||||
objective: `Submit day-ahead bid for ${inputData.market_date}`,
|
||||
owner: 'user-trader-01', // OPEN-QUESTION B3: owner role mapping
|
||||
// D-1 at the local bid deadline (Asia/Shanghai = UTC+8), stored as UTC.
|
||||
deadline: new Date(new Date(`${inputData.market_date}T${ctx.config.bidDeadlineLocal}:00Z`).getTime() - 86_400_000 - 8 * 3_600_000).toISOString(),
|
||||
}).id
|
||||
|
||||
return ctx.lineage.run(async () => {
|
||||
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
|
||||
const bounds = ctx.ledger.dayAheadBounds(inputData.market_date)
|
||||
const capacity = capacityCurve(ctx, inputData.market_date)
|
||||
ctx.lineage.addDataRef(ctx.snapshots.put({ resources: ctx.resources.list().map((r) => ({ id: r.key, version: r.version })) }))
|
||||
const milp = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: 'bid-optimization-milp', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.optimizeBid>[0]) => ctx.skills.optimizeBid(req) },
|
||||
)
|
||||
const { output, ref } = await milp.call({ market_date: inputData.market_date, price_forecast: forecasts.PRICE, adjustable_capacity_mw: capacity, position_bounds: bounds, risk: ctx.config.risk })
|
||||
if (output.solver.status !== 'OPTIMAL') throw new Error(`bid optimization ${output.solver.status}: ${output.binding_constraints.join('; ')}`)
|
||||
const lin = ctx.lineage.current()
|
||||
ctx.caseDesk.addEvidence(caseId, lin.data_refs)
|
||||
return {
|
||||
market_date: inputData.market_date,
|
||||
case_id: caseId,
|
||||
tool_calls: [...calls, ref].filter((c, i, arr) => arr.findIndex((x) => x.tool_call_id === c.tool_call_id) === i),
|
||||
data_refs: lin.data_refs,
|
||||
milp_call: ref,
|
||||
milp_output: output,
|
||||
ledger_version: bounds.ledger_version,
|
||||
policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version,
|
||||
capacity,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const bidStrategy = createStep({
|
||||
id: 'bid-strategy',
|
||||
inputSchema: Context,
|
||||
outputSchema: Context.extend({ draft: BidProposalDraftSchema, llm_used: z.boolean() }),
|
||||
execute: async ({ inputData }) => {
|
||||
const tc = inputData.milp_call.tool_call_id
|
||||
const out = inputData.milp_output as { expected_revenue_yuan: string; daily_energy_mwh: string; binding_constraints: string[] }
|
||||
const template: BidProposalDraft = {
|
||||
market_date: inputData.market_date,
|
||||
prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' },
|
||||
rationale: `Solver ${tc}: expected revenue ${out.expected_revenue_yuan} yuan over ${out.daily_energy_mwh} MWh; binding: ${out.binding_constraints.join(', ') || 'none'} (template — LLM unavailable)`,
|
||||
}
|
||||
try {
|
||||
const prompt =
|
||||
`Market date ${inputData.market_date}. The bid-optimization tool call id is ${tc}; its output has fields prices_yuan_per_mwh, quantities_mwh, expected_revenue_yuan (= ${out.expected_revenue_yuan}), daily_energy_mwh (= ${out.daily_energy_mwh}), binding_constraints (${out.binding_constraints.join(', ') || 'none'}). ` +
|
||||
'Return a BidProposalDraft whose three refs point at those fields of that tool call, plus a one-paragraph rationale for the approver. Do not write any number that is not quoted above.'
|
||||
const draft = await ctx.llm.structured('trading-agent', prompt, BidProposalDraftSchema)
|
||||
return { ...inputData, draft, llm_used: true }
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
return { ...inputData, draft: template, llm_used: false }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const assembleAndSubmit = createStep({
|
||||
id: 'assemble-and-submit',
|
||||
inputSchema: bidStrategy.outputSchema,
|
||||
outputSchema: BidOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
const proposal: Proposal = assembleBidProposal(inputData.draft, {
|
||||
id: ctx.newId('prop'),
|
||||
originator: { agent: 'trading-agent', trigger: 'SCHEDULED', task_id: inputData.case_id },
|
||||
lineage: { tool_calls: inputData.tool_calls as ToolCallRef[], data_refs: inputData.data_refs },
|
||||
ledger_version: inputData.ledger_version,
|
||||
policy_pack_version: inputData.policy_pack_version,
|
||||
snapshots: ctx.snapshots,
|
||||
now: ctx.clock(),
|
||||
})
|
||||
ctx.snapshots.put({ draft: inputData.draft, proposal_id: proposal.id })
|
||||
ctx.caseDesk.recordProposal(inputData.case_id, proposal)
|
||||
|
||||
// ProposalSubmissionPort (docs/11 §1.1): the business workflow ends here;
|
||||
// the lifecycle workflow takes over on its own run id (resumable).
|
||||
const run = await lifecycle.createRun()
|
||||
const result = await run.start({ inputData: { proposal, case_id: inputData.case_id } })
|
||||
if (result.status === 'suspended') {
|
||||
const pending = ctx.caseDesk.pending.get(proposal.id)
|
||||
if (pending) ctx.caseDesk.pending.put({ ...pending.value, run_id: run.runId }, pending.version)
|
||||
}
|
||||
return {
|
||||
case_id: inputData.case_id,
|
||||
proposal_id: proposal.id,
|
||||
proposal,
|
||||
lifecycle_run_id: run.runId,
|
||||
lifecycle: result.status === 'success' ? result.result : null,
|
||||
lifecycle_status: (result.status === 'success' ? 'success' : result.status === 'suspended' ? 'suspended' : 'failed') as 'success' | 'suspended' | 'failed',
|
||||
llm_used: inputData.llm_used,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'day-ahead-bid',
|
||||
inputSchema: BidInput,
|
||||
outputSchema: BidOutput,
|
||||
})
|
||||
.then(fetchContext)
|
||||
.then(bidStrategy)
|
||||
.then(assembleAndSubmit)
|
||||
.commit()
|
||||
}
|
||||
137
packages/runtime/src/workflows/day-ahead-situation.ts
Normal file
137
packages/runtime/src/workflows/day-ahead-situation.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { ForecastBundle, ForecastKind, SituationReport, ToolCallRef } from '@vpp/domain'
|
||||
import { ForecastBundle as ForecastBundleSchema, MarketDate, SituationReport as SituationReportSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
|
||||
/**
|
||||
* D-1 06:00 日前态势 (docs/07): periodic trigger → forecasts (tools) →
|
||||
* analysis agent writes findings around the numbers → SituationReport on
|
||||
* the bus. Runs with the LLM down (I6): findings fall back to a template.
|
||||
*/
|
||||
export const SituationInput = z.object({ market_date: MarketDate })
|
||||
|
||||
export const SituationOutput = z.object({
|
||||
report: SituationReportSchema,
|
||||
report_ref: z.string(),
|
||||
forecasts: z.object({ LOAD: ForecastBundleSchema, PV: ForecastBundleSchema, PRICE: ForecastBundleSchema }),
|
||||
tool_calls: z.array(ToolCallRefSchema),
|
||||
llm_backend: z.string(),
|
||||
llm_used: z.boolean(),
|
||||
})
|
||||
|
||||
const SERIES: Record<ForecastKind, { series: string; unit: 'mw' | 'yuan_per_mwh' }> = {
|
||||
LOAD: { series: 'load:aggregate', unit: 'mw' },
|
||||
PV: { series: 'pv:aggregate', unit: 'mw' },
|
||||
PRICE: { series: 'price:da', unit: 'yuan_per_mwh' },
|
||||
}
|
||||
|
||||
const Findings = z.object({
|
||||
findings: z.array(z.object({ kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']), summary: z.string().min(1) })).min(1).max(6),
|
||||
})
|
||||
|
||||
export async function runForecasts(ctx: RuntimeContext, marketDate: string): Promise<{ forecasts: Record<ForecastKind, ForecastBundle>; calls: ToolCallRef[] }> {
|
||||
const d = new Date(`${marketDate}T00:00:00Z`)
|
||||
const from = new Date(d.getTime() - ctx.config.forecastWindowDays * 86_400_000).toISOString().slice(0, 10)
|
||||
const to = new Date(d.getTime() - 86_400_000).toISOString().slice(0, 10)
|
||||
const forecasts = {} as Record<ForecastKind, ForecastBundle>
|
||||
const calls: ToolCallRef[] = []
|
||||
for (const kind of ['LOAD', 'PV', 'PRICE'] as const) {
|
||||
const history = ctx.timeseries.range(SERIES[kind].series, from, to).map((r) => r.curve)
|
||||
if (history.length === 0) throw new Error(`no history for ${SERIES[kind].series} in [${from}, ${to}] — ingest data first`)
|
||||
const featuresRef = ctx.snapshots.put({ series: SERIES[kind].series, from, to, revisions: history.length })
|
||||
ctx.lineage.addDataRef(featuresRef)
|
||||
const skill = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: `${kind.toLowerCase()}-forecast`, version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.forecast>[1]) => ctx.skills.forecast(kind, req) },
|
||||
)
|
||||
const { output, ref } = await skill.call({ kind, market_date: marketDate, unit: SERIES[kind].unit, history, exogenous: {}, features_snapshot_ref: featuresRef })
|
||||
forecasts[kind] = output
|
||||
calls.push(ref)
|
||||
}
|
||||
return { forecasts, calls }
|
||||
}
|
||||
|
||||
/** Risk level is deterministic (it gates the abnormal-day protocol, docs/13 §1); the LLM only narrates. */
|
||||
export function riskLevel(ctx: RuntimeContext, price: ForecastBundle): SituationReport['risk_level'] {
|
||||
const ratio = Math.max(...price.quantiles.p90.values.map((v, i) => Number(v) / Math.max(Number(price.quantiles.p50.values[i]), 1e-9)))
|
||||
const extreme = Number(ctx.config.extremeDayPriceRatio)
|
||||
if (ratio >= extreme) return 'EXTREME'
|
||||
if (ratio >= 1 + (extreme - 1) * 0.66) return 'HIGH'
|
||||
if (ratio >= 1 + (extreme - 1) * 0.33) return 'MEDIUM'
|
||||
return 'LOW'
|
||||
}
|
||||
|
||||
export function createDayAheadSituation(ctx: RuntimeContext) {
|
||||
const forecastStep = createStep({
|
||||
id: 'forecast',
|
||||
inputSchema: SituationInput,
|
||||
outputSchema: z.object({ market_date: MarketDate, forecasts: SituationOutput.shape.forecasts, tool_calls: z.array(ToolCallRefSchema) }),
|
||||
execute: async ({ inputData }) => {
|
||||
return ctx.lineage.run(async () => {
|
||||
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
|
||||
return { market_date: inputData.market_date, forecasts, tool_calls: calls }
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const analyzeStep = createStep({
|
||||
id: 'analyze',
|
||||
inputSchema: forecastStep.outputSchema,
|
||||
outputSchema: SituationOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
const { forecasts, tool_calls } = inputData
|
||||
const refs = tool_calls.map((t) => t.outputs_ref)
|
||||
const level = riskLevel(ctx, forecasts.PRICE)
|
||||
const peakPrice = Math.max(...forecasts.PRICE.quantiles.p50.values.map(Number))
|
||||
const peakLoad = Math.max(...forecasts.LOAD.quantiles.p50.values.map(Number))
|
||||
|
||||
let findings: SituationReport['findings']
|
||||
let llmUsed = false
|
||||
const fallback: SituationReport['findings'] = [
|
||||
{ kind: 'TREND', summary: `P50 peak load ${peakLoad.toFixed(1)} MW; P50 peak price ${peakPrice.toFixed(2)} yuan/MWh (template — LLM unavailable)`, refs },
|
||||
{ kind: 'RISK', summary: `risk level ${level} from price band width`, refs },
|
||||
]
|
||||
try {
|
||||
const prompt =
|
||||
`Market date ${inputData.market_date}. Tool outputs (snapshot refs): ` +
|
||||
tool_calls.map((t) => `${t.tool}→${t.outputs_ref.slice(0, 12)}`).join(', ') +
|
||||
`. Deterministic risk level: ${level}. P50 peak load ${peakLoad.toFixed(1)} MW, P50 peak price ${peakPrice.toFixed(2)} yuan/MWh. ` +
|
||||
'Write 2–4 findings (TREND/ANOMALY/RISK) that only restate these numbers.'
|
||||
const out = await ctx.llm.structured('analysis-agent', prompt, Findings)
|
||||
findings = out.findings.map((f) => ({ ...f, refs }))
|
||||
llmUsed = true
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
findings = fallback
|
||||
}
|
||||
|
||||
const report: SituationReport = {
|
||||
id: ctx.newId('sit'),
|
||||
market_date: inputData.market_date,
|
||||
risk_level: level,
|
||||
findings,
|
||||
forecast_refs: [forecasts.LOAD.id, forecasts.PV.id, forecasts.PRICE.id],
|
||||
generated_at: ctx.clock(),
|
||||
}
|
||||
const report_ref = ctx.snapshots.put(report)
|
||||
ctx.events.append({
|
||||
event_type: 'SituationPublished',
|
||||
payload: { report, report_ref, forecast_tool_calls: tool_calls },
|
||||
correlation_id: `situation-${inputData.market_date}`,
|
||||
})
|
||||
return { report, report_ref, forecasts, tool_calls, llm_backend: ctx.llm.backend, llm_used: llmUsed }
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'day-ahead-situation',
|
||||
inputSchema: SituationInput,
|
||||
outputSchema: SituationOutput,
|
||||
})
|
||||
.then(forecastStep)
|
||||
.then(analyzeStep)
|
||||
.commit()
|
||||
}
|
||||
247
packages/runtime/src/workflows/proposal-lifecycle.ts
Normal file
247
packages/runtime/src/workflows/proposal-lifecycle.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { Approval, ForecastBundle, Proposal, ProposalStatus } from '@vpp/domain'
|
||||
import { Approval as ApprovalSchema, ForecastBundle as ForecastBundleSchema, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import { GatewayRejected, assertApproverIndependent, isStaleDenial } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
|
||||
/**
|
||||
* The safety chain as ONE workflow for every proposal type (ADR-0005,
|
||||
* docs/03 §2, docs/09 §2): rule check → simulation → envelope gate
|
||||
* (suspend for humans) → fresh check + permit → release. Every transition is
|
||||
* recorded through the case desk (event-sourced) so the run is replayable.
|
||||
*/
|
||||
export const LifecycleInput = z.object({ proposal: ProposalSchema, case_id: z.string().min(1) })
|
||||
|
||||
export const LifecycleOutcome = z.enum(['RELEASED', 'REJECTED', 'STALE', 'SIMULATION_ALERT'])
|
||||
|
||||
export const LifecycleOutput = z.object({
|
||||
outcome: LifecycleOutcome,
|
||||
proposal: ProposalSchema,
|
||||
permit_id: z.string().nullable(),
|
||||
receipt_id: z.string().nullable(),
|
||||
reasons: z.array(z.string()),
|
||||
})
|
||||
|
||||
const Carry = z.object({
|
||||
proposal: ProposalSchema,
|
||||
case_id: z.string(),
|
||||
simulation_alert: z.boolean(),
|
||||
simulation_alerts: z.array(z.string()),
|
||||
approvals: z.array(ApprovalSchema),
|
||||
envelope_match: z.unknown().nullable(),
|
||||
done: LifecycleOutput.nullable(),
|
||||
})
|
||||
type Carry = z.infer<typeof Carry>
|
||||
|
||||
export const ResumeDecision = z.object({
|
||||
decision: z.enum(['approve', 'reject']),
|
||||
approver: z.object({ id: z.string().min(1), role: z.string().min(1) }),
|
||||
comment: z.string().optional(),
|
||||
})
|
||||
export type ResumeDecision = z.infer<typeof ResumeDecision>
|
||||
|
||||
const SuspendInfo = z.object({
|
||||
reason: z.string(),
|
||||
proposal_id: z.string(),
|
||||
required_level: z.string(),
|
||||
reasons: z.array(z.string()),
|
||||
})
|
||||
|
||||
function transition(ctx: RuntimeContext, caseId: string, p: Proposal, status: ProposalStatus, extra: Record<string, unknown> = {}): Proposal {
|
||||
const next: Proposal = { ...p, status }
|
||||
ctx.caseDesk.recordProposal(caseId, next)
|
||||
if (Object.keys(extra).length) {
|
||||
ctx.events.append({ event_type: `Lifecycle.${status}`, payload: { proposal_id: p.id, digest: p.digest, ...extra }, correlation_id: caseId, causation_id: p.id })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function priceForecastFromLineage(ctx: RuntimeContext, p: Proposal): ForecastBundle | null {
|
||||
for (const tc of [...p.lineage.tool_calls].reverse()) {
|
||||
if (tc.tool !== 'price-forecast') continue
|
||||
const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref))
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const finish = (c: Carry, outcome: z.infer<typeof LifecycleOutcome>, reasons: string[], permit_id: string | null = null, receipt_id: string | null = null): Carry => ({
|
||||
...c,
|
||||
done: { outcome, proposal: c.proposal, permit_id, receipt_id, reasons },
|
||||
})
|
||||
|
||||
/** One lifecycle workflow per runtime: steps close over that runtime's services. */
|
||||
export function createProposalLifecycle(ctx: RuntimeContext) {
|
||||
const ruleCheck = createStep({
|
||||
id: 'rule-check',
|
||||
inputSchema: LifecycleInput,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
const pack = ctx.policy.pack(ctx.config.policyPackId, inputData.proposal.lineage.policy_pack_version)
|
||||
let p = transition(ctx, inputData.case_id, inputData.proposal, 'RULE_CHECK')
|
||||
const result = ctx.policy.check(p, { id: pack.id, version: pack.version }, { ledger: ctx.ledger.read(), ledgerService: ctx.ledger, snapshots: ctx.snapshots, now: ctx.clock() })
|
||||
const ref = ctx.snapshots.put(result)
|
||||
ctx.events.append({ event_type: 'RuleCheckCompleted', payload: { proposal_id: p.id, ok: result.ok, result_ref: ref, violations: result.violations }, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
const carry: Carry = { proposal: p, case_id: inputData.case_id, simulation_alert: false, simulation_alerts: [], approvals: [], envelope_match: null, done: null }
|
||||
if (!result.ok) {
|
||||
p = transition(ctx, inputData.case_id, p, 'REJECTED', { by: 'rule-check', violations: result.violations })
|
||||
return finish({ ...carry, proposal: p }, 'REJECTED', result.violations.map((v) => `${v.rule_id}: ${v.message}`))
|
||||
}
|
||||
return carry
|
||||
},
|
||||
})
|
||||
|
||||
const simulation = createStep({
|
||||
id: 'simulation',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION')
|
||||
const result = ctx.simulation.simulate(p, {
|
||||
now: ctx.clock(),
|
||||
priceForecast: priceForecastFromLineage(ctx, p),
|
||||
marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh,
|
||||
worstCaseLossBudgetYuan: ctx.config.worstCaseLossBudgetYuan,
|
||||
})
|
||||
const ref = ctx.snapshots.put(result)
|
||||
ctx.events.append({ event_type: 'SimulationCompleted', payload: { proposal_id: p.id, alert: result.alert, alerts: result.alerts, metrics: result.metrics, result_ref: ref }, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
return { ...inputData, proposal: p, simulation_alert: result.alert, simulation_alerts: result.alerts }
|
||||
},
|
||||
})
|
||||
|
||||
const envelopeGate = createStep({
|
||||
id: 'envelope-gate',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
suspendSchema: SuspendInfo,
|
||||
resumeSchema: ResumeDecision,
|
||||
execute: async ({ inputData, resumeData, suspend }) => {
|
||||
if (inputData.done) return inputData
|
||||
const now = ctx.clock()
|
||||
const p0 = inputData.proposal
|
||||
|
||||
if (resumeData) {
|
||||
// —— Human decision path (I1/I2 enforced here; the AI has no route to APPROVED).
|
||||
try {
|
||||
assertApproverIndependent(p0, resumeData.approver, ctx.config.approverRoles)
|
||||
} catch (e) {
|
||||
ctx.events.append({ event_type: 'ApprovalRefused', payload: { proposal_id: p0.id, approver: resumeData.approver, reason: (e as Error).message }, correlation_id: inputData.case_id, causation_id: p0.id })
|
||||
return await suspend({ reason: `approval refused: ${(e as Error).message}`, proposal_id: p0.id, required_level: 'L2', reasons: [(e as Error).message] })
|
||||
}
|
||||
const approval: Approval = {
|
||||
id: ctx.newId('appr'),
|
||||
proposal_digest: p0.digest,
|
||||
decision: resumeData.decision === 'approve' ? 'APPROVE' : 'REJECT',
|
||||
approver: resumeData.approver,
|
||||
scope: { effect_type: p0.type, limits: {} },
|
||||
validity: { from: now, to: new Date(new Date(now).getTime() + ctx.config.bidPermitTtlMs).toISOString() },
|
||||
evidence_versions: { policy_pack_version: p0.lineage.policy_pack_version, ledger_version: ctx.ledger.read().version, data_snapshot_refs: p0.lineage.data_refs },
|
||||
...(resumeData.comment ? { comment: resumeData.comment } : {}),
|
||||
decided_at: now,
|
||||
}
|
||||
ctx.caseDesk.recordApproval(inputData.case_id, approval)
|
||||
ctx.caseDesk.clearPending(p0.id)
|
||||
if (approval.decision === 'REJECT') {
|
||||
const p = transition(ctx, inputData.case_id, p0, 'REJECTED', { by: approval.approver.id, comment: resumeData.comment ?? null })
|
||||
return finish({ ...inputData, proposal: p, approvals: [approval] }, 'REJECTED', [`rejected by ${approval.approver.id}`])
|
||||
}
|
||||
const p = transition(ctx, inputData.case_id, p0, 'APPROVED', { by: approval.approver.id })
|
||||
return { ...inputData, proposal: p, approvals: [approval] }
|
||||
}
|
||||
|
||||
// —— Automatic path.
|
||||
let p = transition(ctx, inputData.case_id, p0, 'ENVELOPE_CHECK')
|
||||
if (inputData.simulation_alert) {
|
||||
// One vote: any simulation alert escalates to a human, no exceptions.
|
||||
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'simulation alert', alerts: inputData.simulation_alerts })
|
||||
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: 'L2', reasons: inputData.simulation_alerts, since: now })
|
||||
return await suspend({ reason: 'simulation alert', proposal_id: p.id, required_level: 'L2', reasons: inputData.simulation_alerts })
|
||||
}
|
||||
const match = ctx.envelopes.match(p, { now, priceBaseline: priceForecastFromLineage(ctx, p)?.quantiles.p50 ?? null })
|
||||
ctx.snapshots.put(match)
|
||||
if (match.within) {
|
||||
p = transition(ctx, inputData.case_id, { ...p, envelope_ref: match.envelope_id }, 'AUTO_APPROVED', { envelope_id: match.envelope_id })
|
||||
return { ...inputData, proposal: p, envelope_match: match }
|
||||
}
|
||||
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'outside envelope', reasons: match.reasons })
|
||||
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: match.required_level, reasons: match.reasons, since: now })
|
||||
return await suspend({ reason: 'outside envelope', proposal_id: p.id, required_level: match.required_level, reasons: match.reasons })
|
||||
},
|
||||
})
|
||||
|
||||
const freshCheckAndPermit = createStep({
|
||||
id: 'fresh-check',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData
|
||||
const now = ctx.clock()
|
||||
let p = transition(ctx, inputData.case_id, inputData.proposal, 'FRESH_CHECK')
|
||||
const result = ctx.authority.authorize(
|
||||
p,
|
||||
{ approvals: inputData.approvals, envelopeMatch: (inputData.envelope_match as Parameters<typeof ctx.authority.authorize>[1]['envelopeMatch']) ?? null },
|
||||
{ now, ledger: ctx.ledger, currentPolicyPackVersion: ctx.policy.current(ctx.config.policyPackId).version, offlineResources: [], permitTtlMs: ctx.config.bidPermitTtlMs },
|
||||
)
|
||||
if (isStaleDenial(result)) {
|
||||
p = transition(ctx, inputData.case_id, p, 'STALE', { reasons: result.reasons })
|
||||
return finish({ ...inputData, proposal: p }, 'STALE', result.reasons)
|
||||
}
|
||||
ctx.caseDesk.recordPermit(inputData.case_id, result)
|
||||
p = transition(ctx, inputData.case_id, p, 'AUTHORIZED', { permit_id: result.id, expires_at: result.expires_at })
|
||||
return { ...inputData, proposal: p, done: null, envelope_match: inputData.envelope_match, approvals: inputData.approvals, permit: result } as Carry & { permit: unknown }
|
||||
},
|
||||
})
|
||||
|
||||
const release = createStep({
|
||||
id: 'release',
|
||||
inputSchema: Carry.extend({ permit: z.unknown().optional() }),
|
||||
outputSchema: LifecycleOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData.done
|
||||
const now = ctx.clock()
|
||||
const permitId = (inputData.permit as { id: string } | undefined)?.id
|
||||
const permit = permitId ? ctx.authority.permits.get(permitId)?.value : undefined
|
||||
if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring')
|
||||
try {
|
||||
const receipt = ctx.gateway.dispatch(inputData.proposal, permit, now)
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'RELEASED', { receipt_id: receipt.receipt_id, artifact_ref: receipt.artifact_ref })
|
||||
ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
ctx.ledger.append({
|
||||
id: ctx.newId('pos'),
|
||||
timescale: 'DAY_AHEAD',
|
||||
period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10),
|
||||
kind: 'BID_SUBMITTED',
|
||||
energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0',
|
||||
curve: p.type === 'BID' ? p.payload.quantities_mwh : null,
|
||||
source_ref: p.digest,
|
||||
expected_version: ctx.ledger.read().version,
|
||||
})
|
||||
ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true)
|
||||
return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] }
|
||||
} catch (e) {
|
||||
if (e instanceof GatewayRejected) {
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'STALE', { gateway_reasons: e.reasons })
|
||||
return { outcome: 'STALE' as const, proposal: p, permit_id: permit.id, receipt_id: null, reasons: e.reasons }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({
|
||||
id: 'proposal-lifecycle',
|
||||
inputSchema: LifecycleInput,
|
||||
outputSchema: LifecycleOutput,
|
||||
})
|
||||
.then(ruleCheck)
|
||||
.then(simulation)
|
||||
.then(envelopeGate)
|
||||
.then(freshCheckAndPermit)
|
||||
.then(release)
|
||||
.commit()
|
||||
|
||||
}
|
||||
|
||||
export const ENVELOPE_GATE_STEP_ID = 'envelope-gate'
|
||||
54
packages/runtime/test/api.test.ts
Normal file
54
packages/runtime/test/api.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApi } from '../src/api.js'
|
||||
import { TriggerService } from '../src/trigger.js'
|
||||
import { MARKET_DATE, harness } from './helpers.js'
|
||||
|
||||
async function serve(h: Awaited<ReturnType<typeof harness>>) {
|
||||
const server = createApi(h.rt, new TriggerService(h.rt))
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
|
||||
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const call = async (method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<{ status: number; body: any }> => {
|
||||
const init: RequestInit = { method, headers: { 'content-type': 'application/json', ...headers } }
|
||||
if (body !== undefined) init.body = JSON.stringify(body)
|
||||
const res = await fetch(base + path, init)
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
return { call, close: () => new Promise<void>((r) => server.close(() => r())) }
|
||||
}
|
||||
|
||||
describe('Case Desk HTTP API', () => {
|
||||
it('inbox → decide (approve) → case view + lineage expansion', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const { call, close } = await serve(h)
|
||||
expect((await call('GET', '/health')).body).toEqual({ status: 'ok', llm: 'null' })
|
||||
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const out = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
if (out.status !== 'success') throw new Error(out.status)
|
||||
|
||||
const inbox = await call('GET', '/inbox')
|
||||
expect(inbox.body).toHaveLength(1)
|
||||
const { run_id, proposal_id, case_id } = inbox.body[0]
|
||||
|
||||
expect((await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' })).status).toBe(401)
|
||||
const refused = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' }, { 'x-user-id': 'trading-agent', 'x-user-role': 'senior-trader' })
|
||||
expect(refused.body.status).toBe('suspended') // I1: still waiting for a legitimate human
|
||||
|
||||
const ok = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve', comment: 'fine' }, { 'x-user-id': 'user-trader-01', 'x-user-role': 'senior-trader' })
|
||||
expect(ok.body).toMatchObject({ run_id, status: 'success', outcome: 'RELEASED' })
|
||||
expect((await call('GET', '/inbox')).body).toHaveLength(0)
|
||||
|
||||
const view = await call('GET', `/cases/${case_id}`)
|
||||
expect(view.body.case.status).toBe('CLOSED_DONE')
|
||||
expect(view.body.approvals[0].approver.id).toBe('user-trader-01')
|
||||
const lineage = await call('GET', `/proposals/${proposal_id}/lineage`)
|
||||
expect(lineage.body.tool_calls.map((t: { tool: string }) => t.tool)).toContain('bid-optimization-milp')
|
||||
expect((await call('GET', `/events?correlation_id=${case_id}`)).body.length).toBeGreaterThan(8)
|
||||
expect((await call('GET', '/nope')).status).toBe(404)
|
||||
expect((await call('POST', '/tasks', { message: 'hi' })).status).toBe(503) // router needs an LLM
|
||||
await close()
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
102
packages/runtime/test/helpers.ts
Normal file
102
packages/runtime/test/helpers.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain'
|
||||
import { MemoryTimeSeriesStore } from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import { createRuntime } from '../src/runtime.js'
|
||||
import type { RuntimeOptions } from '../src/runtime.js'
|
||||
import type { LlmPort } from '../src/llm.js'
|
||||
|
||||
const datasetPath = fileURLToPath(new URL('../../evals/datasets/synthetic-hubei-v0.json', import.meta.url))
|
||||
|
||||
interface Day { date: string; load_mw: string[]; pv_mw: string[]; price_yuan_per_mwh: string[]; adjustable_capacity_mw: string[] }
|
||||
|
||||
export const MARKET_DATE = '2026-03-15'
|
||||
export const NOW = '2026-03-14T00:00:00Z' // D-1 08:00 Asia/Shanghai
|
||||
|
||||
/**
|
||||
* Deterministic stand-in for the Python skill service: forecast = last
|
||||
* history day with a ±spread band; bid = flat energy at the position's
|
||||
* lower bound offered at `offer` — enough to steer the lifecycle down every
|
||||
* branch (envelope in/out, simulation alert) from tests.
|
||||
*/
|
||||
export class StubSkills implements SkillClient {
|
||||
spread = 0.1
|
||||
offer = '0.00'
|
||||
energyFraction = 0 // 0 → E_min, 1 → E_max
|
||||
calls: string[] = []
|
||||
|
||||
async skills() {
|
||||
return ['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'].map((id) => ({ id, version: '0.0.1', endpoint: '/stub' }))
|
||||
}
|
||||
async forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle> {
|
||||
this.calls.push(`forecast:${kind}`)
|
||||
const last = req.history[req.history.length - 1]!
|
||||
const scaled = (f: number) => ({ interval_minutes: 15 as const, date: req.market_date, values: last.values.map((v) => (Number(v) * f).toFixed(kind === 'PRICE' ? 2 : 3)) })
|
||||
return {
|
||||
id: `fc-${kind.toLowerCase()}-${req.market_date}`, kind, market_date: req.market_date, unit: req.unit,
|
||||
quantiles: { p10: scaled(1 - this.spread), p50: scaled(1), p90: scaled(1 + this.spread) },
|
||||
model: { name: `${kind.toLowerCase()}-forecast`, version: '0.0.1' }, features_snapshot_ref: req.features_snapshot_ref, generated_at: '2026-03-14T06:00:00Z',
|
||||
}
|
||||
}
|
||||
async optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult> {
|
||||
this.calls.push('milp')
|
||||
const lo = Number(req.position_bounds.daily_energy_min_mwh)
|
||||
const hi = Number(req.position_bounds.daily_energy_max_mwh)
|
||||
const per = ((lo + (hi - lo) * this.energyFraction) / 96).toFixed(3)
|
||||
const q = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] }
|
||||
const energy = (Number(per) * 96).toFixed(3)
|
||||
const p50 = req.price_forecast.quantiles.p50.values
|
||||
const revenue = p50.reduce((s, p) => s + (Number(this.offer) <= Number(p) ? Number(p) * Number(per) : 0), 0).toFixed(2)
|
||||
return {
|
||||
market_date: req.market_date, prices_yuan_per_mwh: { ...q, values: Array(96).fill(this.offer) }, quantities_mwh: q, daily_energy_mwh: energy,
|
||||
expected_revenue_yuan: revenue, revenue_distribution_yuan: { p10: revenue, p50: revenue, p90: revenue }, position_bounds: req.position_bounds,
|
||||
solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: revenue, wall_time_ms: 1 }, binding_constraints: ['daily_energy_min'], skill_version: '0.0.1',
|
||||
}
|
||||
}
|
||||
async report(_r: ReportRequest): Promise<SkillReport> { throw new Error('not used') }
|
||||
}
|
||||
|
||||
export function seededTimeseries(clock: () => string) {
|
||||
const ts = new MemoryTimeSeriesStore(clock)
|
||||
const days = (JSON.parse(readFileSync(datasetPath, 'utf8')) as { days: Day[] }).days
|
||||
for (const d of days) {
|
||||
ts.write('load:aggregate', { interval_minutes: 15, date: d.date, values: d.load_mw }, 'synthetic')
|
||||
ts.write('pv:aggregate', { interval_minutes: 15, date: d.date, values: d.pv_mw }, 'synthetic')
|
||||
ts.write('price:da', { interval_minutes: 15, date: d.date, values: d.price_yuan_per_mwh }, 'synthetic')
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
|
||||
id: 'env-bid-001',
|
||||
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
|
||||
bounds,
|
||||
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
|
||||
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
|
||||
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
})
|
||||
|
||||
export interface Harness { rt: Awaited<ReturnType<typeof createRuntime>>; skills: StubSkills; dataDir: string; setNow: (iso: string) => void }
|
||||
|
||||
/** Fresh runtime on a temp dir, seeded like docs/07 D-1: history, one storage resource, a monthly contract. */
|
||||
export async function harness(opts: { llm?: LlmPort | null; dataDir?: string; seed?: boolean; config?: RuntimeOptions['config'] } = {}): Promise<Harness> {
|
||||
let now = NOW
|
||||
const clock = () => now
|
||||
const skills = new StubSkills()
|
||||
const dataDir = opts.dataDir ?? mkdtempSync(join(tmpdir(), 'vpp-rt-'))
|
||||
const rt = await createRuntime({ dataDir, llm: opts.llm ?? null, skills, clock, timeseries: seededTimeseries(clock), ...(opts.config ? { config: opts.config } : {}) })
|
||||
if (opts.seed !== false) {
|
||||
rt.ctx.resources.put({
|
||||
resource_id: 'res-storage-01', name: 'Wuhan storage #1', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '24.0', confidence: '0.9', reliability_score: '0.9',
|
||||
constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW,
|
||||
})
|
||||
// 24 MW × 0.9 × 0.25 h × 96 = 518.4 MWh sellable/day; contract 60% of it → daily share 311.04, band ±5%
|
||||
rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
|
||||
}
|
||||
return { rt, skills, dataDir, setNow: (iso) => (now = iso) }
|
||||
}
|
||||
292
packages/runtime/test/lifecycle.test.ts
Normal file
292
packages/runtime/test/lifecycle.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Proposal } from '@vpp/domain'
|
||||
import { proposalDigest } from '@vpp/services'
|
||||
import { ScriptedLlm } from '../src/llm.js'
|
||||
import { TriggerService } from '../src/trigger.js'
|
||||
import { MARKET_DATE, envelope, harness } from './helpers.js'
|
||||
|
||||
const APPROVER = { id: 'user-trader-01', role: 'senior-trader' }
|
||||
|
||||
async function submitBid(h: Awaited<ReturnType<typeof harness>>) {
|
||||
const wf = h.rt.mastra.getWorkflow('dayAheadBid')
|
||||
const run = await wf.createRun()
|
||||
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
if (res.status !== 'success') throw new Error(`bid workflow ${res.status}: ${JSON.stringify((res as { error?: unknown }).error ?? res)}`)
|
||||
return res.result
|
||||
}
|
||||
|
||||
const statuses = (h: Awaited<ReturnType<typeof harness>>, caseId: string) =>
|
||||
h.rt.ctx.events.list({ event_type: 'ProposalStatusChanged', correlation_id: caseId }).map((e) => (e.payload as { status: string }).status)
|
||||
|
||||
describe('docs/07 timeline D-1 06:00 → 08:30 (LLM down: I6)', () => {
|
||||
it('situation → bid → lifecycle end-to-end, auto-approved inside the envelope, released as a bid file', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
|
||||
|
||||
// 06:00 situation
|
||||
const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun()
|
||||
const sres = await sit.start({ inputData: { market_date: MARKET_DATE } })
|
||||
expect(sres.status).toBe('success')
|
||||
if (sres.status !== 'success') return
|
||||
expect(sres.result.llm_used).toBe(false)
|
||||
expect(sres.result.report.findings[0]!.summary).toMatch(/template/)
|
||||
expect(sres.result.report.risk_level).toBe('LOW')
|
||||
expect(h.rt.ctx.events.list({ event_type: 'SituationPublished' })).toHaveLength(1)
|
||||
|
||||
// 08:00 bid → 08:30 chain
|
||||
const out = await submitBid(h)
|
||||
expect(out.llm_used).toBe(false)
|
||||
expect(out.lifecycle_status).toBe('success')
|
||||
expect(out.lifecycle?.outcome).toBe('RELEASED')
|
||||
expect(statuses(h, out.case_id)).toEqual(['DRAFT', 'RULE_CHECK', 'SIMULATION', 'ENVELOPE_CHECK', 'AUTO_APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
|
||||
|
||||
// P2: every payload number traces to the MILP tool output; P7: the ledger recorded the bid.
|
||||
const lin = h.rt.ctx.caseDesk.expandLineage(out.proposal_id)
|
||||
expect(lin.tool_calls.map((t) => t.tool)).toEqual(['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'])
|
||||
expect((lin.tool_calls[3]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe(out.proposal.payload.expected_revenue_yuan)
|
||||
expect(h.rt.ctx.ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1)
|
||||
expect(out.proposal.lineage.ledger_version).toBe(1)
|
||||
|
||||
// I5: receipt + file; case closed with the receipt as outcome.
|
||||
const view = h.rt.ctx.caseDesk.read(out.case_id)
|
||||
expect(view.case.status).toBe('CLOSED_DONE')
|
||||
expect(view.permits).toHaveLength(1)
|
||||
expect(existsSync(h.rt.ctx.gateway.receipts.list()[0]!.value.artifact_ref)).toBe(true)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('with an LLM, the agents narrate but the numbers are byte-identical to the LLM-down run', async () => {
|
||||
const draftFor = (prompt: string) => {
|
||||
const tc = /tool call id is (tc-[\w-]+)/.exec(prompt)![1]!
|
||||
return { market_date: MARKET_DATE, prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' }, rationale: 'Evening peak carries the position; offer as price-taker.' }
|
||||
}
|
||||
const llm = new ScriptedLlm({
|
||||
'analysis-agent': { findings: [{ kind: 'TREND', summary: 'Load near seasonal norm; no anomalies.' }] },
|
||||
'trading-agent': draftFor,
|
||||
})
|
||||
const withLlm = await harness({ llm })
|
||||
const without = await harness({ llm: null })
|
||||
for (const h of [withLlm, without]) h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const a = await submitBid(withLlm)
|
||||
const b = await submitBid(without)
|
||||
expect(a.llm_used).toBe(true)
|
||||
expect(b.llm_used).toBe(false)
|
||||
expect(a.proposal.payload).toEqual(b.proposal.payload)
|
||||
expect(llm.prompts.map((p) => p.agentId)).toContain('trading-agent')
|
||||
await withLlm.rt.close()
|
||||
await without.rt.close()
|
||||
})
|
||||
|
||||
it('an LLM draft that references a tool call outside lineage cannot become a proposal (P2)', async () => {
|
||||
const llm = new ScriptedLlm({
|
||||
'trading-agent': { market_date: MARKET_DATE, prices_ref: { tool_call_id: 'tc-forged', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-forged', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-forged', path: 'expected_revenue_yuan' }, rationale: 'x' },
|
||||
})
|
||||
const h = await harness({ llm })
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
expect(res.status).toBe('failed')
|
||||
if (res.status === 'failed') expect(String(res.error.message)).toMatch(/not in this proposal's lineage/)
|
||||
expect(h.rt.ctx.caseDesk.proposals.list()).toHaveLength(0)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('human approval path (PENDING_HUMAN → resume)', () => {
|
||||
it('suspends outside the envelope, surfaces in the inbox, and a human approve releases it', async () => {
|
||||
const h = await harness({ llm: null }) // no envelopes → everything manual
|
||||
const out = await submitBid(h)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
expect(statuses(h, out.case_id).at(-1)).toBe('PENDING_HUMAN')
|
||||
const inbox = h.rt.ctx.caseDesk.inbox()
|
||||
expect(inbox).toHaveLength(1)
|
||||
expect(inbox[0]!.run_id).toBe(out.lifecycle_run_id)
|
||||
expect(inbox[0]!.reasons[0]).toMatch(/no ACTIVE envelope/)
|
||||
|
||||
const triggers = new TriggerService(h.rt)
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER, comment: 'ok' })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
|
||||
expect(statuses(h, out.case_id).slice(-4)).toEqual(['APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
|
||||
const view = h.rt.ctx.caseDesk.read(out.case_id)
|
||||
expect(view.approvals[0]!.approver).toEqual(APPROVER)
|
||||
expect(view.approvals[0]!.proposal_digest).toBe(out.proposal.digest)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('I1: the AI (or anyone in the origination chain) cannot approve; the run stays suspended', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
const triggers = new TriggerService(h.rt)
|
||||
for (const approver of [{ id: 'trading-agent', role: 'senior-trader' }, { id: 'user-x', role: 'agent' }, { id: 'bid-optimization-milp', role: 'ops-lead' }]) {
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver })
|
||||
expect(res.status).toBe('suspended')
|
||||
}
|
||||
expect(h.rt.ctx.events.list({ event_type: 'ApprovalRefused' })).toHaveLength(3)
|
||||
expect(h.rt.ctx.caseDesk.read(out.case_id).approvals).toHaveLength(0)
|
||||
const still = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status
|
||||
expect(still).toBe('PENDING_HUMAN')
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a human reject ends the run as REJECTED', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'reject', approver: APPROVER, comment: 'too aggressive' })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('REJECTED')
|
||||
expect(h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('REJECTED')
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a simulation alert escalates to a human even inside the envelope (one vote)', async () => {
|
||||
const h = await harness({ llm: null, config: { worstCaseLossBudgetYuan: '0', risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '10000' } } })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const out = await submitBid(h)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/loss budget/)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('durability: a suspended approval survives process restart', () => {
|
||||
it('resumes on a fresh runtime over the same data dir', async () => {
|
||||
const h1 = await harness({ llm: null })
|
||||
const out = await submitBid(h1)
|
||||
expect(out.lifecycle_status).toBe('suspended')
|
||||
await h1.rt.close()
|
||||
|
||||
const h2 = await harness({ llm: null, dataDir: h1.dataDir, seed: false })
|
||||
expect(h2.rt.ctx.caseDesk.inbox()[0]!.run_id).toBe(out.lifecycle_run_id)
|
||||
expect(h2.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('PENDING_HUMAN')
|
||||
// The ledger is in-memory in phase 1: replay its monthly anchor so the fresh check sees the same version.
|
||||
h2.rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
|
||||
const state = await h2.rt.mastra.getWorkflow('proposalLifecycle').getWorkflowRunById(out.lifecycle_run_id)
|
||||
expect(state?.status).toBe('suspended')
|
||||
|
||||
const res = await new TriggerService(h2.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
|
||||
expect(h2.rt.ctx.caseDesk.read(out.case_id).case.status).toBe('CLOSED_DONE')
|
||||
await h2.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleness and permits (I3/I4)', () => {
|
||||
it('approval goes STALE when the ledger moved between approval and fresh check', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
h.rt.ctx.ledger.append({ id: 'late', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
// Approval evidence is captured at resume time; the auto path captured version 1 in lineage.
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') {
|
||||
// The approval itself now references ledger v2 — consistent — so this passes fresh check;
|
||||
// the *auto-approved* path is the one bound to lineage v1. Exercise it below.
|
||||
expect(res.result.outcome).toBe('RELEASED')
|
||||
}
|
||||
await h.rt.close()
|
||||
|
||||
const g = await harness({ llm: null })
|
||||
g.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
// Move the ledger *during* the run: between fetch-context (lineage v1) and fresh check.
|
||||
g.rt.ctx.events.subscribe('Lifecycle.AUTO_APPROVED', () => {
|
||||
g.rt.ctx.ledger.append({ id: 'race', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: g.rt.ctx.ledger.read().version })
|
||||
})
|
||||
const out2 = await submitBid(g)
|
||||
expect(out2.lifecycle?.outcome).toBe('STALE')
|
||||
expect(out2.lifecycle?.reasons[0]).toMatch(/ledger version 1 at auto-approval, now 2/)
|
||||
expect(g.rt.ctx.gateway.receipts.list()).toHaveLength(0)
|
||||
await g.rt.close()
|
||||
})
|
||||
|
||||
it('permit expiry blocks a late release', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
// Resume after the permit TTL would already have passed relative to approval: the gate issues
|
||||
// the permit at `now`, then the gateway is asked at now + TTL + 1s.
|
||||
const triggers = new TriggerService(h.rt)
|
||||
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', () => h.setNow('2026-03-14T06:00:01Z')) // TTL is 6h
|
||||
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
expect(res.status).toBe('success')
|
||||
if (res.status === 'success') {
|
||||
expect(res.result.outcome).toBe('STALE')
|
||||
expect(res.result.reasons[0]).toMatch(/permit expired/)
|
||||
}
|
||||
expect(h.rt.ctx.gateway.receipts.list()).toHaveLength(0)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a revoked permit is refused by the gateway (kill switch)', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const out = await submitBid(h)
|
||||
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', (evt) => {
|
||||
h.rt.ctx.authority.revoke((evt.payload as { permit_id: string }).permit_id, 'L1 kill switch', h.rt.ctx.clock())
|
||||
})
|
||||
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
|
||||
if (res.status === 'success') expect(res.result.reasons[0]).toMatch(/revoked/)
|
||||
else throw new Error(res.status)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replay (I7) and authoritative store (I8)', () => {
|
||||
it('a released proposal re-checks identically from its snapshots and frozen pack version', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const out = await submitBid(h)
|
||||
const stored: Proposal = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value
|
||||
expect(proposalDigest(stored)).toBe(out.proposal.digest)
|
||||
const originalCheck = h.rt.ctx.events.list({ event_type: 'RuleCheckCompleted' })[0]!.payload as { result_ref: string }
|
||||
const recorded = h.rt.ctx.snapshots.get(originalCheck.result_ref) as { ok: boolean; rules_evaluated: string[] }
|
||||
// Replay against a ledger reconstructed to the lineage version (the bid append moved it to 2).
|
||||
const replayLedger = h.rt.ctx.ledger
|
||||
const replay = h.rt.ctx.policy.check({ ...stored, status: 'DRAFT' }, { id: 'hubei-spot-bidding', version: stored.lineage.policy_pack_version }, { ledger: { version: stored.lineage.ledger_version, entries: [] }, ledgerService: replayLedger, snapshots: h.rt.ctx.snapshots, now: h.rt.ctx.clock() })
|
||||
expect(replay.rules_evaluated).toEqual(recorded.rules_evaluated)
|
||||
expect(replay.ok).toBe(recorded.ok)
|
||||
// I8: the ledger (not any agent memory) is the source the proposal cites.
|
||||
expect(stored.lineage.ledger_version).toBe(1)
|
||||
expect(h.rt.ctx.ledger.read().entries.find((e) => e.kind === 'BID_SUBMITTED')?.source_ref).toBe(stored.digest)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger service', () => {
|
||||
it('scheduled entries fire once per local day and need no LLM', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const t = new TriggerService(h.rt)
|
||||
expect(await t.tick('2026-03-13T21:00:00Z')).toEqual([]) // 05:00 Shanghai
|
||||
expect(await t.tick('2026-03-13T22:30:00Z')).toEqual(['situation-0600'])
|
||||
expect(await t.tick('2026-03-14T00:05:00Z')).toEqual(['bid-0800'])
|
||||
expect(await t.tick('2026-03-14T00:10:00Z')).toEqual([])
|
||||
expect(h.rt.ctx.events.list({ event_type: 'WorkflowTriggered' }).map((e) => (e.payload as { workflow: string }).workflow)).toEqual(['dayAheadSituation', 'dayAheadBid'])
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('manual requests route to a registered template; router refuses when the LLM is down', async () => {
|
||||
const llm = new ScriptedLlm({ 'router-agent': { workflow_id: 'day-ahead-situation', params: { market_date: MARKET_DATE }, confidence: 'HIGH' } })
|
||||
const h = await harness({ llm })
|
||||
const t = new TriggerService(h.rt)
|
||||
const r = await t.manual('明天的态势报告')
|
||||
expect(r.decision.workflow_id).toBe('day-ahead-situation')
|
||||
expect(r.run_id).not.toBeNull()
|
||||
await h.rt.close()
|
||||
|
||||
const down = await harness({ llm: null })
|
||||
await expect(new TriggerService(down.rt).manual('anything')).rejects.toThrow(/pick a template explicitly/)
|
||||
await down.rt.close()
|
||||
})
|
||||
|
||||
it('an EXTREME situation opens an abnormal-day case (event trigger)', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
h.skills.spread = 1.2 // p90/p50 = 2.2 ≥ extreme ratio 2.0
|
||||
const t = new TriggerService(h.rt)
|
||||
t.startEventConsumers()
|
||||
await t.startTemplate('dayAheadSituation', { market_date: MARKET_DATE }, 'MANUAL', 'test')
|
||||
const cases = h.rt.ctx.caseDesk.cases.list().map((r) => r.value)
|
||||
expect(cases.some((c) => c.kind === 'ADHOC_ANALYSIS' && /Abnormal-day/.test(c.objective))).toBe(true)
|
||||
t.stop()
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
8
packages/runtime/tsconfig.json
Normal file
8
packages/runtime/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vpp/domain": "*",
|
||||
"decimal.js-light": "^2.5.1"
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"zod": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
|
||||
127
packages/services/src/authority.ts
Normal file
127
packages/services/src/authority.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { Approval, EnvelopeMatch, ExecutionPermit, Proposal, StaleDenial } from '@vpp/domain'
|
||||
import { ExecutionPermit as PermitSchema } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
import type { LedgerService } from './ledger.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
/**
|
||||
* Authority service (docs/03 §2.2, I3/I4): fresh-state check + permit issue +
|
||||
* revocation. Approval ≠ execution: hours may pass, the world changes. The
|
||||
* permit is the only credential a gateway accepts, short-lived and revocable.
|
||||
*/
|
||||
export interface FreshCheckContext {
|
||||
now: string
|
||||
ledger: LedgerService
|
||||
currentPolicyPackVersion: string
|
||||
/** Resources referenced by the proposal that are currently offline/de-rated. */
|
||||
offlineResources: string[]
|
||||
/** Permit lifetime for this proposal type. Bids: until market deadline — OPEN-QUESTION A1. */
|
||||
permitTtlMs: number
|
||||
}
|
||||
|
||||
export interface ApprovalBasis {
|
||||
/** Human approvals (may be multiple levels) — or none when auto-approved. */
|
||||
approvals: Approval[]
|
||||
/** Envelope match that auto-approved, if any. */
|
||||
envelopeMatch: EnvelopeMatch | null
|
||||
}
|
||||
|
||||
export interface ApproverIdentity {
|
||||
id: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/**
|
||||
* I1/I2: an approver must be a human role outside the proposal's origination
|
||||
* chain. Agents, the runtime, and any id appearing as originator are rejected.
|
||||
*/
|
||||
export function assertApproverIndependent(proposal: Proposal, approver: ApproverIdentity, humanRoles: string[]): void {
|
||||
const chain = new Set([proposal.originator.agent, proposal.originator.task_id, ...proposal.lineage.tool_calls.map((t) => t.tool)])
|
||||
if (chain.has(approver.id)) throw new Error(`approver ${approver.id} is in the origination chain (I1)`)
|
||||
if (!humanRoles.includes(approver.role)) throw new Error(`role '${approver.role}' may not approve (I1/I2: AI cannot approve)`)
|
||||
}
|
||||
|
||||
export class AuthorityService {
|
||||
constructor(
|
||||
readonly permits: Repository<ExecutionPermit> = new MemoryRepository(PermitSchema, (p) => p.id),
|
||||
private readonly idGen: () => string = () => `permit-${Math.random().toString(36).slice(2, 10)}`,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fresh-state check then permit. Denial reasons are exhaustive so the case
|
||||
* view can show *why* an approval went stale.
|
||||
*/
|
||||
authorize(proposal: Proposal, basis: ApprovalBasis, ctx: FreshCheckContext): ExecutionPermit | StaleDenial {
|
||||
const reasons: string[] = []
|
||||
const ledgerVersion = ctx.ledger.read().version
|
||||
|
||||
if (basis.approvals.length === 0 && !basis.envelopeMatch?.within) {
|
||||
reasons.push('no approval basis: neither human approval nor envelope auto-approval')
|
||||
}
|
||||
for (const a of basis.approvals) {
|
||||
if (a.decision !== 'APPROVE') reasons.push(`approval ${a.id} is ${a.decision}`)
|
||||
if (a.proposal_digest !== proposal.digest) reasons.push(`approval ${a.id} bound to digest ${a.proposal_digest.slice(0, 8)}…, proposal is ${proposal.digest.slice(0, 8)}…`)
|
||||
if (ctx.now < a.validity.from || ctx.now > a.validity.to) reasons.push(`approval ${a.id} validity [${a.validity.from}, ${a.validity.to}] does not cover ${ctx.now}`)
|
||||
if (a.evidence_versions.ledger_version !== ledgerVersion) reasons.push(`ledger version ${a.evidence_versions.ledger_version} at approval, now ${ledgerVersion}`)
|
||||
if (a.evidence_versions.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${a.evidence_versions.policy_pack_version} at approval, now ${ctx.currentPolicyPackVersion}`)
|
||||
}
|
||||
if (basis.envelopeMatch?.within) {
|
||||
if (basis.envelopeMatch.proposal_digest !== proposal.digest) reasons.push('envelope match bound to a different digest')
|
||||
if (proposal.lineage.ledger_version !== ledgerVersion) reasons.push(`ledger version ${proposal.lineage.ledger_version} at auto-approval, now ${ledgerVersion}`)
|
||||
if (proposal.lineage.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${proposal.lineage.policy_pack_version} at check, now ${ctx.currentPolicyPackVersion}`)
|
||||
}
|
||||
if (ctx.offlineResources.length > 0) reasons.push(`resources offline since approval: ${ctx.offlineResources.join(', ')}`)
|
||||
|
||||
if (reasons.length > 0) return { proposal_digest: proposal.digest, reasons, checked_at: ctx.now }
|
||||
|
||||
// Effect limits: narrowest of approval scopes; energy actually offered otherwise.
|
||||
const limits: Record<string, string> = {}
|
||||
for (const a of basis.approvals) for (const [k, v] of Object.entries(a.scope.limits)) {
|
||||
limits[k] = limits[k] === undefined || new Decimal(v).lt(limits[k]!) ? v : limits[k]!
|
||||
}
|
||||
if (proposal.type === 'BID' && limits['max_energy_mwh'] === undefined) {
|
||||
limits['max_energy_mwh'] = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)).toString()
|
||||
}
|
||||
const issuedAt = new Date(ctx.now)
|
||||
let expires = new Date(issuedAt.getTime() + ctx.permitTtlMs)
|
||||
for (const a of basis.approvals) if (new Date(a.validity.to) < expires) expires = new Date(a.validity.to)
|
||||
|
||||
const permit: ExecutionPermit = {
|
||||
id: this.idGen(),
|
||||
proposal_digest: proposal.digest,
|
||||
issued_at: issuedAt.toISOString(),
|
||||
expires_at: expires.toISOString(),
|
||||
effect_limits: limits,
|
||||
revoked_at: null,
|
||||
issuer: 'authority-service',
|
||||
}
|
||||
this.permits.put(permit)
|
||||
return permit
|
||||
}
|
||||
|
||||
revoke(permitId: string, reason: string, now: string): void {
|
||||
const row = this.permits.get(permitId)
|
||||
if (!row) throw new Error(`permit ${permitId} not found`)
|
||||
if (row.value.revoked_at) return
|
||||
this.permits.put({ ...row.value, revoked_at: now }, row.version)
|
||||
void reason
|
||||
}
|
||||
|
||||
/** Gateway-side validation (I4): digest match, in window, not revoked, on record. */
|
||||
validate(permit: ExecutionPermit, proposal: Proposal, now: string): string[] {
|
||||
const reasons: string[] = []
|
||||
const stored = this.permits.get(permit.id)?.value
|
||||
if (!stored) reasons.push(`permit ${permit.id} not on record`)
|
||||
else if (stored.revoked_at) reasons.push(`permit ${permit.id} revoked at ${stored.revoked_at}`)
|
||||
if (permit.proposal_digest !== proposal.digest) reasons.push('permit digest does not match proposal')
|
||||
if (now < permit.issued_at || now >= permit.expires_at) reasons.push(`permit expired at ${permit.expires_at} (now ${now})`)
|
||||
if (proposal.type === 'BID' && permit.effect_limits['max_energy_mwh'] !== undefined) {
|
||||
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.gt(permit.effect_limits['max_energy_mwh'])) reasons.push(`energy ${total.toString()} exceeds permit limit ${permit.effect_limits['max_energy_mwh']}`)
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
|
||||
export const isStaleDenial = (x: ExecutionPermit | StaleDenial): x is StaleDenial => 'reasons' in x
|
||||
180
packages/services/src/casedesk.ts
Normal file
180
packages/services/src/casedesk.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import type { Approval, CaseKind, DecisionCase, ExecutionPermit, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { Approval as ApprovalSchema, DecisionCase as DecisionCaseSchema, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import type { AuthorityService } from './authority.js'
|
||||
import type { EventBus } from './events.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Case Desk (docs/02 §5, ADR-0008): the operator's unit of accountability.
|
||||
* A case references proposals/approvals/permits by id — never copies — and
|
||||
* the inbox and case view are projections over those stores plus the event
|
||||
* log. Approval itself is a workflow resume (docs/09 §2); the desk only
|
||||
* records the human decision and hands it to the runtime's resume hook.
|
||||
*/
|
||||
export interface PendingApproval {
|
||||
proposal_id: string
|
||||
proposal_digest: string
|
||||
case_id: string
|
||||
run_id: string
|
||||
required_level: string
|
||||
reasons: string[]
|
||||
since: string
|
||||
}
|
||||
|
||||
export interface CaseView {
|
||||
case: DecisionCase
|
||||
proposals: Array<{ proposal: Proposal; pending: PendingApproval | null }>
|
||||
approvals: Approval[]
|
||||
permits: ExecutionPermit[]
|
||||
events: Array<{ event_id: string; event_type: string; occurred_at: string }>
|
||||
}
|
||||
|
||||
export interface LineageExpansion {
|
||||
proposal_id: string
|
||||
digest: string
|
||||
ledger_version: number
|
||||
policy_pack_version: string
|
||||
tool_calls: Array<ToolCallRef & { inputs: unknown; outputs: unknown }>
|
||||
data_refs: Array<{ ref: string; present: boolean }>
|
||||
}
|
||||
|
||||
export interface CaseDeskDeps {
|
||||
cases?: Repository<DecisionCase>
|
||||
proposals?: Repository<Proposal>
|
||||
approvals?: Repository<Approval>
|
||||
pending?: Repository<PendingApproval>
|
||||
authority: AuthorityService
|
||||
snapshots: SnapshotStore
|
||||
events: EventBus
|
||||
clock: () => string
|
||||
}
|
||||
|
||||
export class CaseDeskService {
|
||||
readonly cases: Repository<DecisionCase>
|
||||
readonly proposals: Repository<Proposal>
|
||||
readonly approvals: Repository<Approval>
|
||||
readonly pending: Repository<PendingApproval>
|
||||
|
||||
constructor(private readonly deps: CaseDeskDeps) {
|
||||
this.cases = deps.cases ?? new MemoryRepository(DecisionCaseSchema, (c) => c.id)
|
||||
this.proposals = deps.proposals ?? new MemoryRepository(ProposalSchema, (p) => p.id)
|
||||
this.approvals = deps.approvals ?? new MemoryRepository(ApprovalSchema, (a) => a.id)
|
||||
this.pending = deps.pending ?? new MemoryRepository(PendingApprovalSchema, (p) => p.proposal_id)
|
||||
}
|
||||
|
||||
open(input: { id: string; kind: CaseKind; objective: string; owner: string; deadline: string }): DecisionCase {
|
||||
const now = this.deps.clock()
|
||||
const c: DecisionCase = {
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
objective: input.objective,
|
||||
owner: input.owner,
|
||||
deadline: input.deadline,
|
||||
status: 'OPEN',
|
||||
refs: { evidence: [], proposals: [], approvals: [], permits: [], scenarios: [], outcome: null },
|
||||
opened_at: now,
|
||||
closed_at: null,
|
||||
}
|
||||
this.cases.put(c)
|
||||
this.deps.events.append({ event_type: 'CaseOpened', payload: { case_id: c.id, kind: c.kind }, correlation_id: c.id })
|
||||
return c
|
||||
}
|
||||
|
||||
private update(caseId: string, fn: (c: DecisionCase) => DecisionCase): DecisionCase {
|
||||
const row = this.cases.get(caseId)
|
||||
if (!row) throw new Error(`case ${caseId} not found`)
|
||||
return this.cases.put(fn(structuredClone(row.value)), row.version).value
|
||||
}
|
||||
|
||||
addEvidence(caseId: string, refs: string[]): void {
|
||||
this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, evidence: [...new Set([...c.refs.evidence, ...refs])] }, status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status }))
|
||||
}
|
||||
|
||||
/** Called by the lifecycle workflow whenever the proposal changes status. */
|
||||
recordProposal(caseId: string, proposal: Proposal): void {
|
||||
this.proposals.put(proposal)
|
||||
this.update(caseId, (c) => ({
|
||||
...c,
|
||||
status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status,
|
||||
refs: { ...c.refs, proposals: [...new Set([...c.refs.proposals, proposal.id])] },
|
||||
}))
|
||||
this.deps.events.append({ event_type: 'ProposalStatusChanged', payload: { proposal_id: proposal.id, digest: proposal.digest, status: proposal.status }, correlation_id: caseId, causation_id: proposal.id })
|
||||
}
|
||||
|
||||
/** Lifecycle suspended at the envelope gate: surface it in the inbox. */
|
||||
requestApproval(p: PendingApproval): void {
|
||||
this.pending.put(p)
|
||||
this.update(p.case_id, (c) => ({ ...c, status: 'AWAITING_APPROVAL' }))
|
||||
this.deps.events.append({ event_type: 'ApprovalRequested', payload: p, correlation_id: p.case_id, causation_id: p.proposal_id })
|
||||
}
|
||||
|
||||
inbox(): PendingApproval[] {
|
||||
return this.pending.list().map((r) => r.value).sort((a, b) => (a.since < b.since ? -1 : 1))
|
||||
}
|
||||
|
||||
recordApproval(caseId: string, approval: Approval): void {
|
||||
this.approvals.put(approval)
|
||||
const pending = this.pending.list().find((r) => r.value.proposal_digest === approval.proposal_digest)
|
||||
if (pending) this.pending.put({ ...pending.value, run_id: '' }) // keep for audit; runtime clears
|
||||
this.update(caseId, (c) => ({ ...c, status: 'IN_PROGRESS', refs: { ...c.refs, approvals: [...new Set([...c.refs.approvals, approval.id])] } }))
|
||||
this.deps.events.append({ event_type: 'HumanDecision', payload: approval, correlation_id: caseId, causation_id: approval.proposal_digest })
|
||||
}
|
||||
|
||||
clearPending(proposalId: string): void {
|
||||
const row = this.pending.get(proposalId)
|
||||
if (row) this.pending.put({ ...row.value, run_id: '' }, row.version)
|
||||
// MemoryRepository has no delete; a cleared entry is one with run_id ''.
|
||||
}
|
||||
|
||||
recordPermit(caseId: string, permit: ExecutionPermit): void {
|
||||
this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, permits: [...new Set([...c.refs.permits, permit.id])] } }))
|
||||
}
|
||||
|
||||
close(caseId: string, outcome: string | null, done: boolean): DecisionCase {
|
||||
const c = this.update(caseId, (c) => ({ ...c, status: done ? 'CLOSED_DONE' : 'CLOSED_ABORTED', refs: { ...c.refs, outcome }, closed_at: this.deps.clock() }))
|
||||
this.deps.events.append({ event_type: 'CaseClosed', payload: { case_id: caseId, status: c.status, outcome }, correlation_id: caseId })
|
||||
return c
|
||||
}
|
||||
|
||||
read(caseId: string): CaseView {
|
||||
const row = this.cases.get(caseId)
|
||||
if (!row) throw new Error(`case ${caseId} not found`)
|
||||
const c = row.value
|
||||
const pendingByProposal = new Map(this.pending.list().filter((r) => r.value.run_id !== '').map((r) => [r.value.proposal_id, r.value]))
|
||||
return {
|
||||
case: c,
|
||||
proposals: c.refs.proposals.map((id) => ({ proposal: this.proposals.get(id)!.value, pending: pendingByProposal.get(id) ?? null })),
|
||||
approvals: c.refs.approvals.map((id) => this.approvals.get(id)!.value),
|
||||
permits: c.refs.permits.map((id) => this.deps.authority.permits.get(id)!.value),
|
||||
events: this.deps.events.list({ correlation_id: caseId }).map((e) => ({ event_id: e.event_id, event_type: e.event_type, occurred_at: e.occurred_at })),
|
||||
}
|
||||
}
|
||||
|
||||
/** P5 in the UI: conclusion → tool outputs → data sources. */
|
||||
expandLineage(proposalId: string): LineageExpansion {
|
||||
const row = this.proposals.get(proposalId)
|
||||
if (!row) throw new Error(`proposal ${proposalId} not found`)
|
||||
const p = row.value
|
||||
return {
|
||||
proposal_id: p.id,
|
||||
digest: p.digest,
|
||||
ledger_version: p.lineage.ledger_version,
|
||||
policy_pack_version: p.lineage.policy_pack_version,
|
||||
tool_calls: p.lineage.tool_calls.map((tc) => ({ ...tc, inputs: this.deps.snapshots.get(tc.inputs_ref), outputs: this.deps.snapshots.get(tc.outputs_ref) })),
|
||||
data_refs: p.lineage.data_refs.map((ref) => ({ ref, present: this.deps.snapshots.has(ref) })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { z } from 'zod'
|
||||
export const PendingApprovalSchema = z.object({
|
||||
proposal_id: z.string().min(1),
|
||||
proposal_digest: z.string().min(1),
|
||||
case_id: z.string().min(1),
|
||||
run_id: z.string(),
|
||||
required_level: z.string().min(1),
|
||||
reasons: z.array(z.string()),
|
||||
since: z.string().min(1),
|
||||
})
|
||||
12
packages/services/src/digest.ts
Normal file
12
packages/services/src/digest.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { Proposal } from '@vpp/domain'
|
||||
import { canonicalJson, contentRef } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Proposal digest (docs/03 §1): sha256 over canonical payload + lineage. It is
|
||||
* what approvals and permits bind to (I3/I4) — any change to a number or to
|
||||
* its provenance yields a different digest and therefore a different approval.
|
||||
*/
|
||||
export const proposalDigest = (p: Pick<Proposal, 'payload' | 'lineage'>): string =>
|
||||
contentRef(canonicalJson({ payload: p.payload, lineage: p.lineage }))
|
||||
|
||||
export const digestMatches = (p: Proposal): boolean => proposalDigest(p) === p.digest
|
||||
107
packages/services/src/envelope.ts
Normal file
107
packages/services/src/envelope.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { ApprovalLevel, Curve96, Envelope, EnvelopeMatch, Proposal } from '@vpp/domain'
|
||||
import { Envelope as EnvelopeSchema } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
/**
|
||||
* Envelope gate (docs/03 §3, P3 tiered autonomy). Human-approved envelopes
|
||||
* define what may auto-approve; anything outside, or with no ACTIVE envelope,
|
||||
* needs a human. Bound keys for BID (docs/03 §3, values OPEN-QUESTION B1):
|
||||
* price_deviation_pct — |offer − baseline| / baseline ≤ pct, per interval
|
||||
* max_energy_mwh — Σ quantities ≤ max
|
||||
* The empty envelope set is the "everything manual" autonomy dial position.
|
||||
*/
|
||||
export interface EnvelopeContext {
|
||||
now: string
|
||||
/** Baseline the price bound is relative to (the P50 price forecast in lineage). */
|
||||
priceBaseline: Curve96 | null
|
||||
/** Level required when no envelope applies. OPEN-QUESTION B3. */
|
||||
fallbackLevel?: ApprovalLevel
|
||||
}
|
||||
|
||||
export class EnvelopeService {
|
||||
constructor(readonly repo: Repository<Envelope> = new MemoryRepository(EnvelopeSchema, (e) => e.id)) {}
|
||||
|
||||
register(envelope: Envelope): void {
|
||||
this.repo.put(envelope)
|
||||
}
|
||||
|
||||
match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch {
|
||||
const fallback = ctx.fallbackLevel ?? 'L2'
|
||||
const candidates = this.repo
|
||||
.list()
|
||||
.map((r) => r.value)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.status === 'ACTIVE' &&
|
||||
e.scope.proposal_type === proposal.type &&
|
||||
e.scope.timescales.includes(proposal.timescale) &&
|
||||
e.validity.from <= ctx.now &&
|
||||
ctx.now <= e.validity.to,
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: fallback,
|
||||
reasons: [`no ACTIVE envelope covers ${proposal.type}/${proposal.timescale} at ${ctx.now}`],
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
|
||||
const reasonsByEnvelope: string[] = []
|
||||
for (const env of candidates) {
|
||||
const reasons = this.checkBounds(proposal, env, ctx)
|
||||
if (reasons.length === 0) {
|
||||
return { proposal_digest: proposal.digest, within: true, envelope_id: env.id, required_level: env.approval.level, reasons: [], checked_at: ctx.now }
|
||||
}
|
||||
reasonsByEnvelope.push(...reasons.map((r) => `${env.id}: ${r}`))
|
||||
}
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
within: false,
|
||||
envelope_id: null,
|
||||
required_level: fallback,
|
||||
reasons: reasonsByEnvelope,
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
|
||||
private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] {
|
||||
if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1']
|
||||
const reasons: string[] = []
|
||||
const maxEnergy = env.bounds['max_energy_mwh']
|
||||
if (maxEnergy !== undefined) {
|
||||
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.gt(maxEnergy)) reasons.push(`daily energy ${total.toString()} > max_energy_mwh ${maxEnergy}`)
|
||||
}
|
||||
const pct = env.bounds['price_deviation_pct']
|
||||
if (pct !== undefined) {
|
||||
if (!ctx.priceBaseline) {
|
||||
reasons.push('price_deviation_pct bound requires a price baseline in lineage')
|
||||
} else {
|
||||
const tol = new Decimal(pct).div(100)
|
||||
let worst: { i: number; dev: Decimal } | null = null
|
||||
proposal.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
|
||||
const base = new Decimal(ctx.priceBaseline!.values[i]!)
|
||||
const offer = new Decimal(v)
|
||||
// A price-taker offer (0) never exceeds the baseline; only offers above baseline are risky.
|
||||
if (offer.lte(base)) return
|
||||
if (base.eq(0)) {
|
||||
worst = { i, dev: new Decimal('Infinity' as unknown as number) }
|
||||
return
|
||||
}
|
||||
const dev = offer.sub(base).div(base)
|
||||
if (dev.gt(tol) && (!worst || dev.gt(worst.dev))) worst = { i, dev }
|
||||
})
|
||||
if (worst) {
|
||||
const w = worst as { i: number; dev: Decimal }
|
||||
reasons.push(`interval ${w.i}: offer deviates ${w.dev.mul(100).toFixed(2)}% above baseline, bound ${pct}%`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
104
packages/services/src/events.ts
Normal file
104
packages/services/src/events.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { EventEnvelope } from '@vpp/domain'
|
||||
import { EventEnvelope as EventEnvelopeSchema } from '@vpp/domain'
|
||||
|
||||
/**
|
||||
* Event store + bus (P5 event sourcing, ADR-0004 outbox). Append-only log of
|
||||
* EventEnvelopes; consumers subscribe by type. Phase-1 backing is a JSONL
|
||||
* file (survives restarts, trivially auditable); the Postgres outbox adapter
|
||||
* implements the same interface without leaking storage specifics.
|
||||
*/
|
||||
export interface EventFilter {
|
||||
event_type?: string
|
||||
correlation_id?: string
|
||||
}
|
||||
|
||||
export type EventHandler = (evt: EventEnvelope) => void | Promise<void>
|
||||
|
||||
export interface EventBus {
|
||||
append(input: {
|
||||
event_type: string
|
||||
payload: unknown
|
||||
correlation_id: string
|
||||
causation_id?: string
|
||||
schema_version?: string
|
||||
}): EventEnvelope
|
||||
list(filter?: EventFilter): EventEnvelope[]
|
||||
subscribe(event_type: string, handler: EventHandler): () => void
|
||||
}
|
||||
|
||||
export class MemoryEventBus implements EventBus {
|
||||
protected readonly events: EventEnvelope[] = []
|
||||
private readonly handlers = new Map<string, Set<EventHandler>>()
|
||||
private seq = 0
|
||||
|
||||
constructor(
|
||||
protected readonly clock: () => string = () => new Date().toISOString(),
|
||||
protected readonly prefix = 'evt',
|
||||
) {}
|
||||
|
||||
append(input: {
|
||||
event_type: string
|
||||
payload: unknown
|
||||
correlation_id: string
|
||||
causation_id?: string
|
||||
schema_version?: string
|
||||
}): EventEnvelope {
|
||||
const evt: EventEnvelope = EventEnvelopeSchema.parse({
|
||||
event_id: `${this.prefix}-${String(++this.seq).padStart(6, '0')}`,
|
||||
event_type: input.event_type,
|
||||
schema_version: input.schema_version ?? '1.0.0',
|
||||
occurred_at: this.clock(),
|
||||
causation_id: input.causation_id ?? input.correlation_id,
|
||||
correlation_id: input.correlation_id,
|
||||
payload: input.payload,
|
||||
})
|
||||
this.persist(evt)
|
||||
this.events.push(evt)
|
||||
for (const h of this.handlers.get(evt.event_type) ?? []) void h(evt)
|
||||
for (const h of this.handlers.get('*') ?? []) void h(evt)
|
||||
return evt
|
||||
}
|
||||
|
||||
protected persist(_evt: EventEnvelope): void {}
|
||||
|
||||
list(filter: EventFilter = {}): EventEnvelope[] {
|
||||
return this.events.filter(
|
||||
(e) =>
|
||||
(filter.event_type === undefined || e.event_type === filter.event_type) &&
|
||||
(filter.correlation_id === undefined || e.correlation_id === filter.correlation_id),
|
||||
)
|
||||
}
|
||||
|
||||
subscribe(event_type: string, handler: EventHandler): () => void {
|
||||
const set = this.handlers.get(event_type) ?? new Set()
|
||||
set.add(handler)
|
||||
this.handlers.set(event_type, set)
|
||||
return () => set.delete(handler)
|
||||
}
|
||||
|
||||
protected load(events: EventEnvelope[]): void {
|
||||
this.events.push(...events)
|
||||
this.seq = events.length
|
||||
}
|
||||
}
|
||||
|
||||
/** JSONL-backed bus: every appended event is fsync'd to disk before handlers run. */
|
||||
export class FileEventBus extends MemoryEventBus {
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
clock?: () => string,
|
||||
) {
|
||||
super(clock, 'evt')
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
if (existsSync(path)) {
|
||||
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
||||
this.load(lines.map((l) => EventEnvelopeSchema.parse(JSON.parse(l))))
|
||||
}
|
||||
}
|
||||
|
||||
protected override persist(evt: EventEnvelope): void {
|
||||
appendFileSync(this.path, JSON.stringify(evt) + '\n')
|
||||
}
|
||||
}
|
||||
75
packages/services/src/gateway.ts
Normal file
75
packages/services/src/gateway.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain'
|
||||
import { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain'
|
||||
import type { AuthorityService } from './authority.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository } from './relational.js'
|
||||
|
||||
export class GatewayRejected extends Error {
|
||||
constructor(readonly reasons: string[]) {
|
||||
super(`gateway rejected: ${reasons.join('; ')}`)
|
||||
this.name = 'GatewayRejected'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway port (docs/03 §2.2, docs/11 §1.1): accepts only (Proposal, Permit)
|
||||
* pairs, validates through the authority service, and is idempotent by
|
||||
* (digest, permit) — re-dispatch returns the persisted receipt (I5).
|
||||
*/
|
||||
export interface GatewayPort {
|
||||
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt
|
||||
}
|
||||
|
||||
/**
|
||||
* M3 release path: bid file for manual upload to the trading platform — the
|
||||
* degraded channel by design (docs/06, ROADMAP M3). The programmatic channel
|
||||
* is phase 2 (D2) and will implement the same port.
|
||||
*/
|
||||
export class FileExportGateway implements GatewayPort {
|
||||
constructor(
|
||||
private readonly authority: AuthorityService,
|
||||
private readonly exportDir: string,
|
||||
readonly receipts: Repository<ExecutionReceipt> = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key),
|
||||
) {
|
||||
mkdirSync(exportDir, { recursive: true })
|
||||
}
|
||||
|
||||
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt {
|
||||
const key = `${proposal.digest}:${permit.id}`
|
||||
const existing = this.receipts.get(key)
|
||||
if (existing) return existing.value
|
||||
|
||||
const reasons = this.authority.validate(permit, proposal, now)
|
||||
if (reasons.length > 0) throw new GatewayRejected(reasons)
|
||||
if (proposal.type !== 'BID') throw new GatewayRejected([`file export supports BID only, got ${proposal.type}`])
|
||||
|
||||
const file: BidExportFile = BidExportFileSchema.parse({
|
||||
format_version: '1.0.0',
|
||||
proposal_id: proposal.id,
|
||||
proposal_digest: proposal.digest,
|
||||
permit_id: permit.id,
|
||||
proposal_type: proposal.type,
|
||||
market_date: proposal.payload.market_date,
|
||||
prices_yuan_per_mwh: proposal.payload.prices_yuan_per_mwh,
|
||||
quantities_mwh: proposal.payload.quantities_mwh,
|
||||
exported_at: now,
|
||||
})
|
||||
const name = `bid-${file.market_date}-${proposal.id}-${permit.id}.json`
|
||||
const path = join(this.exportDir, name)
|
||||
if (!existsSync(path)) writeFileSync(path, JSON.stringify(file, null, 2) + '\n', { flag: 'wx' })
|
||||
|
||||
const receipt: ExecutionReceipt = {
|
||||
receipt_id: `rcpt-${proposal.id}-${permit.id}`,
|
||||
proposal_digest: proposal.digest,
|
||||
permit_id: permit.id,
|
||||
channel: 'FILE_EXPORT',
|
||||
idempotency_key: key,
|
||||
artifact_ref: path,
|
||||
accepted_at: now,
|
||||
}
|
||||
this.receipts.put(receipt)
|
||||
return receipt
|
||||
}
|
||||
}
|
||||
@ -5,3 +5,13 @@ export * from './timeseries.js'
|
||||
export * from './relational.js'
|
||||
export * from './ingest.js'
|
||||
export { Decimal } from './decimal.js'
|
||||
export * from './digest.js'
|
||||
export * from './policy.js'
|
||||
export * from './envelope.js'
|
||||
export * from './authority.js'
|
||||
export * from './gateway.js'
|
||||
export * from './events.js'
|
||||
export * from './lineage.js'
|
||||
export * from './simulation.js'
|
||||
export * from './casedesk.js'
|
||||
export * from './skillclient.js'
|
||||
|
||||
120
packages/services/src/lineage.ts
Normal file
120
packages/services/src/lineage.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain'
|
||||
import { proposalDigest } from './digest.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
|
||||
/**
|
||||
* Lineage recorder (docs/09 §4): every skill call records
|
||||
* {tool, version, inputs_ref, outputs_ref}; the buffer travels with the
|
||||
* workflow run via AsyncLocalStorage and is frozen into the Proposal.
|
||||
*/
|
||||
export class LineageRecorder {
|
||||
private readonly als = new AsyncLocalStorage<{ calls: ToolCallRef[]; dataRefs: Set<string> }>()
|
||||
|
||||
run<T>(fn: () => T): T {
|
||||
return this.als.run({ calls: [], dataRefs: new Set() }, fn)
|
||||
}
|
||||
|
||||
record(call: ToolCallRef): void {
|
||||
const store = this.als.getStore()
|
||||
if (!store) throw new Error('lineage.record called outside a lineage.run scope')
|
||||
store.calls.push(call)
|
||||
}
|
||||
|
||||
addDataRef(ref: string): void {
|
||||
this.als.getStore()?.dataRefs.add(ref)
|
||||
}
|
||||
|
||||
current(): { tool_calls: ToolCallRef[]; data_refs: string[] } {
|
||||
const store = this.als.getStore()
|
||||
if (!store) throw new Error('lineage.current called outside a lineage.run scope')
|
||||
return { tool_calls: [...store.calls], data_refs: [...store.dataRefs].sort() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap any deterministic skill call so it is snapshotted and recorded. */
|
||||
export function withLineage<I, O>(
|
||||
lineage: LineageRecorder,
|
||||
snapshots: SnapshotStore,
|
||||
def: { tool: string; version: string; idGen: () => string },
|
||||
call: (input: I) => O,
|
||||
): (input: I) => { output: O; call: ToolCallRef } {
|
||||
return (input: I) => {
|
||||
const inputs_ref = snapshots.put(input)
|
||||
const output = call(input)
|
||||
const outputs_ref = snapshots.put(output)
|
||||
const ref: ToolCallRef = { tool_call_id: def.idGen(), tool: def.tool, version: def.version, inputs_ref, outputs_ref }
|
||||
lineage.record(ref)
|
||||
return { output, call: ref }
|
||||
}
|
||||
}
|
||||
|
||||
export class LineageRefError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'LineageRefError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Dereference {tool_call_id, path} into the recorded output snapshot. */
|
||||
export function resolveRef(ref: LineageRef, calls: ToolCallRef[], snapshots: SnapshotStore): unknown {
|
||||
const call = calls.find((c) => c.tool_call_id === ref.tool_call_id)
|
||||
if (!call) throw new LineageRefError(`tool call ${ref.tool_call_id} is not in this proposal's lineage`)
|
||||
const output = snapshots.get(call.outputs_ref)
|
||||
if (output === undefined) throw new LineageRefError(`output snapshot for ${ref.tool_call_id} missing`)
|
||||
let node: unknown = output
|
||||
for (const part of ref.path.split('.')) {
|
||||
if (node === null || typeof node !== 'object') throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
||||
node = Array.isArray(node) ? node[Number(part)] : (node as Record<string, unknown>)[part]
|
||||
if (node === undefined) throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* The P2 assembler (docs/09 §3): builds a Proposal from an LLM draft that
|
||||
* contains only references. Numbers are dereferenced from lineage snapshots;
|
||||
* a draft with a literal number is rejected by the schema before we get here.
|
||||
*/
|
||||
export function assembleBidProposal(
|
||||
draft: BidProposalDraft,
|
||||
ctx: {
|
||||
id: string
|
||||
originator: Proposal['originator']
|
||||
lineage: { tool_calls: ToolCallRef[]; data_refs: string[] }
|
||||
ledger_version: number
|
||||
policy_pack_version: string
|
||||
snapshots: SnapshotStore
|
||||
now: string
|
||||
},
|
||||
): Proposal {
|
||||
const prices = Curve96.parse(resolveRef(draft.prices_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const quantities = Curve96.parse(resolveRef(draft.quantities_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const revenue = DecimalString.parse(resolveRef(draft.expected_revenue_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
||||
const payload: BidPayload = BidPayloadSchema.parse({
|
||||
kind: 'BID',
|
||||
market_date: draft.market_date,
|
||||
prices_yuan_per_mwh: prices,
|
||||
quantities_mwh: quantities,
|
||||
expected_revenue_yuan: revenue,
|
||||
})
|
||||
const lineage = {
|
||||
tool_calls: ctx.lineage.tool_calls,
|
||||
data_refs: ctx.lineage.data_refs,
|
||||
ledger_version: ctx.ledger_version,
|
||||
policy_pack_version: ctx.policy_pack_version,
|
||||
}
|
||||
const base = {
|
||||
id: ctx.id,
|
||||
type: 'BID' as const,
|
||||
timescale: 'DAY_AHEAD' as const,
|
||||
originator: ctx.originator,
|
||||
payload,
|
||||
lineage,
|
||||
envelope_ref: null,
|
||||
status: 'DRAFT' as const,
|
||||
created_at: ctx.now,
|
||||
}
|
||||
return { ...base, digest: proposalDigest(base) }
|
||||
}
|
||||
210
packages/services/src/policy.ts
Normal file
210
packages/services/src/policy.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain'
|
||||
import { digestMatches } from './digest.js'
|
||||
import type { LedgerService } from './ledger.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
import { Decimal } from './decimal.js'
|
||||
|
||||
/**
|
||||
* Policy engine (docs/05 §4.1, docs/03 §2 规则校核): versioned, tested,
|
||||
* executable rule packs. Rules are deterministic functions of the proposal
|
||||
* and its evidence context; the pack version is frozen into lineage so any
|
||||
* historical check is replayable (I7). Old packs stay in the registry.
|
||||
*/
|
||||
export interface RuleContext {
|
||||
ledger: LedgerView
|
||||
ledgerService: LedgerService
|
||||
snapshots: SnapshotStore
|
||||
now: string
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
rule_id: string
|
||||
severity: 'REJECT' | 'WARN'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: string
|
||||
description: string
|
||||
/** Knowledge-base entries this rule implements (explainability: rule → source). */
|
||||
source_refs: string[]
|
||||
check(proposal: Proposal, ctx: RuleContext): Violation[]
|
||||
}
|
||||
|
||||
export interface PolicyPack {
|
||||
id: string
|
||||
version: string
|
||||
effective: { from: string; to: string | null }
|
||||
rules: Rule[]
|
||||
}
|
||||
|
||||
export class PolicyEngine {
|
||||
private readonly packs = new Map<string, PolicyPack>()
|
||||
|
||||
constructor(packs: PolicyPack[]) {
|
||||
for (const p of packs) this.packs.set(`${p.id}@${p.version}`, p)
|
||||
}
|
||||
|
||||
pack(id: string, version: string): PolicyPack {
|
||||
const p = this.packs.get(`${id}@${version}`)
|
||||
if (!p) throw new Error(`policy pack ${id}@${version} not registered`)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Latest pack by version string order (versions are YYYY.MM, lexicographically ordered). */
|
||||
current(id: string): PolicyPack {
|
||||
const versions = [...this.packs.values()].filter((p) => p.id === id).sort((a, b) => (a.version < b.version ? 1 : -1))
|
||||
if (versions.length === 0) throw new Error(`no policy pack registered for ${id}`)
|
||||
return versions[0]!
|
||||
}
|
||||
|
||||
check(proposal: Proposal, packRef: { id: string; version: string }, ctx: RuleContext): ValidationResult {
|
||||
const pack = this.pack(packRef.id, packRef.version)
|
||||
const violations: Violation[] = []
|
||||
const evaluated: string[] = []
|
||||
for (const rule of pack.rules) {
|
||||
evaluated.push(rule.id)
|
||||
violations.push(...rule.check(proposal, ctx))
|
||||
}
|
||||
return {
|
||||
proposal_digest: proposal.digest,
|
||||
policy_pack: { id: pack.id, version: pack.version },
|
||||
ok: !violations.some((v) => v.severity === 'REJECT'),
|
||||
violations,
|
||||
rules_evaluated: evaluated,
|
||||
checked_at: ctx.now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hubei spot bidding pack v1. Numeric limits below are placeholders pending
|
||||
// OPEN-QUESTION D5 (首批转为政策包的湖北规则清单) and A1/A4/A5 — they are named
|
||||
// config so the values can be replaced without touching rule logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HubeiBidPackConfig {
|
||||
// OPEN-QUESTION D5: Hubei day-ahead price floor/cap for VPP offers.
|
||||
priceFloorYuanPerMwh: string
|
||||
priceCapYuanPerMwh: string
|
||||
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
|
||||
originators: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
|
||||
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
|
||||
priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5
|
||||
originators: { BID: ['trading-agent', 'operator'] },
|
||||
}
|
||||
|
||||
const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message })
|
||||
|
||||
export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): PolicyPack {
|
||||
return {
|
||||
id: 'hubei-spot-bidding',
|
||||
version,
|
||||
effective: { from: '2026-01-01T00:00:00Z', to: null },
|
||||
rules: [
|
||||
{
|
||||
id: 'digest-valid',
|
||||
description: 'digest equals sha256(canonical payload + lineage)',
|
||||
source_refs: ['docs/03 §1'],
|
||||
check: (p) => (digestMatches(p) ? [] : [reject('digest-valid', 'digest does not match payload + lineage')]),
|
||||
},
|
||||
{
|
||||
id: 'bid-format',
|
||||
description: '96 intervals, curves dated on market_date',
|
||||
source_refs: ['docs/11 §3.3', 'D5'],
|
||||
check: (p) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const out: Violation[] = []
|
||||
for (const [name, c] of [['prices_yuan_per_mwh', p.payload.prices_yuan_per_mwh], ['quantities_mwh', p.payload.quantities_mwh]] as const) {
|
||||
if (c.date !== p.payload.market_date) out.push(reject('bid-format', `${name} dated ${c.date}, market_date is ${p.payload.market_date}`))
|
||||
if (c.values.length !== 96) out.push(reject('bid-format', `${name} has ${c.values.length} values`))
|
||||
}
|
||||
if (p.timescale !== 'DAY_AHEAD') out.push(reject('bid-format', `BID must be DAY_AHEAD, got ${p.timescale}`))
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'price-limits',
|
||||
description: 'every offer price within the market floor/cap',
|
||||
source_refs: ['D5'],
|
||||
check: (p) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const floor = new Decimal(cfg.priceFloorYuanPerMwh)
|
||||
const cap = new Decimal(cfg.priceCapYuanPerMwh)
|
||||
const out: Violation[] = []
|
||||
p.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
|
||||
const d = new Decimal(v)
|
||||
if (d.lt(floor) || d.gt(cap)) out.push(reject('price-limits', `interval ${i}: price ${v} outside [${floor}, ${cap}]`))
|
||||
})
|
||||
return out.slice(0, 5)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quantity-non-negative',
|
||||
description: 'offer quantities are non-negative',
|
||||
source_refs: ['D5'],
|
||||
check: (p) =>
|
||||
p.type === 'BID' && p.payload.quantities_mwh.values.some((v) => new Decimal(v).lt(0))
|
||||
? [reject('quantity-non-negative', 'negative quantity in bid')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: 'ledger-consistency',
|
||||
description: 'lineage ledger version is current and daily energy is inside the cascade band (P7)',
|
||||
source_refs: ['docs/00 §4', 'A5'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'BID') return []
|
||||
const out: Violation[] = []
|
||||
if (p.lineage.ledger_version !== ctx.ledger.version) {
|
||||
out.push(reject('ledger-consistency', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`))
|
||||
}
|
||||
try {
|
||||
const b = ctx.ledgerService.dayAheadBounds(p.payload.market_date)
|
||||
const total = p.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
if (total.lt(b.daily_energy_min_mwh) || total.gt(b.daily_energy_max_mwh)) {
|
||||
out.push(reject('ledger-consistency', `daily energy ${total.toString()} outside [${b.daily_energy_min_mwh}, ${b.daily_energy_max_mwh}]`))
|
||||
}
|
||||
} catch (e) {
|
||||
out.push(reject('ledger-consistency', (e as Error).message))
|
||||
}
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lineage-integrity',
|
||||
description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)',
|
||||
source_refs: ['docs/03 §1', 'docs/01 P2'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'BID') return []
|
||||
if (p.lineage.tool_calls.length === 0) return [reject('lineage-integrity', 'no tool calls in lineage')]
|
||||
const outputs: Record<string, unknown>[] = []
|
||||
for (const tc of p.lineage.tool_calls) {
|
||||
const out = ctx.snapshots.get(tc.outputs_ref)
|
||||
if (out === undefined) return [reject('lineage-integrity', `tool call ${tc.tool_call_id}: output snapshot ${tc.outputs_ref.slice(0, 8)}… missing`)]
|
||||
if (out && typeof out === 'object') outputs.push(out as Record<string, unknown>)
|
||||
}
|
||||
const found = (pred: (o: Record<string, unknown>) => boolean) => outputs.some(pred)
|
||||
const eqCurve = (a: unknown, b: { values: string[] }) =>
|
||||
!!a && typeof a === 'object' && JSON.stringify((a as { values?: unknown }).values) === JSON.stringify(b.values)
|
||||
const out: Violation[] = []
|
||||
if (!found((o) => eqCurve(o['prices_yuan_per_mwh'], p.payload.prices_yuan_per_mwh))) out.push(reject('lineage-integrity', 'prices_yuan_per_mwh not traceable to a tool output'))
|
||||
if (!found((o) => eqCurve(o['quantities_mwh'], p.payload.quantities_mwh))) out.push(reject('lineage-integrity', 'quantities_mwh not traceable to a tool output'))
|
||||
if (!found((o) => o['expected_revenue_yuan'] === p.payload.expected_revenue_yuan)) out.push(reject('lineage-integrity', 'expected_revenue_yuan not traceable to a tool output'))
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'originator-permission',
|
||||
description: 'originating agent is allowed to originate this proposal type (I2)',
|
||||
source_refs: ['docs/03 §2', 'docs/06 §4'],
|
||||
check: (p) =>
|
||||
(cfg.originators[p.type] ?? []).includes(p.originator.agent)
|
||||
? []
|
||||
: [reject('originator-permission', `${p.originator.agent} may not originate ${p.type}`)],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { z } from 'zod'
|
||||
import { ResourceProfile as ResourceProfileSchema } from '@vpp/domain'
|
||||
import type { ResourceProfile } from '@vpp/domain'
|
||||
@ -53,6 +55,11 @@ export class MemoryRepository<T> implements Repository<T> {
|
||||
return [...this.rows.keys()].sort().map((k) => this.get(k)!)
|
||||
}
|
||||
|
||||
/** Load a persisted row verbatim (versions preserved). */
|
||||
protected restore(row: Row<T>): void {
|
||||
this.rows.set(row.key, row)
|
||||
}
|
||||
|
||||
put(value: T, expectedVersion?: number): Row<T> {
|
||||
const parsed = this.schema.parse(value)
|
||||
const key = this.keyOf(parsed)
|
||||
@ -76,3 +83,30 @@ export type ResourceRegistry = Repository<ResourceProfile>
|
||||
|
||||
export const createResourceRegistry = (clock?: () => string): ResourceRegistry =>
|
||||
new MemoryRepository(ResourceProfileSchema, (r) => r.resource_id, clock)
|
||||
|
||||
/**
|
||||
* JSON-file-backed repository: full rewrite on every put (row counts in
|
||||
* phase 1 are tiny: cases, envelopes, permits). Enough for "a suspended
|
||||
* approval survives process restart" until the Postgres adapter (M3+).
|
||||
*/
|
||||
export class FsRepository<T> extends MemoryRepository<T> {
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
schema: z.ZodType<T>,
|
||||
keyOf: (value: T) => string,
|
||||
clock?: () => string,
|
||||
) {
|
||||
super(schema, keyOf, clock)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
if (existsSync(path)) {
|
||||
const rows = JSON.parse(readFileSync(path, 'utf8')) as Row<T>[]
|
||||
for (const r of rows) this.restore({ ...r, value: schema.parse(r.value) })
|
||||
}
|
||||
}
|
||||
|
||||
override put(value: T, expectedVersion?: number): Row<T> {
|
||||
const row = super.put(value, expectedVersion)
|
||||
writeFileSync(this.path, JSON.stringify(this.list(), null, 1) + '\n')
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
105
packages/services/src/simulation.ts
Normal file
105
packages/services/src/simulation.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import type { ForecastBundle, Proposal, SimulationResult } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
|
||||
/**
|
||||
* Simulation port (docs/03 §2 仿真验证; docs/05 §2.3 收益情景回测). Bids are
|
||||
* stress-tested against many price scenarios drawn around the forecast band;
|
||||
* any alert escalates to a human — one vote, no exceptions.
|
||||
*
|
||||
* v1 is deterministic (seeded) and in-process. Scenario paths are built from
|
||||
* the P10/P50/P90 band by a seeded uniform draw per interval, interpolated
|
||||
* piecewise-linearly, with per-day correlated shocks so extreme days exist.
|
||||
*/
|
||||
export interface SimulationPort {
|
||||
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult
|
||||
}
|
||||
|
||||
export interface SimulationContext {
|
||||
now: string
|
||||
priceForecast: ForecastBundle | null
|
||||
marginalCostYuanPerMwh: string
|
||||
/**
|
||||
* Alert when the 5th-percentile scenario revenue net of cost falls below
|
||||
* −budget. OPEN-QUESTION B5 (单日亏损预算) — placeholder.
|
||||
*/
|
||||
worstCaseLossBudgetYuan: string
|
||||
scenarioCount?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
/** Small deterministic PRNG (mulberry32) — enough for scenario sampling. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export class RevenueScenarioSimulator implements SimulationPort {
|
||||
static readonly NAME = 'revenue-scenarios'
|
||||
static readonly VERSION = '1.0.0'
|
||||
|
||||
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult {
|
||||
const seed = ctx.seed ?? 20260315
|
||||
const n = ctx.scenarioCount ?? 1000
|
||||
const alerts: string[] = []
|
||||
const base = { proposal_digest: proposal.digest, kind: 'REVENUE_SCENARIOS' as const, simulator: { name: RevenueScenarioSimulator.NAME, version: RevenueScenarioSimulator.VERSION, seed }, simulated_at: ctx.now }
|
||||
|
||||
if (proposal.type !== 'BID') {
|
||||
return { ...base, alert: true, alerts: ['revenue simulation only supports BID'], metrics: {}, scenario_count: 0 }
|
||||
}
|
||||
if (!ctx.priceForecast) {
|
||||
return { ...base, alert: true, alerts: ['no price forecast in lineage to simulate against'], metrics: {}, scenario_count: 0 }
|
||||
}
|
||||
|
||||
const p10 = ctx.priceForecast.quantiles.p10.values.map(Number)
|
||||
const p50 = ctx.priceForecast.quantiles.p50.values.map(Number)
|
||||
const p90 = ctx.priceForecast.quantiles.p90.values.map(Number)
|
||||
const offers = proposal.payload.prices_yuan_per_mwh.values.map(Number)
|
||||
const qty = proposal.payload.quantities_mwh.values.map(Number)
|
||||
const cost = Number(ctx.marginalCostYuanPerMwh)
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const revenues: number[] = []
|
||||
for (let s = 0; s < n; s++) {
|
||||
const dayShock = rand() // correlated across intervals: a "high day" or "low day"
|
||||
let rev = 0
|
||||
for (let t = 0; t < 96; t++) {
|
||||
const u = 0.5 * dayShock + 0.5 * rand()
|
||||
// Map u∈[0,1] onto the band: 0→below P10, 0.5→P50, 1→above P90 (tails extend 50%).
|
||||
let price: number
|
||||
if (u < 0.1) price = p10[t]! - (0.1 - u) / 0.1 * 0.5 * (p50[t]! - p10[t]!)
|
||||
else if (u < 0.5) price = p10[t]! + (u - 0.1) / 0.4 * (p50[t]! - p10[t]!)
|
||||
else if (u < 0.9) price = p50[t]! + (u - 0.5) / 0.4 * (p90[t]! - p50[t]!)
|
||||
else price = p90[t]! + (u - 0.9) / 0.1 * 0.5 * (p90[t]! - p50[t]!)
|
||||
if (offers[t]! <= price) rev += (price - cost) * qty[t]!
|
||||
}
|
||||
revenues.push(rev)
|
||||
}
|
||||
revenues.sort((a, b) => a - b)
|
||||
const pct = (p: number) => revenues[Math.min(n - 1, Math.floor(p * n))]!
|
||||
const p05 = pct(0.05)
|
||||
const worstLoss = Math.max(0, -revenues[0]!)
|
||||
const budget = Number(ctx.worstCaseLossBudgetYuan)
|
||||
if (p05 < -budget) alerts.push(`P05 scenario revenue ${p05.toFixed(2)} below loss budget −${budget}`)
|
||||
if (qty.every((q) => q === 0)) alerts.push('bid offers zero energy in every interval')
|
||||
|
||||
const money = (x: number) => new Decimal(x.toFixed(2)).toFixed(2)
|
||||
return {
|
||||
...base,
|
||||
alert: alerts.length > 0,
|
||||
alerts,
|
||||
metrics: {
|
||||
revenue_p05_yuan: money(p05),
|
||||
revenue_p50_yuan: money(pct(0.5)),
|
||||
revenue_p95_yuan: money(pct(0.95)),
|
||||
worst_loss_yuan: money(worstLoss),
|
||||
},
|
||||
scenario_count: n,
|
||||
}
|
||||
}
|
||||
}
|
||||
293
packages/services/test/chain.test.ts
Normal file
293
packages/services/test/chain.test.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
|
||||
import { CaseDeskService } from '../src/casedesk.js'
|
||||
import { proposalDigest } from '../src/digest.js'
|
||||
import { EnvelopeService } from '../src/envelope.js'
|
||||
import { FileEventBus, MemoryEventBus } from '../src/events.js'
|
||||
import { FileExportGateway, GatewayRejected } from '../src/gateway.js'
|
||||
import { LineageRefError, assembleBidProposal } from '../src/lineage.js'
|
||||
import { FsRepository } from '../src/relational.js'
|
||||
import { RevenueScenarioSimulator } from '../src/simulation.js'
|
||||
import { MemorySnapshotStore } from '../src/snapshot.js'
|
||||
import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, priceForecast, world } from './helpers.js'
|
||||
|
||||
const HUMAN_ROLES = ['senior-trader', 'ops-lead', 'risk-officer']
|
||||
const freshCtx = (w: ReturnType<typeof world>, over: Record<string, unknown> = {}) => ({
|
||||
now: NOW, ledger: w.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000, ...over,
|
||||
})
|
||||
|
||||
describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fail case)', () => {
|
||||
it('passes a well-formed, traceable, in-band bid', () => {
|
||||
const w = world()
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.ok, JSON.stringify(r.violations)).toBe(true)
|
||||
expect(r.rules_evaluated).toContain('lineage-integrity')
|
||||
expect(r.policy_pack.version).toBe(PACK_VERSION)
|
||||
})
|
||||
|
||||
it('digest-valid: a tampered payload no longer matches its digest', () => {
|
||||
const w = world()
|
||||
const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } }
|
||||
const r = w.engine.check(tampered, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('digest-valid')
|
||||
})
|
||||
|
||||
it('price-limits: offer above the cap is rejected', () => {
|
||||
const w = world({ price: '1500.01' })
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.violations[0]!.rule_id).toBe('price-limits')
|
||||
})
|
||||
|
||||
it('ledger-consistency: energy outside the cascade band, or a stale ledger version, is rejected', () => {
|
||||
const over = world({ qty: '14.0' }) // 1344 MWh > 1260
|
||||
let r = over.engine.check(over.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, over.ruleCtx())
|
||||
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /outside/.test(v.message))).toBe(true)
|
||||
|
||||
const w = world()
|
||||
w.ledger.append({ id: 'x', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /ledger_version/.test(v.message))).toBe(true)
|
||||
})
|
||||
|
||||
it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => {
|
||||
const w = world()
|
||||
const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } }
|
||||
forged.digest = proposalDigest(forged) // even a re-digested forgery fails: the number has no provenance
|
||||
const r = w.engine.check(forged, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('lineage-integrity')
|
||||
expect(r.violations.map((v) => v.rule_id)).not.toContain('digest-valid')
|
||||
})
|
||||
|
||||
it('originator-permission: an agent outside the allowlist may not originate a BID (I2)', () => {
|
||||
const w = world({ agent: 'load-control-agent' })
|
||||
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission')
|
||||
})
|
||||
|
||||
it('old pack versions stay registered for replay (I7)', () => {
|
||||
const w = world()
|
||||
expect(w.engine.current('hubei-spot-bidding').version).toBe(PACK_VERSION)
|
||||
expect(() => w.engine.pack('hubei-spot-bidding', '2025.01')).toThrow(/not registered/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('P2 assembler: numbers enter a proposal only by lineage reference', () => {
|
||||
it('rejects a draft carrying a literal number at the schema boundary', () => {
|
||||
const draft = { market_date: DATE, prices_ref: { tool_call_id: 'tc-1', path: 'p' }, quantities_ref: { tool_call_id: 'tc-1', path: 'q' }, expected_revenue_ref: '510600.00', rationale: 'x' }
|
||||
expect(BidProposalDraft.safeParse(draft).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a reference to a tool call that is not in lineage', () => {
|
||||
const w = world()
|
||||
expect(() =>
|
||||
assembleBidProposal(
|
||||
{ market_date: DATE, prices_ref: { tool_call_id: 'tc-99', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-99', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-99', path: 'expected_revenue_yuan' }, rationale: 'x' },
|
||||
{ id: 'p', originator: w.proposal.originator, lineage: w.proposal.lineage, ledger_version: 1, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW },
|
||||
),
|
||||
).toThrow(LineageRefError)
|
||||
})
|
||||
|
||||
it('assembled proposal validates against the domain schema and its digest is reproducible', () => {
|
||||
const w = world()
|
||||
expect(ProposalSchema.safeParse(w.proposal).success).toBe(true)
|
||||
expect(proposalDigest(w.proposal)).toBe(w.proposal.digest)
|
||||
expect(w.proposal.lineage.tool_calls.map((t) => t.tool)).toEqual(['price-forecast', 'bid-optimization-milp'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('envelope gate (P3)', () => {
|
||||
it('empty envelope set → everything goes to a human (autonomy dial at zero)', () => {
|
||||
const w = world()
|
||||
const m = new EnvelopeService().match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
|
||||
expect(m.within).toBe(false)
|
||||
expect(m.envelope_id).toBeNull()
|
||||
expect(m.required_level).toBe('L2')
|
||||
})
|
||||
|
||||
it('auto-approves inside bounds and reports which envelope', () => {
|
||||
const w = world()
|
||||
const svc = new EnvelopeService()
|
||||
svc.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
|
||||
const m = svc.match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
|
||||
expect(m.within).toBe(true)
|
||||
expect(m.envelope_id).toBe('env-bid-001')
|
||||
})
|
||||
|
||||
it('exceeding a bound, an expired window, or SUSPENDED status all fall out of the envelope', () => {
|
||||
const w = world()
|
||||
const outOfEnergy = new EnvelopeService(); outOfEnergy.register(envelope({ max_energy_mwh: '1000.0' }))
|
||||
expect(outOfEnergy.match(w.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_energy_mwh/)
|
||||
|
||||
const expired = new EnvelopeService(); expired.register(envelope({ max_energy_mwh: '1500.0' }, { validity: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z' } }))
|
||||
expect(expired.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
|
||||
|
||||
const suspended = new EnvelopeService(); suspended.register(envelope({ max_energy_mwh: '1500.0' }, { status: 'SUSPENDED' }))
|
||||
expect(suspended.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
|
||||
|
||||
const priced = world({ price: '450.00' }) // 5.8% above 425.50 baseline
|
||||
const tight = new EnvelopeService(); tight.register(envelope({ price_deviation_pct: '5.0' }))
|
||||
expect(tight.match(priced.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }).reasons[0]).toMatch(/deviates/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('approval identity (I1/I2)', () => {
|
||||
it('rejects the originating agent, any tool in the chain, and non-human roles', () => {
|
||||
const w = world()
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'trading-agent', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'bid-optimization-milp', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'agent' }, HUMAN_ROLES)).toThrow(/AI cannot approve/)
|
||||
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'senior-trader' }, HUMAN_ROLES)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('authority: fresh check, permits, revocation (I3/I4)', () => {
|
||||
it('issues a permit bound to the digest with the narrowest effect limits', () => {
|
||||
const w = world()
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1, { scope: { effect_type: 'BID', limits: { max_energy_mwh: '1250.0' } } })], envelopeMatch: null }, freshCtx(w))
|
||||
expect(isStaleDenial(permit)).toBe(false)
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(permit.proposal_digest).toBe(w.proposal.digest)
|
||||
expect(permit.effect_limits['max_energy_mwh']).toBe('1250.0')
|
||||
expect(permit.expires_at).toBe('2026-03-14T09:00:00.000Z')
|
||||
})
|
||||
|
||||
it('goes STALE when the digest, window, ledger, policy pack or resources changed since approval (I3)', () => {
|
||||
const w = world()
|
||||
const a = approvalFor(w.proposal, 1)
|
||||
const stale = (basis: Parameters<AuthorityService['authorize']>[1], ctx = freshCtx(w)) => {
|
||||
const r = w.authority.authorize(w.proposal, basis, ctx)
|
||||
if (!isStaleDenial(r)) throw new Error('expected stale')
|
||||
return r.reasons.join(' | ')
|
||||
}
|
||||
expect(stale({ approvals: [{ ...a, proposal_digest: 'e'.repeat(64) }], envelopeMatch: null })).toMatch(/digest/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { now: '2026-03-15T10:00:00Z' }))).toMatch(/validity/)
|
||||
expect(stale({ approvals: [{ ...a, evidence_versions: { ...a.evidence_versions, ledger_version: 0 } }], envelopeMatch: null })).toMatch(/ledger version/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { currentPolicyPackVersion: '2026.04' }))).toMatch(/policy pack/)
|
||||
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { offlineResources: ['res-storage-01'] }))).toMatch(/offline/)
|
||||
expect(stale({ approvals: [{ ...a, decision: 'REJECT' }], envelopeMatch: null })).toMatch(/REJECT/)
|
||||
expect(stale({ approvals: [], envelopeMatch: null })).toMatch(/no approval basis/)
|
||||
})
|
||||
|
||||
it('an auto-approval is a valid basis only while its evidence is current', () => {
|
||||
const w = world()
|
||||
const match = { proposal_digest: w.proposal.digest, within: true, envelope_id: 'env-bid-001', required_level: 'L1' as const, reasons: [], checked_at: NOW }
|
||||
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(false)
|
||||
w.ledger.append({ id: 'y', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
|
||||
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(true)
|
||||
})
|
||||
|
||||
it('validate: expiry, revocation, digest mismatch and unknown permits are all rejected (I4)', () => {
|
||||
const w = world()
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(w.authority.validate(permit, w.proposal, NOW)).toEqual([])
|
||||
expect(w.authority.validate(permit, w.proposal, '2026-03-14T09:00:00.000Z')[0]).toMatch(/expired/)
|
||||
expect(w.authority.validate({ ...permit, proposal_digest: 'e'.repeat(64) }, w.proposal, NOW)[0]).toMatch(/digest/)
|
||||
expect(w.authority.validate({ ...permit, id: 'permit-forged' }, w.proposal, NOW)[0]).toMatch(/not on record/)
|
||||
w.authority.revoke(permit.id, 'storage offline', '2026-03-14T08:30:00Z')
|
||||
expect(w.authority.validate(permit, w.proposal, NOW)[0]).toMatch(/revoked/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gateway: only (Proposal, Permit) pairs; idempotent receipts (I4/I5)', () => {
|
||||
it('exports a bid file once and returns the same receipt on re-dispatch', () => {
|
||||
const w = world()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vpp-export-'))
|
||||
const gw = new FileExportGateway(w.authority, dir)
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
const r1 = gw.dispatch(w.proposal, permit, NOW)
|
||||
const r2 = gw.dispatch(w.proposal, permit, '2026-03-14T08:05:00Z')
|
||||
expect(r2).toEqual(r1)
|
||||
expect(gw.receipts.list()).toHaveLength(1)
|
||||
const file = JSON.parse(readFileSync(r1.artifact_ref, 'utf8'))
|
||||
expect(file.proposal_digest).toBe(w.proposal.digest)
|
||||
expect(file.quantities_mwh.values[0]).toBe('12.5')
|
||||
})
|
||||
|
||||
it('refuses a late release, a revoked permit, and a bare proposal with a forged permit', () => {
|
||||
const w = world()
|
||||
const gw = new FileExportGateway(w.authority, mkdtempSync(join(tmpdir(), 'vpp-export-')))
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
expect(() => gw.dispatch(w.proposal, permit, '2026-03-15T09:00:01Z')).toThrow(GatewayRejected)
|
||||
expect(() => gw.dispatch(w.proposal, { ...permit, id: 'permit-forged' }, NOW)).toThrow(/not on record/)
|
||||
w.authority.revoke(permit.id, 'kill switch', NOW)
|
||||
expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(/revoked/)
|
||||
expect(gw.receipts.list()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revenue scenario simulation', () => {
|
||||
it('is deterministic for a seed and does not alert on a sane price-taker bid', () => {
|
||||
const w = world()
|
||||
const sim = new RevenueScenarioSimulator()
|
||||
const ctx = { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '100000' }
|
||||
const a = sim.simulate(w.proposal, ctx)
|
||||
const b = sim.simulate(w.proposal, ctx)
|
||||
expect(a).toEqual(b)
|
||||
expect(a.alert).toBe(false)
|
||||
expect(a.scenario_count).toBe(1000)
|
||||
expect(Number(a.metrics['revenue_p05_yuan'])).toBeLessThan(Number(a.metrics['revenue_p95_yuan']))
|
||||
})
|
||||
|
||||
it('alerts when the P05 scenario breaches the loss budget, and when there is no forecast', () => {
|
||||
const w = world()
|
||||
const sim = new RevenueScenarioSimulator()
|
||||
const costly = sim.simulate(w.proposal, { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '600', worstCaseLossBudgetYuan: '1000' })
|
||||
expect(costly.alert).toBe(true)
|
||||
expect(costly.alerts[0]).toMatch(/loss budget/)
|
||||
expect(sim.simulate(w.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '1' }).alerts[0]).toMatch(/no price forecast/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case desk projections', () => {
|
||||
it('opens a case, tracks proposal/approval/permit refs, and expands lineage to tool IO', () => {
|
||||
const w = world()
|
||||
const desk = new CaseDeskService({ authority: w.authority, snapshots: w.snapshots, events: w.events, clock: () => NOW })
|
||||
const c = desk.open({ id: 'case-1', kind: 'DAY_AHEAD_BID', objective: 'D bid', owner: 'user-trader-01', deadline: '2026-03-14T09:00:00Z' })
|
||||
desk.recordProposal(c.id, w.proposal)
|
||||
desk.requestApproval({ proposal_id: w.proposal.id, proposal_digest: w.proposal.digest, case_id: c.id, run_id: 'run-1', required_level: 'L2', reasons: ['no envelope'], since: NOW })
|
||||
expect(desk.inbox().map((p) => p.run_id)).toEqual(['run-1'])
|
||||
expect(desk.read(c.id).case.status).toBe('AWAITING_APPROVAL')
|
||||
|
||||
desk.recordApproval(c.id, approvalFor(w.proposal, 1))
|
||||
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
|
||||
if (isStaleDenial(permit)) throw new Error()
|
||||
desk.recordPermit(c.id, permit)
|
||||
const view = desk.read(c.id)
|
||||
expect(view.approvals[0]!.approver.id).toBe('user-trader-01')
|
||||
expect(view.permits[0]!.id).toBe('permit-001')
|
||||
expect(view.events.map((e) => e.event_type)).toEqual(['CaseOpened', 'ProposalStatusChanged', 'ApprovalRequested', 'HumanDecision'])
|
||||
|
||||
const lin = desk.expandLineage(w.proposal.id)
|
||||
expect(lin.tool_calls[1]!.tool).toBe('bid-optimization-milp')
|
||||
expect((lin.tool_calls[1]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe('510600.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence for restart survival', () => {
|
||||
it('FsRepository and FileEventBus reload their state in a new instance', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vpp-persist-'))
|
||||
const w = world()
|
||||
const repo = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
|
||||
repo.put(w.proposal)
|
||||
repo.put({ ...w.proposal, status: 'PENDING_HUMAN' }, 1)
|
||||
const reloaded = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
|
||||
expect(reloaded.get('prop-001')?.version).toBe(2)
|
||||
expect(reloaded.get('prop-001')?.value.status).toBe('PENDING_HUMAN')
|
||||
|
||||
const bus = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
|
||||
bus.append({ event_type: 'A', payload: { n: 1 }, correlation_id: 'c1' })
|
||||
bus.append({ event_type: 'B', payload: { n: 2 }, correlation_id: 'c1', causation_id: 'evt-000001' })
|
||||
const again = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
|
||||
expect(again.list().map((e) => e.event_type)).toEqual(['A', 'B'])
|
||||
expect(again.append({ event_type: 'C', payload: {}, correlation_id: 'c2' }).event_id).toBe('evt-000003')
|
||||
expect(new MemoryEventBus().list()).toEqual([])
|
||||
})
|
||||
})
|
||||
99
packages/services/test/helpers.ts
Normal file
99
packages/services/test/helpers.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { Approval, Curve96, Envelope, ForecastBundle, Proposal } from '@vpp/domain'
|
||||
import { AuthorityService } from '../src/authority.js'
|
||||
import { MemoryEventBus } from '../src/events.js'
|
||||
import { LedgerService } from '../src/ledger.js'
|
||||
import { LineageRecorder, assembleBidProposal, withLineage } from '../src/lineage.js'
|
||||
import { HUBEI_BID_PACK_PLACEHOLDER, PolicyEngine, hubeiSpotBiddingPack } from '../src/policy.js'
|
||||
import { MemorySnapshotStore } from '../src/snapshot.js'
|
||||
|
||||
export const NOW = '2026-03-14T08:00:00Z'
|
||||
export const DATE = '2026-03-15'
|
||||
export const PACK_VERSION = '2026.03'
|
||||
|
||||
export const curve = (v: string | string[], date = DATE): Curve96 => ({
|
||||
interval_minutes: 15,
|
||||
date,
|
||||
values: Array.isArray(v) ? v : Array.from({ length: 96 }, () => v),
|
||||
})
|
||||
|
||||
export const priceForecast = (p50 = '425.50'): ForecastBundle => ({
|
||||
id: 'fc-price',
|
||||
kind: 'PRICE',
|
||||
market_date: DATE,
|
||||
unit: 'yuan_per_mwh',
|
||||
quantiles: { p10: curve('360.00'), p50: curve(p50), p90: curve('490.00') },
|
||||
model: { name: 'price-forecast', version: '1.0.0' },
|
||||
features_snapshot_ref: 'a'.repeat(64),
|
||||
generated_at: NOW,
|
||||
})
|
||||
|
||||
/** A consistent little world: ledger with a monthly contract, a recorded MILP output, a proposal built from refs. */
|
||||
export function world(opts: { qty?: string; price?: string; agent?: string } = {}) {
|
||||
const snapshots = new MemorySnapshotStore()
|
||||
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock: () => NOW })
|
||||
ledger.append({
|
||||
id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT',
|
||||
energy_mwh: '37200.0', curve: null, source_ref: 'contract-2026-03-001', expected_version: 0,
|
||||
}) // 1200 MWh/day → band [1140, 1260]
|
||||
const engine = new PolicyEngine([hubeiSpotBiddingPack(PACK_VERSION, HUBEI_BID_PACK_PLACEHOLDER)])
|
||||
const lineage = new LineageRecorder()
|
||||
const events = new MemoryEventBus(() => NOW)
|
||||
const authority = new AuthorityService(undefined, () => 'permit-001')
|
||||
|
||||
let n = 0
|
||||
const milp = withLineage(lineage, snapshots, { tool: 'bid-optimization-milp', version: '1.0.0', idGen: () => `tc-${++n}` }, (_input: unknown) => ({
|
||||
market_date: DATE,
|
||||
prices_yuan_per_mwh: curve(opts.price ?? '0.00'),
|
||||
quantities_mwh: curve(opts.qty ?? '12.5'), // 12.5 × 96 = 1200 MWh
|
||||
expected_revenue_yuan: '510600.00',
|
||||
}))
|
||||
const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast())
|
||||
|
||||
const proposal = lineage.run(() => {
|
||||
forecastTool({ kind: 'PRICE' })
|
||||
const { call } = milp({ market_date: DATE })
|
||||
return assembleBidProposal(
|
||||
{
|
||||
market_date: DATE,
|
||||
prices_ref: { tool_call_id: call.tool_call_id, path: 'prices_yuan_per_mwh' },
|
||||
quantities_ref: { tool_call_id: call.tool_call_id, path: 'quantities_mwh' },
|
||||
expected_revenue_ref: { tool_call_id: call.tool_call_id, path: 'expected_revenue_yuan' },
|
||||
rationale: 'test',
|
||||
},
|
||||
{
|
||||
id: 'prop-001',
|
||||
originator: { agent: opts.agent ?? 'trading-agent', trigger: 'SCHEDULED', task_id: 'task-001' },
|
||||
lineage: lineage.current(),
|
||||
ledger_version: ledger.read().version,
|
||||
policy_pack_version: PACK_VERSION,
|
||||
snapshots,
|
||||
now: NOW,
|
||||
},
|
||||
)
|
||||
})
|
||||
const ruleCtx = () => ({ ledger: ledger.read(), ledgerService: ledger, snapshots, now: NOW })
|
||||
return { snapshots, ledger, engine, lineage, events, authority, proposal, ruleCtx }
|
||||
}
|
||||
|
||||
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
|
||||
id: 'env-bid-001',
|
||||
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
|
||||
bounds,
|
||||
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
|
||||
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
|
||||
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
})
|
||||
|
||||
export const approvalFor = (p: Proposal, ledgerVersion: number, over: Partial<Approval> = {}): Approval => ({
|
||||
id: 'appr-001',
|
||||
proposal_digest: p.digest,
|
||||
decision: 'APPROVE',
|
||||
approver: { id: 'user-trader-01', role: 'senior-trader' },
|
||||
scope: { effect_type: 'BID', limits: {} },
|
||||
validity: { from: NOW, to: '2026-03-15T09:00:00Z' },
|
||||
evidence_versions: { policy_pack_version: PACK_VERSION, ledger_version: ledgerVersion, data_snapshot_refs: [] },
|
||||
decided_at: NOW,
|
||||
...over,
|
||||
})
|
||||
53
skills-py/vpp_contracts/bid_export_file.py
Normal file
53
skills-py/vpp_contracts/bid_export_file.py
Normal file
@ -0,0 +1,53 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: bid_export_file.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class ProposalType(StrEnum):
|
||||
BID = 'BID'
|
||||
INVITATION = 'INVITATION'
|
||||
CONTROL_PLAN = 'CONTROL_PLAN'
|
||||
DISPATCH_PLAN = 'DISPATCH_PLAN'
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class PricesYuanPerMwh(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 QuantitiesMwh(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 BidExportFile(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
format_version: Literal['1.0.0']
|
||||
proposal_id: constr(min_length=1)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
permit_id: constr(min_length=1)
|
||||
proposal_type: ProposalType
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
prices_yuan_per_mwh: PricesYuanPerMwh
|
||||
quantities_mwh: QuantitiesMwh
|
||||
exported_at: AwareDatetime
|
||||
41
skills-py/vpp_contracts/bid_proposal_draft.py
Normal file
41
skills-py/vpp_contracts/bid_proposal_draft.py
Normal file
@ -0,0 +1,41 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: bid_proposal_draft.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class PricesRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class QuantitiesRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class ExpectedRevenueRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class BidProposalDraft(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
prices_ref: PricesRef
|
||||
quantities_ref: QuantitiesRef
|
||||
expected_revenue_ref: ExpectedRevenueRef
|
||||
rationale: constr(min_length=1)
|
||||
26
skills-py/vpp_contracts/envelope_match.py
Normal file
26
skills-py/vpp_contracts/envelope_match.py
Normal file
@ -0,0 +1,26 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: envelope_match.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class RequiredLevel(StrEnum):
|
||||
L1 = 'L1'
|
||||
L2 = 'L2'
|
||||
L3 = 'L3'
|
||||
|
||||
|
||||
class EnvelopeMatch(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
within: bool
|
||||
envelope_id: constr(min_length=1) | None
|
||||
required_level: RequiredLevel
|
||||
reasons: list[str]
|
||||
checked_at: AwareDatetime
|
||||
27
skills-py/vpp_contracts/execution_receipt.py
Normal file
27
skills-py/vpp_contracts/execution_receipt.py
Normal file
@ -0,0 +1,27 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: execution_receipt.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class Channel(StrEnum):
|
||||
FILE_EXPORT = 'FILE_EXPORT'
|
||||
TRADING_PLATFORM_API = 'TRADING_PLATFORM_API'
|
||||
SIMULATION_GATEWAY = 'SIMULATION_GATEWAY'
|
||||
|
||||
|
||||
class ExecutionReceipt(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
receipt_id: constr(min_length=1)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
permit_id: constr(min_length=1)
|
||||
channel: Channel
|
||||
idempotency_key: constr(min_length=1)
|
||||
artifact_ref: constr(min_length=1)
|
||||
accepted_at: AwareDatetime
|
||||
36
skills-py/vpp_contracts/router_decision.py
Normal file
36
skills-py/vpp_contracts/router_decision.py
Normal file
@ -0,0 +1,36 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: router_decision.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class WorkflowId(StrEnum):
|
||||
day_ahead_situation = 'day-ahead-situation'
|
||||
day_ahead_bid = 'day-ahead-bid'
|
||||
adhoc_analysis = 'adhoc-analysis'
|
||||
|
||||
|
||||
class Params(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
|
||||
|
||||
class Confidence(StrEnum):
|
||||
HIGH = 'HIGH'
|
||||
MEDIUM = 'MEDIUM'
|
||||
LOW = 'LOW'
|
||||
|
||||
|
||||
class RouterDecision(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
workflow_id: WorkflowId
|
||||
params: Params
|
||||
confidence: Confidence
|
||||
36
skills-py/vpp_contracts/simulation_result.py
Normal file
36
skills-py/vpp_contracts/simulation_result.py
Normal file
@ -0,0 +1,36 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: simulation_result.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, conint, constr
|
||||
|
||||
|
||||
class Kind(StrEnum):
|
||||
REVENUE_SCENARIOS = 'REVENUE_SCENARIOS'
|
||||
POWER_BALANCE = 'POWER_BALANCE'
|
||||
|
||||
|
||||
class Simulator(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
name: constr(min_length=1)
|
||||
version: constr(min_length=1)
|
||||
seed: conint(ge=-9007199254740991, le=9007199254740991)
|
||||
|
||||
|
||||
class SimulationResult(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
kind: Kind
|
||||
alert: bool
|
||||
alerts: list[str]
|
||||
metrics: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
|
||||
scenario_count: conint(ge=0, le=9007199254740991)
|
||||
simulator: Simulator
|
||||
simulated_at: AwareDatetime
|
||||
19
skills-py/vpp_contracts/stale_denial.py
Normal file
19
skills-py/vpp_contracts/stale_denial.py
Normal file
@ -0,0 +1,19 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: stale_denial.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class Reason(RootModel[constr(min_length=1)]):
|
||||
root: constr(min_length=1)
|
||||
|
||||
|
||||
class StaleDenial(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
reasons: list[Reason] = Field(..., min_length=1)
|
||||
checked_at: AwareDatetime
|
||||
42
skills-py/vpp_contracts/validation_result.py
Normal file
42
skills-py/vpp_contracts/validation_result.py
Normal file
@ -0,0 +1,42 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: validation_result.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class PolicyPack(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
version: constr(min_length=1)
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
REJECT = 'REJECT'
|
||||
WARN = 'WARN'
|
||||
|
||||
|
||||
class Violation(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
rule_id: constr(min_length=1)
|
||||
severity: Severity
|
||||
message: constr(min_length=1)
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
policy_pack: PolicyPack
|
||||
ok: bool
|
||||
violations: list[Violation]
|
||||
rules_evaluated: list[constr(min_length=1)]
|
||||
checked_at: AwareDatetime
|
||||
Loading…
Reference in New Issue
Block a user