diff --git a/.env.example b/.env.example index 91ffa0286..20cc71a99 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,18 @@ UCDP_ACCESS_TOKEN= # Cloudflare Radar API (requires free Cloudflare account with Radar access) CLOUDFLARE_API_TOKEN= +# Cloudflare R2 account id for seed scripts that read or write R2 objects +CLOUDFLARE_R2_ACCOUNT_ID= + +# Cloudflare R2 trace storage for forecast seed review artifacts +# Create R2 access keys in Cloudflare and target the bucket you want to use for forecast traces. +CLOUDFLARE_R2_BUCKET= +CLOUDFLARE_R2_TRACE_BUCKET= +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= +CLOUDFLARE_R2_REGION=auto +CLOUDFLARE_R2_TRACE_PREFIX=seed-data/forecast-traces + # ------ Satellite Fire Detection (Vercel) ------ diff --git a/.husky/pre-push b/.husky/pre-push index ec846d463..78e86ac74 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -36,6 +36,9 @@ node --test tests/mdx-lint.test.mjs || exit 1 echo "Running proto freshness check..." if git diff --name-only origin/main -- proto/ src/generated/ docs/api/ Makefile | grep -q .; then + if command -v buf >/dev/null 2>&1 || [ -x "$HOME/go/bin/buf" ]; then + export PATH="$HOME/go/bin:$PATH" + fi if command -v buf &>/dev/null && command -v protoc-gen-ts-client &>/dev/null; then make generate if ! git diff --exit-code src/generated/ docs/api/; then diff --git a/api/bootstrap.js b/api/bootstrap.js index 84290764a..8d9875d35 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -45,6 +45,7 @@ const BOOTSTRAP_CACHE_KEYS = { techEvents: 'research:tech-events-bootstrap:v1', gdeltIntel: 'intelligence:gdelt-intel:v1', correlationCards: 'correlation:cards-bootstrap:v1', + forecasts: 'forecast:predictions:v2', securityAdvisories: 'intelligence:advisories-bootstrap:v1', }; @@ -61,7 +62,7 @@ const FAST_KEYS = new Set([ 'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', 'chokepointTransits', 'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions', 'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel', - 'correlationCards', + 'correlationCards', 'forecasts', ]); const TIER_CACHE = { diff --git a/api/health.js b/api/health.js index b6a77c499..529b7103a 100644 --- a/api/health.js +++ b/api/health.js @@ -30,7 +30,7 @@ const BOOTSTRAP_KEYS = { techEvents: 'research:tech-events-bootstrap:v1', gdeltIntel: 'intelligence:gdelt-intel:v1', correlationCards: 'correlation:cards-bootstrap:v1', - forecasts: 'forecast:predictions:v1', + forecasts: 'forecast:predictions:v2', securityAdvisories: 'intelligence:advisories-bootstrap:v1', }; diff --git a/docs/api/ForecastService.openapi.json b/docs/api/ForecastService.openapi.json index 53fb06599..fb697b751 100644 --- a/docs/api/ForecastService.openapi.json +++ b/docs/api/ForecastService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"CalibrationInfo":{"properties":{"drift":{"format":"double","type":"number"},"marketPrice":{"format":"double","type":"number"},"marketTitle":{"type":"string"},"source":{"type":"string"}},"type":"object"},"CascadeEffect":{"properties":{"domain":{"type":"string"},"effect":{"type":"string"},"probability":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"Forecast":{"properties":{"calibration":{"$ref":"#/components/schemas/CalibrationInfo"},"cascades":{"items":{"$ref":"#/components/schemas/CascadeEffect"},"type":"array"},"confidence":{"format":"double","type":"number"},"createdAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"domain":{"type":"string"},"id":{"type":"string"},"perspectives":{"$ref":"#/components/schemas/Perspectives"},"priorProbability":{"format":"double","type":"number"},"probability":{"format":"double","type":"number"},"projections":{"$ref":"#/components/schemas/Projections"},"region":{"type":"string"},"scenario":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/ForecastSignal"},"type":"array"},"timeHorizon":{"type":"string"},"title":{"type":"string"},"trend":{"type":"string"},"updatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ForecastSignal":{"properties":{"type":{"type":"string"},"value":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"GetForecastsRequest":{"properties":{"domain":{"type":"string"},"region":{"type":"string"}},"type":"object"},"GetForecastsResponse":{"properties":{"forecasts":{"items":{"$ref":"#/components/schemas/Forecast"},"type":"array"},"generatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"Perspectives":{"properties":{"contrarian":{"type":"string"},"regional":{"type":"string"},"strategic":{"type":"string"}},"type":"object"},"Projections":{"properties":{"d30":{"format":"double","type":"number"},"d7":{"format":"double","type":"number"},"h24":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ForecastService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/forecast/v1/get-forecasts":{"get":{"operationId":"GetForecasts","parameters":[{"in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"in":"query","name":"region","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetForecastsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetForecasts","tags":["ForecastService"]}}}} \ No newline at end of file +{"components":{"schemas":{"CalibrationInfo":{"properties":{"drift":{"format":"double","type":"number"},"marketPrice":{"format":"double","type":"number"},"marketTitle":{"type":"string"},"source":{"type":"string"}},"type":"object"},"CascadeEffect":{"properties":{"domain":{"type":"string"},"effect":{"type":"string"},"probability":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"Forecast":{"properties":{"calibration":{"$ref":"#/components/schemas/CalibrationInfo"},"cascades":{"items":{"$ref":"#/components/schemas/CascadeEffect"},"type":"array"},"caseFile":{"$ref":"#/components/schemas/ForecastCase"},"confidence":{"format":"double","type":"number"},"createdAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"domain":{"type":"string"},"feedSummary":{"type":"string"},"id":{"type":"string"},"perspectives":{"$ref":"#/components/schemas/Perspectives"},"priorProbability":{"format":"double","type":"number"},"probability":{"format":"double","type":"number"},"projections":{"$ref":"#/components/schemas/Projections"},"region":{"type":"string"},"scenario":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/ForecastSignal"},"type":"array"},"timeHorizon":{"type":"string"},"title":{"type":"string"},"trend":{"type":"string"},"updatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ForecastActor":{"properties":{"category":{"type":"string"},"constraints":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"influenceScore":{"format":"double","type":"number"},"likelyActions":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"objectives":{"items":{"type":"string"},"type":"array"},"role":{"type":"string"}},"type":"object"},"ForecastBranch":{"properties":{"kind":{"type":"string"},"outcome":{"type":"string"},"projectedProbability":{"format":"double","type":"number"},"rounds":{"items":{"$ref":"#/components/schemas/ForecastBranchRound"},"type":"array"},"summary":{"type":"string"},"title":{"type":"string"}},"type":"object"},"ForecastBranchRound":{"properties":{"actorMoves":{"items":{"type":"string"},"type":"array"},"developments":{"items":{"type":"string"},"type":"array"},"focus":{"type":"string"},"probabilityShift":{"format":"double","type":"number"},"round":{"format":"int32","type":"integer"}},"type":"object"},"ForecastCase":{"properties":{"actorLenses":{"items":{"type":"string"},"type":"array"},"actors":{"items":{"$ref":"#/components/schemas/ForecastActor"},"type":"array"},"baseCase":{"type":"string"},"branches":{"items":{"$ref":"#/components/schemas/ForecastBranch"},"type":"array"},"changeItems":{"items":{"type":"string"},"type":"array"},"changeSummary":{"type":"string"},"contrarianCase":{"type":"string"},"counterEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"escalatoryCase":{"type":"string"},"supportingEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"triggers":{"items":{"type":"string"},"type":"array"},"worldState":{"$ref":"#/components/schemas/ForecastWorldState"}},"type":"object"},"ForecastCaseEvidence":{"properties":{"summary":{"type":"string"},"type":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastSignal":{"properties":{"type":{"type":"string"},"value":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastWorldState":{"properties":{"activePressures":{"items":{"type":"string"},"type":"array"},"keyUnknowns":{"items":{"type":"string"},"type":"array"},"stabilizers":{"items":{"type":"string"},"type":"array"},"summary":{"type":"string"}},"type":"object"},"GetForecastsRequest":{"properties":{"domain":{"type":"string"},"region":{"type":"string"}},"type":"object"},"GetForecastsResponse":{"properties":{"forecasts":{"items":{"$ref":"#/components/schemas/Forecast"},"type":"array"},"generatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"Perspectives":{"properties":{"contrarian":{"type":"string"},"regional":{"type":"string"},"strategic":{"type":"string"}},"type":"object"},"Projections":{"properties":{"d30":{"format":"double","type":"number"},"d7":{"format":"double","type":"number"},"h24":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ForecastService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/forecast/v1/get-forecasts":{"get":{"operationId":"GetForecasts","parameters":[{"in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"in":"query","name":"region","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetForecastsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetForecasts","tags":["ForecastService"]}}}} \ No newline at end of file diff --git a/docs/api/ForecastService.openapi.yaml b/docs/api/ForecastService.openapi.yaml index 610ac3e01..8d14250c0 100644 --- a/docs/api/ForecastService.openapi.yaml +++ b/docs/api/ForecastService.openapi.yaml @@ -103,6 +103,8 @@ components: type: string scenario: type: string + feedSummary: + type: string probability: type: number format: double @@ -138,6 +140,8 @@ components: $ref: '#/components/schemas/Perspectives' projections: $ref: '#/components/schemas/Projections' + caseFile: + $ref: '#/components/schemas/ForecastCase' ForecastSignal: type: object properties: @@ -192,3 +196,134 @@ components: d30: type: number format: double + ForecastCase: + type: object + properties: + supportingEvidence: + type: array + items: + $ref: '#/components/schemas/ForecastCaseEvidence' + counterEvidence: + type: array + items: + $ref: '#/components/schemas/ForecastCaseEvidence' + triggers: + type: array + items: + type: string + actorLenses: + type: array + items: + type: string + baseCase: + type: string + escalatoryCase: + type: string + contrarianCase: + type: string + changeSummary: + type: string + changeItems: + type: array + items: + type: string + actors: + type: array + items: + $ref: '#/components/schemas/ForecastActor' + worldState: + $ref: '#/components/schemas/ForecastWorldState' + branches: + type: array + items: + $ref: '#/components/schemas/ForecastBranch' + ForecastCaseEvidence: + type: object + properties: + type: + type: string + summary: + type: string + weight: + type: number + format: double + ForecastActor: + type: object + properties: + id: + type: string + name: + type: string + category: + type: string + role: + type: string + objectives: + type: array + items: + type: string + constraints: + type: array + items: + type: string + likelyActions: + type: array + items: + type: string + influenceScore: + type: number + format: double + ForecastWorldState: + type: object + properties: + summary: + type: string + activePressures: + type: array + items: + type: string + stabilizers: + type: array + items: + type: string + keyUnknowns: + type: array + items: + type: string + ForecastBranch: + type: object + properties: + kind: + type: string + title: + type: string + summary: + type: string + outcome: + type: string + projectedProbability: + type: number + format: double + rounds: + type: array + items: + $ref: '#/components/schemas/ForecastBranchRound' + ForecastBranchRound: + type: object + properties: + round: + type: integer + format: int32 + focus: + type: string + developments: + type: array + items: + type: string + actorMoves: + type: array + items: + type: string + probabilityShift: + type: number + format: double diff --git a/package-lock.json b/package-lock.json index 9f15fed96..d152fafc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { + "@aws-sdk/client-s3": "^3.1009.0", "@deck.gl/aggregation-layers": "^9.2.6", "@deck.gl/core": "^9.2.6", "@deck.gl/geo-layers": "^9.2.6", @@ -190,6 +191,857 @@ "tslib": "^2.8.1" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1009.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1009.0.tgz", + "integrity": "sha512-luy8CxallkoiGWTqU86ca/BbvkWJjs0oala7uIIRN1JtQxMb5i4Yl/PBZVcQFhbK9kQi0PK0GfD8gIpLkI91fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/credential-provider-node": "^3.972.21", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.973.6", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.20", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.7", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.11", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-retry": "^4.4.42", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.41", + "@smithy/util-defaults-mode-node": "^4.2.44", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz", + "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.11", + "@smithy/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz", + "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz", + "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.19", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz", + "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-login": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz", + "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz", + "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-ini": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz", + "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz", + "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/token-providers": "3.1009.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz", + "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.6.tgz", + "integrity": "sha512-0nYEgkJH7Yt9k+nZJyllTghnkKaz17TWFcr5Mi0XMVMzYlF4ytDZADQpF2/iJo36cKL5AYSzRsvlykE4M/ErTA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.20.tgz", + "integrity": "sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz", + "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.11", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz", + "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.7", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.11", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-retry": "^4.4.42", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.41", + "@smithy/util-defaults-mode-node": "^4.2.44", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", + "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.8.tgz", + "integrity": "sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1009.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz", + "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz", + "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz", + "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -6531,6 +7383,739 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz", + "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.11", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz", + "integrity": "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.25", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz", + "integrity": "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.11", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.42", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz", + "integrity": "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz", + "integrity": "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.11", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz", + "integrity": "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz", + "integrity": "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.11", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.19", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.41", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz", + "integrity": "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz", + "integrity": "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.11", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.19", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz", + "integrity": "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", @@ -9151,6 +10736,12 @@ "license": "ISC", "peer": true }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", diff --git a/package.json b/package.json index a65b120ab..81350a04f 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "vite-plugin-pwa": "^1.2.0" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1009.0", "@deck.gl/aggregation-layers": "^9.2.6", "@deck.gl/core": "^9.2.6", "@deck.gl/geo-layers": "^9.2.6", diff --git a/proto/worldmonitor/forecast/v1/forecast.proto b/proto/worldmonitor/forecast/v1/forecast.proto index de7a6efb3..99e19aec2 100644 --- a/proto/worldmonitor/forecast/v1/forecast.proto +++ b/proto/worldmonitor/forecast/v1/forecast.proto @@ -35,12 +35,69 @@ message Projections { double d30 = 3; } +message ForecastCaseEvidence { + string type = 1; + string summary = 2; + double weight = 3; +} + +message ForecastActor { + string id = 1; + string name = 2; + string category = 3; + string role = 4; + repeated string objectives = 5; + repeated string constraints = 6; + repeated string likely_actions = 7; + double influence_score = 8; +} + +message ForecastWorldState { + string summary = 1; + repeated string active_pressures = 2; + repeated string stabilizers = 3; + repeated string key_unknowns = 4; +} + +message ForecastBranchRound { + int32 round = 1; + string focus = 2; + repeated string developments = 3; + repeated string actor_moves = 4; + double probability_shift = 5; +} + +message ForecastBranch { + string kind = 1; + string title = 2; + string summary = 3; + string outcome = 4; + double projected_probability = 5; + repeated ForecastBranchRound rounds = 6; +} + +message ForecastCase { + repeated ForecastCaseEvidence supporting_evidence = 1; + repeated ForecastCaseEvidence counter_evidence = 2; + repeated string triggers = 3; + repeated string actor_lenses = 4; + string base_case = 5; + string escalatory_case = 6; + string contrarian_case = 7; + string change_summary = 8; + repeated string change_items = 9; + repeated ForecastActor actors = 10; + ForecastWorldState world_state = 11; + repeated ForecastBranch branches = 12; +} + message Forecast { string id = 1; string domain = 2; string region = 3; string title = 4; string scenario = 5; + string feed_summary = 19; double probability = 6; double confidence = 7; string time_horizon = 8; @@ -53,4 +110,5 @@ message Forecast { int64 updated_at = 15 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; Perspectives perspectives = 16; Projections projections = 17; + ForecastCase case_file = 18; } diff --git a/scripts/_r2-storage.mjs b/scripts/_r2-storage.mjs new file mode 100644 index 000000000..d5484bfbe --- /dev/null +++ b/scripts/_r2-storage.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +function getEnvValue(env, keys) { + for (const key of keys) { + if (env[key]) return env[key]; + } + return ''; +} + +function parseBoolean(value, fallback) { + if (value == null || value === '') return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function resolveR2StorageConfig(env = process.env, options = {}) { + const accountId = getEnvValue(env, ['CLOUDFLARE_R2_ACCOUNT_ID']); + const bucket = getEnvValue(env, [options.bucketEnv || 'CLOUDFLARE_R2_TRACE_BUCKET', 'CLOUDFLARE_R2_BUCKET']); + const accessKeyId = getEnvValue(env, ['CLOUDFLARE_R2_ACCESS_KEY_ID']); + const secretAccessKey = getEnvValue(env, ['CLOUDFLARE_R2_SECRET_ACCESS_KEY']); + const apiToken = getEnvValue(env, ['CLOUDFLARE_R2_TOKEN', 'CLOUDFLARE_API_TOKEN']); + const endpoint = getEnvValue(env, ['CLOUDFLARE_R2_ENDPOINT']) || (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : ''); + const apiBaseUrl = getEnvValue(env, ['CLOUDFLARE_API_BASE_URL']) || 'https://api.cloudflare.com/client/v4'; + const region = getEnvValue(env, ['CLOUDFLARE_R2_REGION']) || 'auto'; + const basePrefix = (getEnvValue(env, [options.prefixEnv || 'CLOUDFLARE_R2_TRACE_PREFIX']) || 'seed-data/forecast-traces') + .replace(/^\/+|\/+$/g, ''); + const forcePathStyle = parseBoolean(getEnvValue(env, ['CLOUDFLARE_R2_FORCE_PATH_STYLE']), true); + + if (!bucket || !accountId) return null; + + if (endpoint && accessKeyId && secretAccessKey) { + return { + mode: 's3', + accountId, + bucket, + endpoint, + region, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle, + basePrefix, + }; + } + + if (apiToken) { + return { + mode: 'api', + accountId, + bucket, + apiToken, + apiBaseUrl, + basePrefix, + }; + } + + return null; +} + +const CLIENT_CACHE = new Map(); + +function getR2StorageClient(config) { + const cacheKey = JSON.stringify({ + endpoint: config.endpoint, + region: config.region, + bucket: config.bucket, + accessKeyId: config.credentials.accessKeyId, + forcePathStyle: config.forcePathStyle, + }); + let client = CLIENT_CACHE.get(cacheKey); + if (!client) { + client = new S3Client({ + endpoint: config.endpoint, + region: config.region, + credentials: config.credentials, + forcePathStyle: config.forcePathStyle, + }); + CLIENT_CACHE.set(cacheKey, client); + } + return client; +} + +async function putR2JsonObject(config, key, payload, metadata = {}) { + const body = `${JSON.stringify(payload, null, 2)}\n`; + + if (config.mode === 'api') { + const encodedKey = key.split('/').map(part => encodeURIComponent(part)).join('/'); + const resp = await fetch(`${config.apiBaseUrl}/accounts/${config.accountId}/r2/buckets/${config.bucket}/objects/${encodedKey}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body, + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`Cloudflare R2 API upload failed: HTTP ${resp.status} — ${text.slice(0, 200)}`); + } + return { bucket: config.bucket, key, bytes: Buffer.byteLength(body, 'utf8') }; + } + + const client = getR2StorageClient(config); + await client.send(new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: body, + ContentType: 'application/json; charset=utf-8', + CacheControl: 'no-store', + Metadata: metadata, + })); + return { bucket: config.bucket, key, bytes: Buffer.byteLength(body, 'utf8') }; +} + +export { + resolveR2StorageConfig, + getR2StorageClient, + putR2JsonObject, +}; diff --git a/scripts/_seed-utils.mjs b/scripts/_seed-utils.mjs index fd8fb460c..a2eac6bab 100644 --- a/scripts/_seed-utils.mjs +++ b/scripts/_seed-utils.mjs @@ -266,7 +266,7 @@ export function parseYahooChart(data, symbol) { } export async function runSeed(domain, resource, canonicalKey, fetchFn, opts = {}) { - const { validateFn, ttlSeconds, lockTtlMs = 120_000, extraKeys } = opts; + const { validateFn, ttlSeconds, lockTtlMs = 120_000, extraKeys, afterPublish } = opts; const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const startMs = Date.now(); @@ -318,6 +318,7 @@ export async function runSeed(domain, resource, canonicalKey, fetchFn, opts = {} ? (typeof opts.recordCount === 'function' ? opts.recordCount(data) : opts.recordCount) : Array.isArray(data) ? data.length : (topicArticleCount + ?? data?.predictions?.length ?? data?.events?.length ?? data?.earthquakes?.length ?? data?.outages?.length ?? data?.fireDetections?.length ?? data?.anomalies?.length ?? data?.threats?.length ?? data?.quotes?.length ?? data?.stablecoins?.length @@ -330,6 +331,10 @@ export async function runSeed(domain, resource, canonicalKey, fetchFn, opts = {} } } + if (afterPublish) { + await afterPublish(data, { canonicalKey, ttlSeconds, recordCount, runId }); + } + const meta = await writeFreshnessMetadata(domain, resource, recordCount, opts.sourceVersion); const durationMs = Date.now() - startMs; diff --git a/scripts/data/forecast-evaluation-benchmark.json b/scripts/data/forecast-evaluation-benchmark.json new file mode 100644 index 000000000..72c39803c --- /dev/null +++ b/scripts/data/forecast-evaluation-benchmark.json @@ -0,0 +1,89 @@ +[ + { + "name": "well_grounded_conflict", + "forecast": { + "domain": "conflict", + "region": "Iran", + "title": "Escalation risk: Iran", + "probability": 0.71, + "confidence": 0.62, + "timeHorizon": "7d", + "trend": "rising", + "signals": [ + { "type": "cii", "value": "Iran CII 87 (critical)", "weight": 0.4 }, + { "type": "ucdp", "value": "3 UCDP conflict events", "weight": 0.3 }, + { "type": "theater", "value": "Middle East theater posture elevated", "weight": 0.2 } + ], + "newsContext": [ + "Iran military drills intensify after border incident", + "Regional officials warn of retaliation risk" + ], + "calibration": { + "marketTitle": "Will Iran conflict escalate before July?", + "marketPrice": 0.58, + "drift": 0.04, + "source": "polymarket" + }, + "cascades": [ + { "domain": "market", "effect": "commodity price shock", "probability": 0.41 } + ] + }, + "thresholds": { + "overallMin": 0.7, + "groundingMin": 0.6 + } + }, + { + "name": "well_grounded_supply_chain", + "forecast": { + "domain": "supply_chain", + "region": "Red Sea", + "title": "Shipping disruption: Red Sea", + "probability": 0.64, + "confidence": 0.57, + "timeHorizon": "7d", + "trend": "rising", + "signals": [ + { "type": "chokepoint", "value": "Red Sea disruption detected", "weight": 0.5 }, + { "type": "gps_jamming", "value": "GPS interference near Red Sea", "weight": 0.2 } + ], + "newsContext": [ + "Red Sea shipping disruption worsens after new attacks", + "Freight rates react to Red Sea rerouting" + ], + "calibration": { + "marketTitle": "Will oil close above $90?", + "marketPrice": 0.62, + "drift": 0.03, + "source": "polymarket" + }, + "cascades": [ + { "domain": "market", "effect": "supply shortage pricing", "probability": 0.38 } + ] + }, + "thresholds": { + "overallMin": 0.66, + "groundingMin": 0.6 + } + }, + { + "name": "thin_generic_market", + "forecast": { + "domain": "market", + "region": "Europe", + "title": "Energy stress: Europe", + "probability": 0.69, + "confidence": 0.58, + "timeHorizon": "30d", + "trend": "stable", + "signals": [ + { "type": "prediction_market", "value": "Broad market stress chatter", "weight": 0.2 } + ], + "newsContext": [], + "cascades": [] + }, + "thresholds": { + "overallMax": 0.55 + } + } +] diff --git a/scripts/data/forecast-historical-benchmark.json b/scripts/data/forecast-historical-benchmark.json new file mode 100644 index 000000000..9c006fbdb --- /dev/null +++ b/scripts/data/forecast-historical-benchmark.json @@ -0,0 +1,176 @@ +[ + { + "name": "red_sea_shipping_disruption_2024_01_15", + "eventDate": "2024-01-15", + "description": "Red Sea disruption risk hardens after rerouting and interference signals broaden.", + "priorForecast": { + "domain": "supply_chain", + "region": "Red Sea", + "title": "Shipping disruption: Red Sea", + "probability": 0.52, + "confidence": 0.54, + "timeHorizon": "7d", + "signals": [ + { "type": "chokepoint", "value": "Red Sea disruption detected", "weight": 0.5 } + ], + "newsContext": [ + "Shipping firms monitor Red Sea route risk" + ], + "calibration": { + "marketTitle": "Will oil close above $90?", + "marketPrice": 0.54, + "drift": 0.02, + "source": "polymarket" + } + }, + "forecast": { + "domain": "supply_chain", + "region": "Red Sea", + "title": "Shipping disruption: Red Sea", + "probability": 0.68, + "confidence": 0.59, + "timeHorizon": "7d", + "signals": [ + { "type": "chokepoint", "value": "Red Sea disruption detected", "weight": 0.5 }, + { "type": "gps_jamming", "value": "GPS interference near Red Sea", "weight": 0.2 } + ], + "newsContext": [ + "Shipping firms monitor Red Sea route risk", + "Freight rates react to Red Sea rerouting" + ], + "calibration": { + "marketTitle": "Will oil close above $90?", + "marketPrice": 0.67, + "drift": 0.01, + "source": "polymarket" + }, + "cascades": [ + { "domain": "market", "effect": "supply shortage pricing", "probability": 0.38 } + ] + }, + "thresholds": { + "overallMin": 0.72, + "groundingMin": 0.65, + "trend": "rising", + "changeSummaryIncludes": ["rose from 52% to 68%"], + "changeItemsInclude": [ + "New signal: GPS interference near Red Sea", + "New reporting: Freight rates react to Red Sea rerouting", + "Market moved from 54% to 67%" + ] + } + }, + { + "name": "iran_exchange_2024_04_14", + "eventDate": "2024-04-14", + "description": "Iran escalation risk jumps as conflict-event and theater signals stack on top of already-high instability.", + "priorForecast": { + "domain": "conflict", + "region": "Iran", + "title": "Escalation risk: Iran", + "probability": 0.46, + "confidence": 0.55, + "timeHorizon": "7d", + "signals": [ + { "type": "cii", "value": "Iran CII 79 (high)", "weight": 0.4 } + ], + "newsContext": [ + "Iran military drills intensify after border incident" + ], + "calibration": { + "marketTitle": "Will Iran conflict escalate before July?", + "marketPrice": 0.45, + "drift": 0.03, + "source": "polymarket" + } + }, + "forecast": { + "domain": "conflict", + "region": "Iran", + "title": "Escalation risk: Iran", + "probability": 0.74, + "confidence": 0.64, + "timeHorizon": "7d", + "signals": [ + { "type": "cii", "value": "Iran CII 79 (high)", "weight": 0.4 }, + { "type": "ucdp", "value": "3 UCDP conflict events", "weight": 0.3 }, + { "type": "theater", "value": "Middle East theater posture elevated", "weight": 0.2 } + ], + "newsContext": [ + "Iran military drills intensify after border incident", + "Regional officials warn of retaliation risk" + ], + "calibration": { + "marketTitle": "Will Iran conflict escalate before July?", + "marketPrice": 0.71, + "drift": 0.03, + "source": "polymarket" + }, + "cascades": [ + { "domain": "market", "effect": "commodity price shock", "probability": 0.41 } + ] + }, + "thresholds": { + "overallMin": 0.78, + "groundingMin": 0.65, + "trend": "rising", + "changeSummaryIncludes": ["rose from 46% to 74%"], + "changeItemsInclude": [ + "New signal: 3 UCDP conflict events", + "New reporting: Regional officials warn of retaliation risk", + "Market moved from 45% to 71%" + ] + } + }, + { + "name": "europe_energy_stress_eases_2025_02_01", + "eventDate": "2025-02-01", + "description": "A softer market path and thinner corroboration pull a European energy stress forecast down.", + "priorForecast": { + "domain": "market", + "region": "Europe", + "title": "Energy stress: Europe", + "probability": 0.64, + "confidence": 0.58, + "timeHorizon": "30d", + "signals": [ + { "type": "prediction_market", "value": "EU gas price stress remains elevated", "weight": 0.25 } + ], + "newsContext": [ + "European gas storage draw accelerates" + ], + "calibration": { + "marketTitle": "Will EU gas prices spike this month?", + "marketPrice": 0.59, + "drift": 0.02, + "source": "polymarket" + } + }, + "forecast": { + "domain": "market", + "region": "Europe", + "title": "Energy stress: Europe", + "probability": 0.49, + "confidence": 0.54, + "timeHorizon": "30d", + "signals": [ + { "type": "prediction_market", "value": "EU gas price stress remains elevated", "weight": 0.25 } + ], + "newsContext": [], + "calibration": { + "marketTitle": "Will EU gas prices spike this month?", + "marketPrice": 0.44, + "drift": 0.05, + "source": "polymarket" + } + }, + "thresholds": { + "overallMax": 0.58, + "trend": "falling", + "changeSummaryIncludes": ["fell from 64% to 49%"], + "changeItemsInclude": [ + "Market moved from 59% to 44%" + ] + } + } +] diff --git a/scripts/evaluate-forecast-benchmark.mjs b/scripts/evaluate-forecast-benchmark.mjs new file mode 100644 index 000000000..de6773118 --- /dev/null +++ b/scripts/evaluate-forecast-benchmark.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + makePrediction, + computeTrends, + buildForecastCase, + buildPriorForecastSnapshot, + annotateForecastChanges, + scoreForecastReadiness, + computeAnalysisPriority, +} from './seed-forecasts.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const benchmarkPaths = [ + join(__dirname, 'data', 'forecast-evaluation-benchmark.json'), + join(__dirname, 'data', 'forecast-historical-benchmark.json'), +]; + +function materializeForecast(input) { + const pred = makePrediction( + input.domain, + input.region, + input.title, + input.probability, + input.confidence, + input.timeHorizon, + input.signals || [], + ); + pred.trend = input.trend || pred.trend; + pred.newsContext = input.newsContext || []; + pred.calibration = input.calibration || null; + pred.cascades = input.cascades || []; + buildForecastCase(pred); + return pred; +} + +function evaluateEntry(entry) { + const pred = materializeForecast(entry.forecast); + let priorPred = null; + let prior = null; + + if (entry.priorForecast) { + priorPred = materializeForecast(entry.priorForecast); + prior = { predictions: [buildPriorForecastSnapshot(priorPred)] }; + computeTrends([pred], prior); + buildForecastCase(pred); + annotateForecastChanges([pred], prior); + } + + const readiness = scoreForecastReadiness(pred); + const priority = computeAnalysisPriority(pred); + const failures = []; + const thresholds = entry.thresholds || {}; + + if (typeof thresholds.overallMin === 'number' && readiness.overall < thresholds.overallMin) { + failures.push(`overall ${readiness.overall} < ${thresholds.overallMin}`); + } + if (typeof thresholds.overallMax === 'number' && readiness.overall > thresholds.overallMax) { + failures.push(`overall ${readiness.overall} > ${thresholds.overallMax}`); + } + if (typeof thresholds.groundingMin === 'number' && readiness.groundingScore < thresholds.groundingMin) { + failures.push(`grounding ${readiness.groundingScore} < ${thresholds.groundingMin}`); + } + if (typeof thresholds.priorityMin === 'number' && priority < thresholds.priorityMin) { + failures.push(`priority ${priority} < ${thresholds.priorityMin}`); + } + if (typeof thresholds.priorityMax === 'number' && priority > thresholds.priorityMax) { + failures.push(`priority ${priority} > ${thresholds.priorityMax}`); + } + if (typeof thresholds.trend === 'string' && pred.trend !== thresholds.trend) { + failures.push(`trend ${pred.trend} !== ${thresholds.trend}`); + } + for (const fragment of thresholds.changeSummaryIncludes || []) { + if (!pred.caseFile?.changeSummary?.includes(fragment)) { + failures.push(`changeSummary missing "${fragment}"`); + } + } + for (const fragment of thresholds.changeItemsInclude || []) { + const found = (pred.caseFile?.changeItems || []).some(item => item.includes(fragment)); + if (!found) failures.push(`changeItems missing "${fragment}"`); + } + + return { + name: entry.name, + eventDate: entry.eventDate || null, + description: entry.description || '', + readiness, + priority, + trend: pred.trend, + changeSummary: pred.caseFile?.changeSummary || '', + changeItems: pred.caseFile?.changeItems || [], + pass: failures.length === 0, + failures, + }; +} + +const suites = benchmarkPaths.map(benchmarkPath => { + const benchmark = JSON.parse(readFileSync(benchmarkPath, 'utf8')); + const results = benchmark.map(evaluateEntry); + const passed = results.filter(result => result.pass).length; + return { + benchmark: benchmarkPath, + cases: results.length, + passed, + failed: results.length - passed, + results, + }; +}); + +const summary = { + cases: suites.reduce((sum, suite) => sum + suite.cases, 0), + passed: suites.reduce((sum, suite) => sum + suite.passed, 0), + failed: suites.reduce((sum, suite) => sum + suite.failed, 0), + suites, +}; + +console.log(JSON.stringify(summary, null, 2)); + +if (summary.failed > 0) process.exit(1); diff --git a/scripts/extract-forecast-benchmark-candidates.mjs b/scripts/extract-forecast-benchmark-candidates.mjs new file mode 100644 index 000000000..b08ee4d00 --- /dev/null +++ b/scripts/extract-forecast-benchmark-candidates.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +import { loadEnvFile } from './_seed-utils.mjs'; +import { HISTORY_KEY } from './seed-forecasts.mjs'; + +const _isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); +if (_isDirectRun) loadEnvFile(import.meta.url); + +const NOISE_SIGNAL_TYPES = new Set(['news_corroboration']); + +function slugify(value) { + return (value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 64); +} + +function toBenchmarkForecast(entry) { + return { + domain: entry.domain, + region: entry.region, + title: entry.title, + probability: entry.probability, + confidence: entry.confidence, + timeHorizon: entry.timeHorizon, + trend: entry.trend, + signals: entry.signals || [], + newsContext: entry.newsContext || [], + calibration: entry.calibration || null, + cascades: entry.cascades || [], + }; +} + +function summarizeObservedChange(current, prior) { + const currentSignals = new Set((current.signals || []) + .filter(signal => !NOISE_SIGNAL_TYPES.has(signal.type)) + .map(signal => signal.value)); + const priorSignals = new Set((prior.signals || []) + .filter(signal => !NOISE_SIGNAL_TYPES.has(signal.type)) + .map(signal => signal.value)); + const currentHeadlines = new Set(current.newsContext || []); + const priorHeadlines = new Set(prior.newsContext || []); + const deltaProbability = +(current.probability - prior.probability).toFixed(3); + const newSignals = [...currentSignals].filter(value => !priorSignals.has(value)); + const newHeadlines = [...currentHeadlines].filter(value => !priorHeadlines.has(value)); + const marketMove = current.calibration && prior.calibration + && current.calibration.marketTitle === prior.calibration.marketTitle + ? +((current.calibration.marketPrice || 0) - (prior.calibration.marketPrice || 0)).toFixed(3) + : null; + + return { + deltaProbability, + trend: current.trend, + newSignals, + newHeadlines, + marketMove, + }; +} + +function buildBenchmarkCandidate(current, prior, snapshotAt) { + const eventDate = new Date(snapshotAt).toISOString().slice(0, 10); + const observedChange = summarizeObservedChange(current, prior); + return { + name: `${slugify(current.title)}_${eventDate.replace(/-/g, '_')}`, + eventDate, + description: `${current.title} moved from ${Math.round(prior.probability * 100)}% to ${Math.round(current.probability * 100)}% between consecutive forecast snapshots.`, + priorForecast: toBenchmarkForecast(prior), + forecast: toBenchmarkForecast(current), + observedChange, + }; +} + +function scoreCandidate(candidate) { + const absDelta = Math.abs(candidate.observedChange.deltaProbability || 0); + const signalBonus = Math.min(0.15, (candidate.observedChange.newSignals?.length || 0) * 0.05); + const marketBonus = Math.min(0.15, Math.abs(candidate.observedChange.marketMove || 0) * 0.7); + const hasStructuredChange = absDelta >= 0.03 + || (candidate.observedChange.newSignals?.length || 0) > 0 + || Math.abs(candidate.observedChange.marketMove || 0) >= 0.03; + const headlineBonus = hasStructuredChange + ? Math.min(0.04, (candidate.observedChange.newHeadlines?.length || 0) * 0.02) + : 0; + return +(absDelta + signalBonus + headlineBonus + marketBonus).toFixed(3); +} + +function selectBenchmarkCandidates(historySnapshots, options = {}) { + const minDelta = options.minDelta ?? 0.08; + const minMarketMove = options.minMarketMove ?? 0.08; + const maxCandidates = options.maxCandidates ?? 10; + const minInterestingness = options.minInterestingness ?? 0.12; + const candidates = []; + + for (let i = 0; i < historySnapshots.length - 1; i++) { + const currentSnapshot = historySnapshots[i]; + const priorSnapshot = historySnapshots[i + 1]; + const priorMap = new Map((priorSnapshot?.predictions || []).map(pred => [pred.id, pred])); + + for (const current of currentSnapshot?.predictions || []) { + const prior = priorMap.get(current.id); + if (!prior) continue; + const candidate = buildBenchmarkCandidate(current, prior, currentSnapshot.generatedAt); + const interestingness = scoreCandidate(candidate); + const hasMeaningfulStateChange = + Math.abs(candidate.observedChange.deltaProbability) >= minDelta + || Math.abs(candidate.observedChange.marketMove || 0) >= minMarketMove + || (candidate.observedChange.newSignals?.length || 0) > 0; + if (!hasMeaningfulStateChange && interestingness < minInterestingness) continue; + if (!hasMeaningfulStateChange) continue; + candidates.push({ ...candidate, interestingness }); + } + } + + return candidates + .sort((a, b) => b.interestingness - a.interestingness || b.eventDate.localeCompare(a.eventDate)) + .slice(0, maxCandidates); +} + +async function readForecastHistory(key = HISTORY_KEY, limit = 60) { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) throw new Error('Missing UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN'); + + const resp = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(['LRANGE', key, 0, Math.max(0, limit - 1)]), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) throw new Error(`Redis LRANGE failed: HTTP ${resp.status}`); + const payload = await resp.json(); + const rows = Array.isArray(payload?.result) ? payload.result : []; + return rows.map(row => { + try { return JSON.parse(row); } catch { return null; } + }).filter(Boolean); +} + +if (_isDirectRun) { + const limitArg = Number(process.argv.find(arg => arg.startsWith('--limit='))?.split('=')[1] || 60); + const maxArg = Number(process.argv.find(arg => arg.startsWith('--max-candidates='))?.split('=')[1] || 10); + const history = await readForecastHistory(HISTORY_KEY, limitArg); + const candidates = selectBenchmarkCandidates(history, { maxCandidates: maxArg }); + console.log(JSON.stringify({ key: HISTORY_KEY, snapshots: history.length, candidates }, null, 2)); +} + +export { + toBenchmarkForecast, + summarizeObservedChange, + buildBenchmarkCandidate, + scoreCandidate, + selectBenchmarkCandidates, + readForecastHistory, +}; diff --git a/scripts/promote-forecast-benchmark-candidate.mjs b/scripts/promote-forecast-benchmark-candidate.mjs new file mode 100644 index 000000000..4d8b606ff --- /dev/null +++ b/scripts/promote-forecast-benchmark-candidate.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadEnvFile } from './_seed-utils.mjs'; +import { + readForecastHistory, + selectBenchmarkCandidates, +} from './extract-forecast-benchmark-candidates.mjs'; +import { + HISTORY_KEY, + makePrediction, + computeTrends, + buildForecastCase, + buildPriorForecastSnapshot, + annotateForecastChanges, + scoreForecastReadiness, + computeAnalysisPriority, +} from './seed-forecasts.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_OUTPUT_PATH = join(__dirname, 'data', 'forecast-historical-benchmark.json'); +const _isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); +if (_isDirectRun) loadEnvFile(import.meta.url); + +function roundPct(value) { + return `${Math.round((value || 0) * 100)}%`; +} + +function materializeForecast(input) { + const pred = makePrediction( + input.domain, + input.region, + input.title, + input.probability, + input.confidence, + input.timeHorizon, + input.signals || [], + ); + pred.trend = input.trend || pred.trend; + pred.newsContext = input.newsContext || []; + pred.calibration = input.calibration || null; + pred.cascades = input.cascades || []; + buildForecastCase(pred); + return pred; +} + +function buildSummaryExpectation(pred, priorForecast) { + if (!priorForecast) return `new in the current run, entering at ${roundPct(pred.probability)}`; + + const delta = pred.probability - priorForecast.probability; + if (Math.abs(delta) >= 0.05) { + return `${delta > 0 ? 'rose' : 'fell'} from ${roundPct(priorForecast.probability)} to ${roundPct(pred.probability)}`; + } + return `holding near ${roundPct(pred.probability)} versus ${roundPct(priorForecast.probability)}`; +} + +function buildItemExpectations(pred) { + return (pred.caseFile?.changeItems || []) + .filter(item => item && !item.startsWith('Evidence mix is broadly unchanged')) + .slice(0, 3); +} + +function deriveThresholds(candidate, options = {}) { + const readinessSlack = options.readinessSlack ?? 0.06; + const prioritySlack = options.prioritySlack ?? 0.08; + const pred = materializeForecast(candidate.forecast); + let prior = null; + + if (candidate.priorForecast) { + const priorPred = materializeForecast(candidate.priorForecast); + prior = { predictions: [buildPriorForecastSnapshot(priorPred)] }; + computeTrends([pred], prior); + buildForecastCase(pred); + annotateForecastChanges([pred], prior); + } + + const readiness = scoreForecastReadiness(pred); + const priority = computeAnalysisPriority(pred); + const thresholds = { + overallMin: +Math.max(0, readiness.overall - readinessSlack).toFixed(3), + overallMax: +Math.min(1, readiness.overall + readinessSlack).toFixed(3), + groundingMin: +Math.max(0, readiness.groundingScore - readinessSlack).toFixed(3), + priorityMin: +Math.max(0, priority - prioritySlack).toFixed(3), + priorityMax: +Math.min(1, priority + prioritySlack).toFixed(3), + trend: pred.trend, + changeSummaryIncludes: [buildSummaryExpectation(pred, candidate.priorForecast || null)], + }; + + const itemExpectations = buildItemExpectations(pred); + if (itemExpectations.length > 0) thresholds.changeItemsInclude = itemExpectations; + + return thresholds; +} + +function toHistoricalBenchmarkEntry(candidate, options = {}) { + return { + name: candidate.name, + eventDate: candidate.eventDate, + description: candidate.description, + priorForecast: candidate.priorForecast, + forecast: candidate.forecast, + thresholds: deriveThresholds(candidate, options), + }; +} + +function mergeHistoricalBenchmarks(existingEntries, nextEntry, options = {}) { + const replace = options.replace ?? false; + const index = existingEntries.findIndex(entry => entry.name === nextEntry.name); + + if (index >= 0 && !replace) { + throw new Error(`Benchmark entry "${nextEntry.name}" already exists. Re-run with --replace to overwrite it.`); + } + + const merged = [...existingEntries]; + if (index >= 0) { + merged[index] = nextEntry; + } else { + merged.push(nextEntry); + } + + merged.sort((a, b) => { + const left = a.eventDate || ''; + const right = b.eventDate || ''; + return left.localeCompare(right) || a.name.localeCompare(b.name); + }); + return merged; +} + +function createJsonPatch(existingEntries, nextEntry, options = {}) { + const index = existingEntries.findIndex(entry => entry.name === nextEntry.name); + if (index >= 0) { + if (!(options.replace ?? false)) { + throw new Error(`Benchmark entry "${nextEntry.name}" already exists. Re-run with --replace to overwrite it.`); + } + return [{ op: 'replace', path: `/${index}`, value: nextEntry }]; + } + return [{ op: 'add', path: `/${existingEntries.length}`, value: nextEntry }]; +} + +function renderUnifiedDiff(currentEntries, nextEntries, outputPath) { + const tempDir = mkdtempSync(join(tmpdir(), 'forecast-benchmark-')); + const currentPath = join(tempDir, `before-${basename(outputPath)}`); + const nextPath = join(tempDir, `after-${basename(outputPath)}`); + const currentText = `${JSON.stringify(currentEntries, null, 2)}\n`; + const nextText = `${JSON.stringify(nextEntries, null, 2)}\n`; + + writeFileSync(currentPath, currentText, 'utf8'); + writeFileSync(nextPath, nextText, 'utf8'); + + try { + try { + const rawDiff = execFileSync('git', ['diff', '--no-index', '--', currentPath, nextPath], { encoding: 'utf8' }); + return rawDiff + .replaceAll(currentPath, `a/${outputPath}`) + .replaceAll(nextPath, `b/${outputPath}`); + } catch (error) { + const output = `${error.stdout || ''}${error.stderr || ''}`.trim() + .replaceAll(currentPath, `a/${outputPath}`) + .replaceAll(nextPath, `b/${outputPath}`); + if (output) return output; + throw error; + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function parseArgs(argv) { + const args = { + limit: 60, + maxCandidates: 10, + index: 0, + output: DEFAULT_OUTPUT_PATH, + write: false, + replace: false, + name: '', + format: 'entry', + }; + + for (const arg of argv) { + if (arg.startsWith('--limit=')) args.limit = Number(arg.split('=')[1] || 60); + else if (arg.startsWith('--max-candidates=')) args.maxCandidates = Number(arg.split('=')[1] || 10); + else if (arg.startsWith('--index=')) args.index = Number(arg.split('=')[1] || 0); + else if (arg.startsWith('--output=')) args.output = arg.split('=').slice(1).join('='); + else if (arg.startsWith('--name=')) args.name = arg.split('=').slice(1).join('='); + else if (arg.startsWith('--format=')) args.format = arg.split('=').slice(1).join('=') || 'entry'; + else if (arg === '--write') args.write = true; + else if (arg === '--replace') args.replace = true; + } + + return args; +} + +function pickCandidate(candidates, options = {}) { + if (options.name) { + const named = candidates.find(candidate => candidate.name === options.name); + if (!named) throw new Error(`No extracted candidate named "${options.name}" was found.`); + return named; + } + + if (!Number.isInteger(options.index) || options.index < 0 || options.index >= candidates.length) { + throw new Error(`Candidate index ${options.index} is out of range for ${candidates.length} candidate(s).`); + } + return candidates[options.index]; +} + +function readBenchmarkFile(pathname) { + return JSON.parse(readFileSync(pathname, 'utf8')); +} + +function buildPreviewPayload(args, candidate, nextEntry, currentEntries) { + const merged = mergeHistoricalBenchmarks(currentEntries, nextEntry, { replace: args.replace }); + + if (args.format === 'json-patch') { + return { + mode: 'preview', + format: 'json-patch', + output: args.output, + candidateCount: null, + selected: candidate.name, + patch: createJsonPatch(currentEntries, nextEntry, { replace: args.replace }), + }; + } + + if (args.format === 'diff') { + return { + mode: 'preview', + format: 'diff', + output: args.output, + selected: candidate.name, + diff: renderUnifiedDiff(currentEntries, merged, args.output), + }; + } + + return { + mode: 'preview', + format: 'entry', + output: args.output, + selected: candidate.name, + entry: nextEntry, + }; +} + +if (_isDirectRun) { + const args = parseArgs(process.argv.slice(2)); + const history = await readForecastHistory(HISTORY_KEY, args.limit); + const candidates = selectBenchmarkCandidates(history, { maxCandidates: args.maxCandidates }); + + if (candidates.length === 0) { + console.error('No promotable forecast benchmark candidates are available yet.'); + process.exit(1); + } + + const candidate = pickCandidate(candidates, args); + const nextEntry = toHistoricalBenchmarkEntry(candidate); + const current = readBenchmarkFile(args.output); + + if (!args.write) { + const preview = buildPreviewPayload(args, candidate, nextEntry, current); + preview.candidateCount = candidates.length; + console.log(JSON.stringify(preview, null, 2)); + } else { + const merged = mergeHistoricalBenchmarks(current, nextEntry, { replace: args.replace }); + writeFileSync(args.output, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); + console.log(JSON.stringify({ + mode: args.replace ? 'replaced' : 'appended', + output: args.output, + selected: candidate.name, + totalEntries: merged.length, + }, null, 2)); + } +} + +export { + materializeForecast, + buildSummaryExpectation, + buildItemExpectations, + deriveThresholds, + toHistoricalBenchmarkEntry, + mergeHistoricalBenchmarks, + createJsonPatch, + renderUnifiedDiff, + buildPreviewPayload, + pickCandidate, + parseArgs, + DEFAULT_OUTPUT_PATH, +}; diff --git a/scripts/seed-forecasts.mjs b/scripts/seed-forecasts.mjs index 53c471ab6..6b6764000 100644 --- a/scripts/seed-forecasts.mjs +++ b/scripts/seed-forecasts.mjs @@ -4,13 +4,22 @@ import crypto from 'node:crypto'; import { readFileSync } from 'node:fs'; import { loadEnvFile, runSeed, CHROME_UA } from './_seed-utils.mjs'; import { tagRegions } from './_prediction-scoring.mjs'; +import { resolveR2StorageConfig, putR2JsonObject } from './_r2-storage.mjs'; const _isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); if (_isDirectRun) loadEnvFile(import.meta.url); -const CANONICAL_KEY = 'forecast:predictions:v1'; -const PRIOR_KEY = 'forecast:predictions:prior:v1'; -const TTL_SECONDS = 4200; // 70min — covers 1h cron interval + cold start gap +const CANONICAL_KEY = 'forecast:predictions:v2'; +const PRIOR_KEY = 'forecast:predictions:prior:v2'; +const HISTORY_KEY = 'forecast:predictions:history:v1'; +const TTL_SECONDS = 3600; +const HISTORY_MAX_RUNS = 200; +const HISTORY_MAX_FORECASTS = 25; +const HISTORY_TTL_SECONDS = 45 * 24 * 60 * 60; +const TRACE_LATEST_KEY = 'forecast:trace:latest:v1'; +const TRACE_RUNS_KEY = 'forecast:trace:runs:v1'; +const TRACE_RUNS_MAX = 50; +const TRACE_REDIS_TTL_SECONDS = 60 * 24 * 60 * 60; const THEATER_IDS = [ 'iran-theater', 'taiwan-theater', 'baltic-theater', @@ -59,6 +68,20 @@ function getRedisCredentials() { return { url, token }; } +async function redisCommand(url, token, command) { + const resp = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`Redis command failed: HTTP ${resp.status} — ${text.slice(0, 200)}`); + } + return resp.json(); +} + async function redisGet(url, token, key) { const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, { headers: { Authorization: `Bearer ${token}` }, @@ -179,6 +202,7 @@ function makePrediction(domain, region, title, probability, confidence, timeHori region, title, scenario: '', + feedSummary: '', probability: Math.round(Math.max(0, Math.min(1, probability)) * 1000) / 1000, confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 1000) / 1000, timeHorizon, @@ -187,6 +211,7 @@ function makePrediction(domain, region, title, probability, confidence, timeHori trend: 'stable', priorProbability: 0, calibration: null, + caseFile: null, createdAt: now, updatedAt: now, }; @@ -328,6 +353,7 @@ function detectMarketScenarios(inputs) { const graph = loadEntityGraph(); for (const c of scores) { if (!c.score || c.score <= 75) continue; + const countryName = c.name || resolveCountryName(c.code || '') || c.code; // Find theater region: check entity graph links for theater nodes with commodity sensitivity const nodeId = graph.aliases?.[c.code] || graph.aliases?.[c.name]; const node = nodeId ? graph.nodes?.[nodeId] : null; @@ -407,8 +433,7 @@ function detectSupplyChainScenarios(inputs) { } const riskNorm = normalize(cp.riskScore || 70, 40, 100); - const severityFloor = cp.riskLevel === 'critical' ? 0.55 : cp.riskLevel === 'high' ? 0.35 : 0; - const prob = Math.min(0.85, Math.max(severityFloor, riskNorm * 0.9) + (aisGaps.length > 0 ? 0.1 : 0) + (nearbyJam.length > 0 ? 0.05 : 0)); + const prob = Math.min(0.85, riskNorm * 0.7 + (aisGaps.length > 0 ? 0.1 : 0) + (nearbyJam.length > 0 ? 0.05 : 0)); const confidence = Math.max(0.3, normalize(sourceCount, 0, 4)); predictions.push(makePrediction( @@ -647,7 +672,7 @@ function detectGpsJammingScenarios(inputs) { predictions.push(makePrediction( 'supply_chain', region, `GPS interference in ${region} shipping zone`, - Math.min(0.75, normalize(inRegion.length, 2, 60) * 0.7 + (inRegion.length > 20 ? 0.1 : 0)), + Math.min(0.6, normalize(inRegion.length, 2, 30) * 0.5), 0.3, '7d', [{ type: 'gps_jamming', value: `${inRegion.length} jamming hexes in ${region}`, weight: 0.5 }], )); @@ -660,6 +685,115 @@ const MARKET_TAG_TO_REGION = { america: 'Americas', latam: 'Latin America', africa: 'Africa', oceania: 'Oceania', }; +const DOMAIN_HINTS = { + conflict: ['conflict', 'war', 'strike', 'attack', 'ceasefire', 'offensive', 'military'], + market: ['market', 'oil', 'gas', 'trade', 'tariff', 'inflation', 'recession', 'price', 'shipping', 'semiconductor'], + supply_chain: ['shipping', 'supply', 'chokepoint', 'port', 'transit', 'freight', 'logistics', 'gps'], + political: ['election', 'government', 'parliament', 'protest', 'unrest', 'leadership', 'coalition'], + military: ['military', 'force', 'deployment', 'exercise', 'missile', 'carrier', 'bomber', 'air defense'], + infrastructure: ['outage', 'blackout', 'power', 'grid', 'pipeline', 'cyber', 'telecom', 'internet'], +}; + +const DOMAIN_ACTOR_BLUEPRINTS = { + conflict: [ + { key: 'state_command', name: 'Regional command authority', category: 'state', influenceScore: 0.88 }, + { key: 'security_forces', name: 'Security forces', category: 'security', influenceScore: 0.82 }, + { key: 'external_power', name: 'External power broker', category: 'external', influenceScore: 0.74 }, + { key: 'energy_market', name: 'Energy market participants', category: 'market', influenceScore: 0.58 }, + ], + market: [ + { key: 'commodity_traders', name: 'Commodity traders', category: 'market', influenceScore: 0.84 }, + { key: 'policy_officials', name: 'Policy officials', category: 'state', influenceScore: 0.72 }, + { key: 'large_importers', name: 'Large importers', category: 'commercial', influenceScore: 0.68 }, + { key: 'regional_producers', name: 'Regional producers', category: 'commercial', influenceScore: 0.62 }, + ], + supply_chain: [ + { key: 'shipping_operators', name: 'Shipping operators', category: 'commercial', influenceScore: 0.84 }, + { key: 'port_authorities', name: 'Port authorities', category: 'infrastructure', influenceScore: 0.71 }, + { key: 'cargo_owners', name: 'Major cargo owners', category: 'commercial', influenceScore: 0.67 }, + { key: 'marine_insurers', name: 'Marine insurers', category: 'market', influenceScore: 0.54 }, + ], + political: [ + { key: 'incumbent_leadership', name: 'Incumbent leadership', category: 'state', influenceScore: 0.86 }, + { key: 'opposition_networks', name: 'Opposition networks', category: 'political', influenceScore: 0.69 }, + { key: 'regional_diplomats', name: 'Regional diplomats', category: 'external', influenceScore: 0.57 }, + { key: 'civil_society', name: 'Civil society blocs', category: 'civic', influenceScore: 0.49 }, + ], + military: [ + { key: 'defense_planners', name: 'Defense planners', category: 'security', influenceScore: 0.86 }, + { key: 'allied_observers', name: 'Allied observers', category: 'external', influenceScore: 0.68 }, + { key: 'commercial_carriers', name: 'Commercial carriers', category: 'commercial', influenceScore: 0.51 }, + { key: 'regional_command', name: 'Regional command posts', category: 'security', influenceScore: 0.74 }, + ], + infrastructure: [ + { key: 'grid_operators', name: 'Grid operators', category: 'infrastructure', influenceScore: 0.83 }, + { key: 'civil_protection', name: 'Civil protection authorities', category: 'state', influenceScore: 0.72 }, + { key: 'critical_providers', name: 'Critical service providers', category: 'commercial', influenceScore: 0.64 }, + { key: 'cyber_responders', name: 'Incident response teams', category: 'security', influenceScore: 0.59 }, + ], +}; + +const SIGNAL_TRIGGER_TEMPLATES = { + cii: (pred, signal) => `Watch for another deterioration in ${pred.region} risk indicators beyond ${signal.value}.`, + cii_delta: (pred) => `A further sharp 24h deterioration in ${pred.region} risk metrics would strengthen the base case.`, + conflict_events: (pred) => `A fresh cluster of reported conflict events in ${pred.region} would raise escalation pressure quickly.`, + ucdp: (pred) => `A sustained increase in verified conflict-event counts would confirm the escalation path in ${pred.region}.`, + theater: (pred) => `Any shift from elevated to critical theater posture in ${pred.region} would move this forecast higher.`, + indicators: (pred) => `More active posture indicators in ${pred.region} would support an escalatory revision.`, + mil_flights: () => 'Another spike in military flight anomalies would strengthen the near-term risk path.', + chokepoint: (_pred, signal) => `${signal.value} persisting for another cycle would deepen downstream disruption risk.`, + ais_gap: () => 'Further AIS gaps around the affected route would confirm operational disruption rather than noise.', + gps_jamming: () => 'Wider GPS interference across adjacent zones would increase the chance of spillover effects.', + unrest: (pred) => `A higher unrest signal in ${pred.region} would raise the probability of instability broadening.`, + anomaly: () => 'A new anomaly spike above the current protest baseline would strengthen the forecast.', + outage: (pred) => `A second major outage in ${pred.region} would turn a contained event into a cascade risk.`, + cyber: (pred) => `Additional cyber incidents tied to ${pred.region} infrastructure would materially worsen the case.`, + prediction_market: () => 'A market repricing of 8-10 points would be a meaningful confirmation or rejection signal.', + news_corroboration: (pred) => `More directly matched reporting on ${pred.region} would improve confidence in the current path.`, +}; + +function tokenizeText(text) { + return (text || '') + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(token => token.length >= 3); +} + +function getDomainTerms(domain) { + return DOMAIN_HINTS[domain] || []; +} + +function computeHeadlineRelevance(headline, terms, domain) { + const lower = headline.toLowerCase(); + let score = 0; + for (const term of terms) { + const normalized = term.toLowerCase(); + if (!normalized || normalized.length < 3) continue; + if (lower.includes(normalized)) score += normalized.length > 5 ? 3 : 2; + } + for (const hint of getDomainTerms(domain)) { + if (lower.includes(hint)) score += 1; + } + return score; +} + +function computeMarketMatchScore(pred, marketTitle, regionTerms) { + const lower = marketTitle.toLowerCase(); + let score = 0; + for (const term of regionTerms) { + const normalized = term.toLowerCase(); + if (!normalized || normalized.length < 3) continue; + if (lower.includes(normalized)) score += normalized.length > 5 ? 3 : 2; + } + for (const hint of getDomainTerms(pred.domain)) { + if (lower.includes(hint)) score += 1; + } + for (const token of tokenizeText(pred.title)) { + if (lower.includes(token)) score += 1; + } + return score; +} + function detectFromPredictionMarkets(inputs) { const predictions = []; const markets = inputs.predictionMarkets?.geopolitical || []; @@ -821,11 +955,24 @@ function calibrateWithMarkets(predictions, markets) { if (!markets?.geopolitical) return; for (const pred of predictions) { const keywords = REGION_KEYWORDS[pred.region] || []; - if (keywords.length === 0) continue; - const match = markets.geopolitical.find(m => { - const mRegions = tagRegions(m.title); - return mRegions.some(r => keywords.includes(r)); - }); + const regionTerms = [...new Set([...getSearchTermsForRegion(pred.region), pred.region])]; + if (keywords.length === 0 && regionTerms.length === 0) continue; + const candidates = markets.geopolitical + .map(m => { + const mRegions = tagRegions(m.title); + const sameMacroRegion = keywords.length > 0 && mRegions.some(r => keywords.includes(r)); + const score = computeMarketMatchScore(pred, m.title, regionTerms); + return { market: m, score, sameMacroRegion }; + }) + .filter(item => item.sameMacroRegion || item.score >= 3) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return (b.market.volume || 0) - (a.market.volume || 0); + }); + const best = candidates[0]; + const match = best && (best.score >= 4 || (best.sameMacroRegion && best.score >= 2)) + ? best.market + : null; if (match) { const marketProb = (match.yesPrice || 50) / 100; pred.calibration = { @@ -943,13 +1090,13 @@ function attachNewsContext(predictions, newsInsights, newsDigest) { for (const pred of predictions) { const searchTerms = getSearchTermsForRegion(pred.region); + const matched = allHeadlines + .map(headline => ({ headline, score: computeHeadlineRelevance(headline, searchTerms, pred.domain) })) + .filter(item => item.score >= 2) + .sort((a, b) => b.score - a.score || a.headline.length - b.headline.length) + .map(item => item.headline); - const matched = allHeadlines.filter(h => { - const lower = h.toLowerCase(); - return searchTerms.some(t => lower.includes(t.toLowerCase())); - }); - - pred.newsContext = matched.length > 0 ? matched.slice(0, 3) : allHeadlines.slice(0, 3); + pred.newsContext = matched.slice(0, 4); if (matched.length > 0) { pred.signals.push({ @@ -989,6 +1136,730 @@ function computeConfidence(predictions) { } } +function roundPct(value) { + return `${Math.round((value || 0) * 100)}%`; +} + +function slugifyValue(value) { + return (value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48); +} + +function buildCounterEvidence(pred) { + const items = []; + if (!pred.newsContext || pred.newsContext.length === 0) { + items.push({ type: 'coverage_gap', summary: `No directly matched headlines are currently attached to ${pred.region}.`, weight: 0.2 }); + } + if (pred.confidence < 0.45) { + items.push({ type: 'confidence', summary: `Confidence is only ${roundPct(pred.confidence)}, implying thin source diversity or mixed calibration.`, weight: 0.25 }); + } + if (pred.trend === 'falling') { + items.push({ type: 'trend', summary: `The forecast is already trending down from its prior snapshot (${roundPct(pred.priorProbability)} to ${roundPct(pred.probability)}).`, weight: 0.35 }); + } + if (pred.calibration) { + const drift = pred.calibration.drift; + if (Math.abs(drift) >= 0.08) { + const direction = drift > 0 ? 'below' : 'above'; + items.push({ + type: 'market_divergence', + summary: `${pred.calibration.source} pricing in "${pred.calibration.marketTitle}" sits ${direction} the internal estimate by ${Math.round(Math.abs(drift) * 100)} points.`, + weight: Math.min(0.5, Math.abs(drift)), + }); + } + } + return items.slice(0, 4); +} + +function buildCaseTriggers(pred) { + const triggers = []; + for (const signal of pred.signals || []) { + const template = SIGNAL_TRIGGER_TEMPLATES[signal.type]; + if (!template) continue; + triggers.push(template(pred, signal)); + if (triggers.length >= 4) break; + } + if (pred.calibration) { + triggers.push(`If prediction markets move decisively away from ${roundPct(pred.calibration.marketPrice)}, revisit the probability baseline.`); + } + return [...new Set(triggers)].slice(0, 4); +} + +function buildForecastActors(pred) { + const blueprints = DOMAIN_ACTOR_BLUEPRINTS[pred.domain] || [ + { key: 'regional_watchers', name: 'Regional watchers', category: 'general', influenceScore: 0.6 }, + { key: 'market_participants', name: 'Market participants', category: 'market', influenceScore: 0.52 }, + { key: 'external_observers', name: 'External observers', category: 'external', influenceScore: 0.48 }, + ]; + const topTrigger = buildCaseTriggers(pred)[0]; + const topSupport = pred.signals?.[0]?.value || pred.caseFile?.supportingEvidence?.[0]?.summary || pred.title; + const drift = Math.abs(pred.calibration?.drift || 0); + + return blueprints.slice(0, 4).map((blueprint, index) => { + const objectives = []; + const constraints = []; + const likelyActions = []; + + if (pred.domain === 'conflict' || pred.domain === 'military') { + objectives.push(`Prevent the ${pred.region} situation from moving beyond the current ${pred.trend} path.`); + objectives.push(`Preserve decision freedom if ${topSupport} hardens into a broader escalation signal.`); + likelyActions.push(`Reposition attention and resources around ${pred.region} over the next ${pred.timeHorizon}.`); + } else if (pred.domain === 'supply_chain') { + objectives.push(`Keep critical flows through ${pred.region} functioning over the ${pred.timeHorizon}.`); + objectives.push(`Reduce exposure if ${topSupport} persists into the next cycle.`); + likelyActions.push(`Adjust routing and contingency plans around ${pred.region}.`); + } else if (pred.domain === 'market') { + objectives.push(`Price whether stress in ${pred.region} becomes durable over the ${pred.timeHorizon}.`); + objectives.push(`Protect against repricing if ${topSupport} intensifies.`); + likelyActions.push(`Rebalance positions if the probability path moves away from ${roundPct(pred.probability)}.`); + } else if (pred.domain === 'infrastructure') { + objectives.push(`Contain service degradation in ${pred.region} before it becomes cross-system.`); + objectives.push(`Maintain continuity if ${topSupport} spreads across adjacent systems.`); + likelyActions.push(`Prioritize mitigation and continuity measures around the most exposed nodes.`); + } else { + objectives.push(`Manage the current ${pred.trend} trajectory in ${pred.region}.`); + objectives.push(`Limit the chance that ${topSupport} becomes a wider destabilizing signal.`); + likelyActions.push(`Shift messaging and posture as new evidence arrives.`); + } + + if (topTrigger) likelyActions.push(topTrigger); + if ((pred.cascades || []).length > 0) { + likelyActions.push(`Monitor spillover into ${(pred.cascades || []).slice(0, 2).map(c => c.domain).join(' and ')}.`); + } + + if (!pred.newsContext?.length) { + constraints.push(`Public reporting directly tied to ${pred.region} is still thin.`); + } + if (drift >= 0.08 && pred.calibration?.marketTitle) { + constraints.push(`Market pricing in "${pred.calibration.marketTitle}" is not fully aligned with the internal estimate.`); + } + if (pred.trend === 'falling') { + constraints.push(`Recent momentum is softening from ${roundPct(pred.priorProbability)} to ${roundPct(pred.probability)}.`); + } + if (constraints.length === 0) { + constraints.push(`Action remains bounded by the current ${roundPct(pred.confidence)} confidence level.`); + } + + return { + id: `${pred.id}:${slugifyValue(blueprint.key || blueprint.name || `actor_${index}`)}`, + name: blueprint.name, + category: blueprint.category, + role: `${blueprint.name} is a primary ${blueprint.category} actor for the ${pred.domain} path in ${pred.region}.`, + objectives: objectives.slice(0, 2), + constraints: constraints.slice(0, 2), + likelyActions: [...new Set(likelyActions)].slice(0, 3), + influenceScore: +(blueprint.influenceScore || 0.5).toFixed(3), + }; + }); +} + +function buildForecastWorldState(pred, actors = [], triggers = [], counterEvidence = []) { + const leadSupport = pred.caseFile?.supportingEvidence?.[0]?.summary || pred.signals?.[0]?.value || pred.title; + const summary = `${leadSupport} is setting the current ${pred.trend} baseline in ${pred.region}, with the forecast sitting near ${roundPct(pred.probability)} over the ${pred.timeHorizon}.`; + + const activePressures = [ + ...(pred.caseFile?.supportingEvidence || []).slice(0, 3).map(item => item.summary), + ...(pred.cascades || []).slice(0, 1).map(cascade => `Spillover pressure into ${cascade.domain} via ${cascade.effect}.`), + ].filter(Boolean).slice(0, 4); + + const stabilizers = [ + ...counterEvidence.slice(0, 2).map(item => item.summary), + pred.trend === 'falling' ? `The observed trend is already easing from ${roundPct(pred.priorProbability)} to ${roundPct(pred.probability)}.` : '', + pred.calibration && Math.abs(pred.calibration.drift || 0) < 0.05 + ? `Prediction-market pricing near ${roundPct(pred.calibration.marketPrice)} is not strongly disputing the internal estimate.` + : '', + ].filter(Boolean).slice(0, 3); + + const keyUnknowns = [ + ...triggers.slice(0, 2), + actors[0]?.constraints?.[0] || '', + !pred.newsContext?.length ? `Whether directly matched reporting on ${pred.region} appears in the next run.` : '', + ].filter(Boolean).slice(0, 4); + + return { + summary, + activePressures, + stabilizers, + keyUnknowns, + }; +} + +function branchTitle(kind) { + if (kind === 'base') return 'Base Branch'; + if (kind === 'escalatory') return 'Escalatory Branch'; + return 'Contrarian Branch'; +} + +function branchShift(kind, pred, context = {}) { + const pressureCount = context.worldState?.activePressures?.length || 0; + const stabilizerCount = context.worldState?.stabilizers?.length || 0; + const triggerCount = context.triggers?.length || 0; + const cascadeFactor = Math.min(0.06, (pred.cascades?.length || 0) * 0.02); + const driftFactor = Math.min(0.04, Math.abs(pred.calibration?.drift || 0) * 0.5); + + if (kind === 'escalatory') { + return Math.min(0.22, 0.08 + (pressureCount * 0.02) + (triggerCount * 0.015) + cascadeFactor - (stabilizerCount * 0.01)); + } + if (kind === 'contrarian') { + return -Math.min(0.22, 0.08 + (stabilizerCount * 0.025) + driftFactor + (pred.trend === 'falling' ? 0.02 : 0)); + } + + const trendNudge = pred.trend === 'rising' ? 0.02 : pred.trend === 'falling' ? -0.02 : 0; + return Math.max(-0.08, Math.min(0.08, trendNudge + ((pressureCount - stabilizerCount) * 0.01))); +} + +function buildBranchRounds(kind, pred, context = {}) { + const leadPressure = context.worldState?.activePressures?.[0] || pred.signals?.[0]?.value || pred.title; + const leadTrigger = context.triggers?.[0] || `The next ${pred.domain} update in ${pred.region} becomes the key threshold.`; + const leadStabilizer = context.worldState?.stabilizers?.[0] || context.counterEvidence?.[0]?.summary || `The current ${roundPct(pred.confidence)} confidence level keeps this path from becoming fully settled.`; + const actors = context.actors || []; + + const round1 = { + round: 1, + focus: kind === 'contrarian' ? 'Constraint absorption' : 'Signal absorption', + developments: [ + kind === 'contrarian' + ? leadStabilizer + : leadPressure, + ].filter(Boolean), + actorMoves: actors.slice(0, 2).map(actor => actor.likelyActions?.[0]).filter(Boolean), + probabilityShift: +((branchShift(kind, pred, context)) / 3).toFixed(3), + }; + + const round2 = { + round: 2, + focus: 'Actor response', + developments: [ + kind === 'escalatory' + ? leadTrigger + : kind === 'contrarian' + ? `Actors slow the path if ${leadStabilizer.toLowerCase()}` + : `Actors adapt to whether ${leadTrigger.toLowerCase()}`, + ], + actorMoves: actors.slice(0, 3).map(actor => actor.likelyActions?.[1] || actor.objectives?.[0]).filter(Boolean), + probabilityShift: +((branchShift(kind, pred, context)) / 3).toFixed(3), + }; + + const round3 = { + round: 3, + focus: 'System effect', + developments: [ + kind === 'escalatory' && (pred.cascades?.length || 0) > 0 + ? `Spillover becomes visible in ${(pred.cascades || []).slice(0, 2).map(c => c.domain).join(' and ')}.` + : kind === 'contrarian' + ? `The path cools if counter-pressure remains stronger than fresh escalation evidence.` + : `The path settles near the current balance of pressure and restraint.`, + ], + actorMoves: actors.slice(0, 2).map(actor => actor.constraints?.[0]).filter(Boolean), + probabilityShift: +(branchShift(kind, pred, context) - (((branchShift(kind, pred, context)) / 3) * 2)).toFixed(3), + }; + + return [round1, round2, round3]; +} + +function buildForecastBranches(pred, context = {}) { + return ['base', 'escalatory', 'contrarian'].map(kind => { + const shift = branchShift(kind, pred, context); + const projectedProbability = clamp01((pred.probability || 0) + shift); + const rounds = buildBranchRounds(kind, pred, context); + const leadPressure = context.worldState?.activePressures?.[0] || pred.signals?.[0]?.value || pred.title; + const leadStabilizer = context.worldState?.stabilizers?.[0] || context.counterEvidence?.[0]?.summary || `The current ${roundPct(pred.confidence)} confidence level still leaves room for reversal.`; + const leadTrigger = context.triggers?.[0] || `The next evidence cycle in ${pred.region} becomes decisive.`; + + const summary = kind === 'escalatory' + ? `${leadTrigger} If that threshold breaks, the path can move above the current ${roundPct(pred.probability)} baseline.` + : kind === 'contrarian' + ? `${leadStabilizer} If that restraint persists, the forecast can move below the current ${roundPct(pred.probability)} baseline.` + : `${leadPressure} keeps the central path near ${roundPct(projectedProbability)} over the ${pred.timeHorizon}.`; + + const outcome = kind === 'escalatory' + ? `Actors treat escalation as increasingly self-reinforcing, especially if cross-domain pressure appears.` + : kind === 'contrarian' + ? `Actors prioritize containment and the system drifts toward stabilization unless new hard signals emerge.` + : `Actors absorb the current evidence mix without a decisive break toward either shock or relief.`; + + return { + kind, + title: branchTitle(kind), + summary: summary.slice(0, 400), + outcome: outcome.slice(0, 400), + projectedProbability: +projectedProbability.toFixed(3), + rounds, + }; + }); +} + +function buildActorLenses(pred) { + const actors = buildForecastActors(pred); + const lenses = actors.map(actor => { + const objective = actor.objectives?.[0] || actor.role; + const action = actor.likelyActions?.[0] || `Track ${pred.region} closely over the ${pred.timeHorizon}.`; + return `${actor.name}: ${objective} ${action}`; + }); + if (pred.cascades?.length > 0) { + lenses.push(`Cross-domain watchers will track spillover into ${pred.cascades.slice(0, 2).map(c => c.domain).join(' and ')} if this path hardens.`); + } + return lenses.slice(0, 4); +} + +function buildForecastCase(pred) { + const supportingEvidence = []; + const rankedSignals = [...(pred.signals || [])].sort((a, b) => (b.weight || 0) - (a.weight || 0)); + + for (const signal of rankedSignals.slice(0, 4)) { + supportingEvidence.push({ + type: signal.type, + summary: signal.value, + weight: +(signal.weight || 0).toFixed(3), + }); + } + + for (const headline of (pred.newsContext || []).slice(0, 2)) { + supportingEvidence.push({ + type: 'headline', + summary: headline, + weight: 0.15, + }); + } + + if (pred.calibration) { + supportingEvidence.push({ + type: 'market_calibration', + summary: `${pred.calibration.source} prices "${pred.calibration.marketTitle}" near ${roundPct(pred.calibration.marketPrice)}.`, + weight: Math.min(0.5, Math.abs(pred.calibration.drift) + 0.2), + }); + } + + for (const cascade of (pred.cascades || []).slice(0, 2)) { + supportingEvidence.push({ + type: 'cascade', + summary: `Potential spillover into ${cascade.domain} via ${cascade.effect} (${roundPct(cascade.probability)}).`, + weight: +(cascade.probability || 0).toFixed(3), + }); + } + + const counterEvidence = buildCounterEvidence(pred); + const triggers = buildCaseTriggers(pred); + const actors = buildForecastActors(pred); + const actorLenses = actors.map(actor => { + const objective = actor.objectives?.[0] || actor.role; + const action = actor.likelyActions?.[0] || `Track ${pred.region} closely over the ${pred.timeHorizon}.`; + return `${actor.name}: ${objective} ${action}`; + }).slice(0, 4); + const worldState = buildForecastWorldState( + { + ...pred, + caseFile: { + ...(pred.caseFile || {}), + supportingEvidence: supportingEvidence.slice(0, 6), + }, + }, + actors, + triggers, + counterEvidence, + ); + const branches = buildForecastBranches(pred, { + actors, + triggers, + counterEvidence, + worldState, + }); + + pred.caseFile = { + supportingEvidence: supportingEvidence.slice(0, 6), + counterEvidence, + triggers, + actorLenses, + baseCase: '', + escalatoryCase: '', + contrarianCase: '', + changeSummary: '', + changeItems: [], + actors, + worldState, + branches, + }; + + return pred.caseFile; +} + +function buildForecastCases(predictions) { + for (const pred of predictions) buildForecastCase(pred); +} + +function buildPriorForecastSnapshot(pred) { + return { + id: pred.id, + probability: pred.probability, + signals: (pred.signals || []).map(signal => signal.value), + newsContext: pred.newsContext || [], + calibration: pred.calibration + ? { + marketTitle: pred.calibration.marketTitle, + marketPrice: pred.calibration.marketPrice, + } + : null, + }; +} + +function buildHistoryForecastEntry(pred) { + return { + id: pred.id, + domain: pred.domain, + region: pred.region, + title: pred.title, + probability: pred.probability, + confidence: pred.confidence, + timeHorizon: pred.timeHorizon, + trend: pred.trend, + priorProbability: pred.priorProbability, + signals: (pred.signals || []).slice(0, 6).map(signal => ({ + type: signal.type, + value: signal.value, + weight: signal.weight, + })), + newsContext: (pred.newsContext || []).slice(0, 4), + calibration: pred.calibration + ? { + marketTitle: pred.calibration.marketTitle, + marketPrice: pred.calibration.marketPrice, + drift: pred.calibration.drift, + source: pred.calibration.source, + } + : null, + cascades: (pred.cascades || []).slice(0, 3).map(cascade => ({ + domain: cascade.domain, + effect: cascade.effect, + probability: cascade.probability, + })), + }; +} + +function buildHistorySnapshot(data, options = {}) { + const maxForecasts = options.maxForecasts || HISTORY_MAX_FORECASTS; + const predictions = Array.isArray(data?.predictions) ? data.predictions : []; + return { + generatedAt: data?.generatedAt || Date.now(), + predictions: predictions.slice(0, maxForecasts).map(buildHistoryForecastEntry), + }; +} + +async function appendHistorySnapshot(data, options = {}) { + const key = options.key || HISTORY_KEY; + const maxRuns = options.maxRuns || HISTORY_MAX_RUNS; + const ttlSeconds = options.ttlSeconds || HISTORY_TTL_SECONDS; + const snapshot = buildHistorySnapshot(data, options); + const { url, token } = getRedisCredentials(); + + await redisCommand(url, token, ['LPUSH', key, JSON.stringify(snapshot)]); + await redisCommand(url, token, ['LTRIM', key, 0, maxRuns - 1]); + await redisCommand(url, token, ['EXPIRE', key, ttlSeconds]); + return snapshot; +} + +function getTraceMaxForecasts() { + const parsed = Number(process.env.FORECAST_TRACE_MAX_FORECASTS || 12); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(50, Math.floor(parsed)) : 12; +} + +function applyTraceMeta(pred, patch) { + pred.traceMeta = { + ...(pred.traceMeta || {}), + ...patch, + }; +} + +function buildTraceRunPrefix(runId, generatedAt, basePrefix) { + const iso = new Date(generatedAt || Date.now()).toISOString(); + const [datePart] = iso.split('T'); + const [year, month, day] = datePart.split('-'); + return `${basePrefix}/${year}/${month}/${day}/${runId}`; +} + +function buildForecastTraceRecord(pred, rank) { + return { + rank, + id: pred.id, + title: pred.title, + domain: pred.domain, + region: pred.region, + probability: pred.probability, + confidence: pred.confidence, + trend: pred.trend, + timeHorizon: pred.timeHorizon, + priorProbability: pred.priorProbability, + feedSummary: pred.feedSummary || '', + scenario: pred.scenario || '', + projections: pred.projections || null, + calibration: pred.calibration || null, + cascades: pred.cascades || [], + signals: pred.signals || [], + newsContext: pred.newsContext || [], + perspectives: pred.perspectives || null, + caseFile: pred.caseFile || null, + readiness: scoreForecastReadiness(pred), + analysisPriority: computeAnalysisPriority(pred), + traceMeta: pred.traceMeta || { + narrativeSource: 'fallback', + branchSource: 'deterministic', + }, + }; +} + +function buildForecastTraceArtifacts(data, context = {}, config = {}) { + const generatedAt = data?.generatedAt || Date.now(); + const predictions = Array.isArray(data?.predictions) ? data.predictions : []; + const maxForecasts = config.maxForecasts || getTraceMaxForecasts(); + const tracedPredictions = predictions.slice(0, maxForecasts).map((pred, index) => buildForecastTraceRecord(pred, index + 1)); + const prefix = buildTraceRunPrefix( + context.runId || `run_${generatedAt}`, + generatedAt, + config.basePrefix || 'seed-data/forecast-traces' + ); + const manifestKey = `${prefix}/manifest.json`; + const summaryKey = `${prefix}/summary.json`; + const forecastKeys = tracedPredictions.map(item => ({ + id: item.id, + title: item.title, + key: `${prefix}/forecasts/${item.id}.json`, + })); + + const manifest = { + version: 1, + runId: context.runId || '', + generatedAt, + generatedAtIso: new Date(generatedAt).toISOString(), + canonicalKey: CANONICAL_KEY, + forecastCount: predictions.length, + tracedForecastCount: tracedPredictions.length, + manifestKey, + summaryKey, + forecastKeys, + }; + + const summary = { + runId: manifest.runId, + generatedAt: manifest.generatedAt, + generatedAtIso: manifest.generatedAtIso, + forecastCount: manifest.forecastCount, + tracedForecastCount: manifest.tracedForecastCount, + topForecasts: tracedPredictions.map(item => ({ + rank: item.rank, + id: item.id, + title: item.title, + domain: item.domain, + region: item.region, + probability: item.probability, + confidence: item.confidence, + trend: item.trend, + analysisPriority: item.analysisPriority, + readiness: item.readiness, + narrativeSource: item.traceMeta?.narrativeSource || 'fallback', + llmCached: !!item.traceMeta?.llmCached, + })), + }; + + return { + prefix, + manifestKey, + summaryKey, + manifest, + summary, + forecasts: tracedPredictions.map(item => ({ + key: `${prefix}/forecasts/${item.id}.json`, + payload: item, + })), + }; +} + +async function writeForecastTracePointer(pointer) { + const { url, token } = getRedisCredentials(); + await redisCommand(url, token, ['SET', TRACE_LATEST_KEY, JSON.stringify(pointer), 'EX', TRACE_REDIS_TTL_SECONDS]); + await redisCommand(url, token, ['LPUSH', TRACE_RUNS_KEY, JSON.stringify(pointer)]); + await redisCommand(url, token, ['LTRIM', TRACE_RUNS_KEY, 0, TRACE_RUNS_MAX - 1]); + await redisCommand(url, token, ['EXPIRE', TRACE_RUNS_KEY, TRACE_REDIS_TTL_SECONDS]); +} + +async function writeForecastTraceArtifacts(data, context = {}) { + const storageConfig = resolveR2StorageConfig(); + if (!storageConfig) return null; + + const artifacts = buildForecastTraceArtifacts(data, context, { + basePrefix: storageConfig.basePrefix, + maxForecasts: getTraceMaxForecasts(), + }); + + await putR2JsonObject(storageConfig, artifacts.manifestKey, artifacts.manifest, { + runid: String(artifacts.manifest.runId || ''), + kind: 'manifest', + }); + await putR2JsonObject(storageConfig, artifacts.summaryKey, artifacts.summary, { + runid: String(artifacts.manifest.runId || ''), + kind: 'summary', + }); + await Promise.all( + artifacts.forecasts.map((item, index) => putR2JsonObject(storageConfig, item.key, item.payload, { + runid: String(artifacts.manifest.runId || ''), + kind: 'forecast', + rank: String(index + 1), + })), + ); + + const pointer = { + runId: artifacts.manifest.runId, + generatedAt: artifacts.manifest.generatedAt, + generatedAtIso: artifacts.manifest.generatedAtIso, + bucket: storageConfig.bucket, + prefix: artifacts.prefix, + manifestKey: artifacts.manifestKey, + summaryKey: artifacts.summaryKey, + forecastCount: artifacts.manifest.forecastCount, + tracedForecastCount: artifacts.manifest.tracedForecastCount, + }; + await writeForecastTracePointer(pointer); + return pointer; +} + +function buildChangeItems(pred, prev) { + const items = []; + if (!prev) { + items.push(`New forecast surfaced in this run at ${roundPct(pred.probability)} over the ${pred.timeHorizon}.`); + if (pred.caseFile?.supportingEvidence?.[0]?.summary) { + items.push(`Lead evidence: ${pred.caseFile.supportingEvidence[0].summary}`); + } + if (pred.calibration?.marketTitle) { + items.push(`Initial market check: ${pred.calibration.marketTitle} at ${roundPct(pred.calibration.marketPrice)}.`); + } + return items.slice(0, 4); + } + + const previousSignals = new Set(prev.signals || []); + const newSignals = (pred.signals || []) + .map(signal => signal.value) + .filter(value => !previousSignals.has(value)); + for (const signal of newSignals.slice(0, 2)) { + items.push(`New signal: ${signal}`); + } + + const previousHeadlines = new Set(prev.newsContext || []); + const newHeadlines = (pred.newsContext || []).filter(headline => !previousHeadlines.has(headline)); + for (const headline of newHeadlines.slice(0, 2)) { + items.push(`New reporting: ${headline}`); + } + + if (pred.calibration) { + const prevMarket = prev.calibration; + if (!prevMarket || prevMarket.marketTitle !== pred.calibration.marketTitle) { + items.push(`New market anchor: ${pred.calibration.marketTitle} at ${roundPct(pred.calibration.marketPrice)}.`); + } else if (Math.abs((pred.calibration.marketPrice || 0) - (prevMarket.marketPrice || 0)) >= 0.05) { + items.push(`Market moved from ${roundPct(prevMarket.marketPrice)} to ${roundPct(pred.calibration.marketPrice)} in ${pred.calibration.marketTitle}.`); + } + } + + if (items.length === 0) { + if (Math.abs(pred.probability - (prev.probability || pred.priorProbability || pred.probability)) < 0.05) { + items.push('Evidence mix is broadly unchanged from the prior snapshot.'); + } else if (pred.caseFile?.counterEvidence?.[0]?.summary) { + items.push(`Counter-pressure: ${pred.caseFile.counterEvidence[0].summary}`); + } + } + + return items.slice(0, 4); +} + +function buildChangeSummary(pred, prev, changeItems) { + if (!prev) { + return `This forecast is new in the current run, entering at ${roundPct(pred.probability)} with a ${pred.trend} trajectory.`; + } + + const delta = pred.probability - prev.probability; + const movement = Math.abs(delta); + const lead = movement >= 0.05 + ? `Probability ${delta > 0 ? 'rose' : 'fell'} from ${roundPct(prev.probability)} to ${roundPct(pred.probability)} since the prior run.` + : `Probability is holding near ${roundPct(pred.probability)} versus ${roundPct(prev.probability)} in the prior run.`; + + const follow = changeItems[0] + ? changeItems[0] + : pred.trend === 'rising' + ? 'The evidence mix is leaning more supportive than in the last snapshot.' + : pred.trend === 'falling' + ? 'The latest snapshot is showing more restraint than the previous run.' + : 'The evidence mix remains broadly similar to the previous run.'; + + return `${lead} ${follow}`.slice(0, 500); +} + +function annotateForecastChanges(predictions, prior) { + const priorMap = new Map((prior?.predictions || []).map(item => [item.id, item])); + for (const pred of predictions) { + if (!pred.caseFile) buildForecastCase(pred); + const prev = priorMap.get(pred.id); + const changeItems = buildChangeItems(pred, prev); + pred.caseFile.changeItems = changeItems; + pred.caseFile.changeSummary = buildChangeSummary(pred, prev, changeItems); + } +} + +function clamp01(value) { + return Math.max(0, Math.min(1, value || 0)); +} + +function scoreForecastReadiness(pred) { + const supportCount = pred.caseFile?.supportingEvidence?.length || 0; + const counterCount = pred.caseFile?.counterEvidence?.length || 0; + const triggerCount = pred.caseFile?.triggers?.length || 0; + const actorCount = pred.caseFile?.actorLenses?.length || 0; + const headlineCount = pred.newsContext?.length || 0; + const sourceCount = new Set((pred.signals || []).map(s => SIGNAL_TO_SOURCE[s.type] || s.type)).size; + + const evidenceScore = clamp01((normalize(supportCount, 0, 6) * 0.55) + (normalize(sourceCount, 1, 4) * 0.45)); + const groundingScore = clamp01( + (headlineCount > 0 ? Math.min(1, headlineCount / 2) * 0.35 : 0) + + (pred.calibration ? 0.3 : 0) + + (triggerCount > 0 ? Math.min(1, triggerCount / 3) * 0.35 : 0) + ); + const alternativeScore = clamp01( + ((pred.caseFile?.baseCase || supportCount > 0) ? 1 : 0) * (1 / 3) + + ((pred.caseFile?.escalatoryCase || triggerCount > 0 || (pred.cascades?.length || 0) > 0) ? 1 : 0) * (1 / 3) + + ((pred.caseFile?.contrarianCase || counterCount > 0 || pred.trend === 'falling') ? 1 : 0) * (1 / 3) + ); + const actorScore = actorCount > 0 ? Math.min(1, actorCount / 3) : 0; + const driftPenalty = pred.calibration ? Math.min(0.18, Math.abs(pred.calibration.drift || 0) * 0.6) : 0; + const overall = clamp01( + evidenceScore * 0.4 + + groundingScore * 0.25 + + alternativeScore * 0.2 + + actorScore * 0.15 - + driftPenalty + ); + + return { + evidenceScore: +evidenceScore.toFixed(3), + groundingScore: +groundingScore.toFixed(3), + alternativeScore: +alternativeScore.toFixed(3), + actorScore: +actorScore.toFixed(3), + overall: +overall.toFixed(3), + }; +} + +function computeAnalysisPriority(pred) { + const readiness = scoreForecastReadiness(pred); + const baseScore = (pred.probability || 0) * (pred.confidence || 0); + const readinessMultiplier = 0.85 + (readiness.overall * 0.35); + const trendBonus = pred.trend === 'rising' ? 0.015 : pred.trend === 'falling' ? -0.005 : 0; + return +((baseScore * readinessMultiplier) + trendBonus).toFixed(6); +} + +function rankForecastsForAnalysis(predictions) { + predictions.sort((a, b) => { + const delta = computeAnalysisPriority(b) - computeAnalysisPriority(a); + if (Math.abs(delta) > 1e-6) return delta; + return (b.probability * b.confidence) - (a.probability * a.confidence); + }); +} + // ── Phase 2: LLM Scenario Enrichment ─────────────────────── const FORECAST_LLM_PROVIDERS = [ { name: 'groq', envKey: 'GROQ_API_KEY', apiUrl: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.1-8b-instant', timeout: 20_000 }, @@ -998,36 +1869,37 @@ const FORECAST_LLM_PROVIDERS = [ const SCENARIO_SYSTEM_PROMPT = `You are a senior geopolitical intelligence analyst writing scenario briefs. RULES: -- Each scenario MUST be exactly 2-3 sentences, 40-80 words. -- Each scenario MUST name at least one specific signal value from the data (e.g., "CII score of 87", "3 UCDP events", "theater posture elevated"). -- Each scenario MUST state a causal mechanism (what leads to what). -- Do NOT use hedging words ("could", "might", "potentially") without citing a data point. -- Do NOT use your own knowledge. Base everything on the provided signals and headlines. +- Write four fields for each prediction: + - scenario: 1-2 sentence executive summary of the base case + - baseCase: 2 sentences on the most likely path + - escalatoryCase: 1-2 sentences on what would push risk materially higher + - contrarianCase: 1-2 sentences on what would stall or reverse the path +- Every field MUST cite at least one concrete signal, headline, market cue, or trigger from the provided case file. +- Do NOT use your own knowledge. Base everything on the provided evidence only. +- Keep each field under 90 words. -GOOD EXAMPLE: -{"index": 0, "scenario": "Iran's CII score of 87 (critical, rising) combined with 3 active UCDP conflict events indicates sustained military pressure. The elevated Middle East theater posture with 47 tracked flights suggests force projection capability is being maintained."} - -BAD EXAMPLE (too generic, no signal values): -{"index": 0, "scenario": "Tensions in the Middle East continue to escalate as various factors contribute to regional instability."} - -Respond with ONLY a JSON array: [{"index": 0, "scenario": "..."}, ...]`; +Respond with ONLY a JSON array: [{"index": 0, "scenario": "...", "baseCase": "...", "escalatoryCase": "...", "contrarianCase": "..."}, ...]`; // Phase 3: Combined scenario + perspectives prompt for top-2 predictions const COMBINED_SYSTEM_PROMPT = `You are a senior geopolitical intelligence analyst. For each prediction: -1. Write a SCENARIO (2-3 sentences, evidence-grounded, citing signal values) -2. Write 3 PERSPECTIVES (1-2 sentences each): +1. Write a SCENARIO (1-2 sentences, evidence-grounded, citing signal values) +2. Write 3 CASES (1-2 sentences each): + - BASE_CASE: the most likely path + - ESCALATORY_CASE: what would push risk higher + - CONTRARIAN_CASE: what would stall or reverse the path +3. Write 3 PERSPECTIVES (1-2 sentences each): - STRATEGIC: Neutral analysis of what signals indicate - REGIONAL: What this means for actors in the affected region - - CONTRARIAN: What factors could prevent or reverse this outcome + - CONTRARIAN: What factors could prevent or reverse this outcome, grounded in the counter-evidence RULES: -- Every sentence MUST cite a specific signal value from the data +- Every field MUST cite a specific signal value, headline, market cue, or trigger from the case file - Base everything on provided data, not your knowledge - Do NOT use hedging without a data point Output JSON array: -[{"index": 0, "scenario": "...", "strategic": "...", "regional": "...", "contrarian": "..."}, ...]`; +[{"index": 0, "scenario": "...", "baseCase": "...", "escalatoryCase": "...", "contrarianCase": "...", "strategic": "...", "regional": "...", "contrarian": "..."}, ...]`; function validatePerspectives(items, predictions) { if (!Array.isArray(items)) return []; @@ -1042,6 +1914,19 @@ function validatePerspectives(items, predictions) { }); } +function validateCaseNarratives(items, predictions) { + if (!Array.isArray(items)) return []; + return items.filter(item => { + if (typeof item.index !== 'number' || item.index < 0 || item.index >= predictions.length) return false; + for (const key of ['baseCase', 'escalatoryCase', 'contrarianCase']) { + if (typeof item[key] !== 'string') return false; + item[key] = item[key].replace(/<[^>]*>/g, '').trim().slice(0, 500); + if (item[key].length < 20) return false; + } + return true; + }); +} + function sanitizeForPrompt(text) { return (text || '').replace(/[\n\r]/g, ' ').replace(/[<>{}\x00-\x1f]/g, '').slice(0, 200).trim(); } @@ -1066,6 +1951,13 @@ function parseLLMScenarios(text) { return null; } +function hasEvidenceReference(text, candidate) { + const normalized = sanitizeForPrompt(candidate).toLowerCase(); + if (!normalized) return false; + if (text.includes(normalized)) return true; + return tokenizeText(normalized).some(token => token.length > 3 && text.includes(token)); +} + function validateScenarios(scenarios, predictions) { if (!Array.isArray(scenarios)) return []; return scenarios.filter(s => { @@ -1073,12 +1965,18 @@ function validateScenarios(scenarios, predictions) { if (typeof s.index !== 'number' || s.index < 0 || s.index >= predictions.length) return false; const pred = predictions[s.index]; const scenarioLower = s.scenario.toLowerCase(); - const hasSignalRef = pred.signals.some(sig => - scenarioLower.includes(sig.type.toLowerCase()) || - sig.value.split(/\s+/).some(word => word.length > 3 && scenarioLower.includes(word.toLowerCase())) - ); - if (!hasSignalRef) { - console.warn(` [LLM] Scenario ${s.index} rejected: no signal reference`); + const evidenceCandidates = [ + ...pred.signals.flatMap(sig => [sig.type, sig.value]), + ...(pred.newsContext || []), + pred.calibration?.marketTitle || '', + pred.calibration ? roundPct(pred.calibration.marketPrice) : '', + ...(pred.caseFile?.supportingEvidence || []).map(item => item.summary || ''), + ...(pred.caseFile?.counterEvidence || []).map(item => item.summary || ''), + ...(pred.caseFile?.triggers || []), + ]; + const hasEvidenceRef = evidenceCandidates.some(candidate => hasEvidenceReference(scenarioLower, candidate)); + if (!hasEvidenceRef) { + console.warn(` [LLM] Scenario ${s.index} rejected: no evidence reference`); return false; } s.scenario = s.scenario.replace(/<[^>]*>/g, '').slice(0, 500); @@ -1087,12 +1985,6 @@ function validateScenarios(scenarios, predictions) { } async function callForecastLLM(systemPrompt, userPrompt) { - const available = FORECAST_LLM_PROVIDERS.filter(p => !!process.env[p.envKey]); - if (available.length === 0) { - console.warn(` [LLM] No providers configured. Set one of: ${FORECAST_LLM_PROVIDERS.map(p => p.envKey).join(', ')}`); - return null; - } - console.log(` [LLM] Trying providers: ${available.map(p => p.name).join(', ')}`); for (const provider of FORECAST_LLM_PROVIDERS) { const apiKey = process.env[provider.envKey]; if (!apiKey) continue; @@ -1116,22 +2008,13 @@ async function callForecastLLM(systemPrompt, userPrompt) { }), signal: AbortSignal.timeout(provider.timeout), }); - if (!resp.ok) { - const errBody = await resp.text().catch(() => ''); - console.warn(` [LLM] ${provider.name}: HTTP ${resp.status} ${errBody.slice(0, 100)}`); - continue; - } + if (!resp.ok) { console.warn(` [LLM] ${provider.name}: HTTP ${resp.status}`); continue; } const json = await resp.json(); const text = json.choices?.[0]?.message?.content?.trim(); - if (!text || text.length < 20) { - console.warn(` [LLM] ${provider.name}: empty/short response (${text?.length || 0} chars)`); - continue; - } - console.log(` [LLM] ${provider.name} OK (${text.length} chars)`); + if (!text || text.length < 20) continue; return { text, model: json.model || provider.model, provider: provider.name }; } catch (err) { console.warn(` [LLM] ${provider.name}: ${err.message}`); continue; } } - console.warn(' [LLM] All providers failed'); return null; } @@ -1153,25 +2036,143 @@ function buildCacheHash(preds) { s: p.signals.map(s => s.value).join(','), c: p.calibration?.drift, n: (p.newsContext || []).join(','), + t: p.trend, + j: p.projections ? `${p.projections.h24}|${p.projections.d7}|${p.projections.d30}` : '', + g: (p.cascades || []).map(cascade => `${cascade.domain}:${cascade.effect}:${cascade.probability}`).join(','), })))) .digest('hex').slice(0, 16); } function buildUserPrompt(preds) { - const seen = new Set(); - const mergedHeadlines = []; - for (const p of preds) { - for (const h of p.newsContext || []) { - if (!seen.has(h)) { seen.add(h); mergedHeadlines.push(h); } - } - } - const headlines = mergedHeadlines.slice(0, 5).map(h => `- ${sanitizeForPrompt(h)}`).join('\n'); const predsText = preds.map((p, i) => { const sigs = p.signals.map(s => `[SIGNAL] ${sanitizeForPrompt(s.value)}`).join('\n'); const cal = p.calibration ? `\n[CALIBRATION] ${sanitizeForPrompt(p.calibration.marketTitle)} at ${Math.round(p.calibration.marketPrice * 100)}%` : ''; - return `[${i}] "${sanitizeForPrompt(p.title)}" (${p.domain}, ${p.region})\nProbability: ${Math.round(p.probability * 100)}% | Horizon: ${p.timeHorizon}\n${sigs}${cal}`; + const projections = p.projections + ? `\n[PROJECTIONS] 24h ${Math.round(p.projections.h24 * 100)}% | 7d ${Math.round(p.projections.d7 * 100)}% | 30d ${Math.round(p.projections.d30 * 100)}%` + : ''; + const cascades = (p.cascades || []).length > 0 + ? `\n[CASCADES] ${p.cascades.map(c => `${sanitizeForPrompt(c.domain)} via ${sanitizeForPrompt(c.effect)} (${Math.round(c.probability * 100)}%)`).join('; ')}` + : ''; + const headlines = (p.newsContext || []).slice(0, 3).map(h => `- ${sanitizeForPrompt(h)}`).join('\n'); + const news = headlines ? `\n[HEADLINES]\n${headlines}` : '\n[HEADLINES]\n- No directly matched headlines'; + const caseFile = p.caseFile || {}; + const support = (caseFile.supportingEvidence || []) + .slice(0, 4) + .map(item => `- ${sanitizeForPrompt(item.summary)} (${Math.round((item.weight || 0) * 100)}%)`) + .join('\n'); + const counter = (caseFile.counterEvidence || []) + .slice(0, 3) + .map(item => `- ${sanitizeForPrompt(item.summary)}`) + .join('\n'); + const triggers = (caseFile.triggers || []).slice(0, 3).map(item => `- ${sanitizeForPrompt(item)}`).join('\n'); + const actors = (caseFile.actors || []) + .slice(0, 3) + .map(actor => `- ${sanitizeForPrompt(actor.name)} [${sanitizeForPrompt(actor.category)}]: ${sanitizeForPrompt(actor.role)} | objective: ${sanitizeForPrompt(actor.objectives?.[0] || '')} | likely action: ${sanitizeForPrompt(actor.likelyActions?.[0] || '')}`) + .join('\n'); + const worldSummary = caseFile.worldState?.summary ? sanitizeForPrompt(caseFile.worldState.summary) : ''; + const worldPressures = (caseFile.worldState?.activePressures || []).slice(0, 3).map(item => `- ${sanitizeForPrompt(item)}`).join('\n'); + const worldStabilizers = (caseFile.worldState?.stabilizers || []).slice(0, 2).map(item => `- ${sanitizeForPrompt(item)}`).join('\n'); + const worldUnknowns = (caseFile.worldState?.keyUnknowns || []).slice(0, 3).map(item => `- ${sanitizeForPrompt(item)}`).join('\n'); + const branches = (caseFile.branches || []) + .slice(0, 3) + .map(branch => `- ${sanitizeForPrompt(branch.kind)}: ${sanitizeForPrompt(branch.summary)} | outcome: ${sanitizeForPrompt(branch.outcome)} | projected: ${Math.round((branch.projectedProbability || 0) * 100)}%`) + .join('\n'); + const caseSections = `${support ? `\n[SUPPORTING_EVIDENCE]\n${support}` : ''}${counter ? `\n[COUNTER_EVIDENCE]\n${counter}` : ''}${triggers ? `\n[TRIGGERS]\n${triggers}` : ''}${actors ? `\n[ACTORS]\n${actors}` : ''}${worldSummary ? `\n[WORLD_STATE]\n- ${worldSummary}` : ''}${worldPressures ? `\n[ACTIVE_PRESSURES]\n${worldPressures}` : ''}${worldStabilizers ? `\n[STABILIZERS]\n${worldStabilizers}` : ''}${worldUnknowns ? `\n[KEY_UNKNOWNS]\n${worldUnknowns}` : ''}${branches ? `\n[SIMULATED_BRANCHES]\n${branches}` : ''}`; + return `[${i}] "${sanitizeForPrompt(p.title)}" (${p.domain}, ${p.region})\nProbability: ${Math.round(p.probability * 100)}% | Confidence: ${Math.round(p.confidence * 100)}% | Trend: ${p.trend} | Horizon: ${p.timeHorizon}\n${sigs}${cal}${projections}${cascades}${news}${caseSections}`; }).join('\n\n'); - return headlines ? `Current top headlines:\n${headlines}\n\nPredictions to analyze:\n\n${predsText}` : `Predictions to analyze:\n\n${predsText}`; + return `Predictions to analyze:\n\n${predsText}`; +} + +function buildFallbackBaseCase(pred) { + const branch = pred.caseFile?.branches?.find(item => item.kind === 'base'); + if (branch?.summary && branch?.outcome) { + return `${branch.summary} ${branch.outcome}`.slice(0, 500); + } + const support = pred.caseFile?.supportingEvidence?.[0]?.summary || pred.signals?.[0]?.value || pred.title; + const secondary = pred.caseFile?.supportingEvidence?.[1]?.summary || pred.signals?.[1]?.value; + const lead = `${support} is the clearest active driver behind this ${pred.domain} forecast in ${pred.region}.`; + const follow = secondary + ? `${secondary} keeps the base case anchored near ${roundPct(pred.probability)} over the ${pred.timeHorizon}.` + : `The most likely path remains near ${roundPct(pred.probability)} over the ${pred.timeHorizon}, with ${pred.trend} momentum.`; + return `${lead} ${follow}`.slice(0, 500); +} + +function buildFallbackEscalatoryCase(pred) { + const branch = pred.caseFile?.branches?.find(item => item.kind === 'escalatory'); + if (branch?.summary && branch?.outcome) { + return `${branch.summary} ${branch.outcome}`.slice(0, 500); + } + const trigger = pred.caseFile?.triggers?.[0]; + const cascade = pred.cascades?.[0]; + const firstSignal = pred.signals?.[0]?.value || pred.title; + const escalation = trigger + ? `${trigger} That would likely push the forecast above its current ${roundPct(pred.probability)} baseline.` + : `${firstSignal} intensifying further would move this forecast above its current ${roundPct(pred.probability)} baseline.`; + const spillover = cascade + ? `The first spillover risk would likely appear in ${cascade.domain} via ${cascade.effect}.` + : `The next move higher would depend on the current ${pred.trend} trajectory hardening into a clearer signal cluster.`; + return `${escalation} ${spillover}`.slice(0, 500); +} + +function buildFallbackContrarianCase(pred) { + const branch = pred.caseFile?.branches?.find(item => item.kind === 'contrarian'); + if (branch?.summary && branch?.outcome) { + return `${branch.summary} ${branch.outcome}`.slice(0, 500); + } + const counter = pred.caseFile?.counterEvidence?.[0]?.summary; + const calibration = pred.calibration + ? `A move in "${pred.calibration.marketTitle}" away from the current ${roundPct(pred.calibration.marketPrice)} market signal would challenge the existing baseline.` + : 'A failure to add corroborating evidence across sources would challenge the current baseline.'; + return `${counter || calibration} ${pred.trend === 'falling' ? 'The already falling trend is the main stabilizing clue.' : 'The base case still needs further confirmation to stay durable.'}`.slice(0, 500); +} + +function buildFallbackScenario(pred) { + const baseCase = pred.caseFile?.baseCase || buildFallbackBaseCase(pred); + return baseCase.slice(0, 500); +} + +function buildFeedSummary(pred) { + const lead = pred.caseFile?.baseCase || pred.scenario || buildFallbackScenario(pred); + const compact = lead.replace(/\s+/g, ' ').trim(); + const summary = compact.length > 180 ? `${compact.slice(0, 177).trimEnd()}...` : compact; + if (summary) return summary; + return `${pred.title} remains live at ${roundPct(pred.probability)} over the ${pred.timeHorizon}.`; +} + +function buildFallbackPerspectives(pred) { + const firstSignal = pred.caseFile?.supportingEvidence?.[0]?.summary || pred.signals?.[0]?.value || pred.title; + const contrarian = pred.caseFile?.contrarianCase || buildFallbackContrarianCase(pred); + return { + strategic: `${firstSignal} is setting the strategic baseline, and the current ${Math.round(pred.probability * 100)}% probability implies a live but not settled risk path.`, + regional: `For actors in ${pred.region}, the practical implication is continued sensitivity to short-term triggers over the ${pred.timeHorizon}, especially if the current ${pred.trend} trend persists.`, + contrarian, + }; +} + +function populateFallbackNarratives(predictions) { + for (const pred of predictions) { + if (!pred.caseFile) buildForecastCase(pred); + if (!pred.caseFile.baseCase) pred.caseFile.baseCase = buildFallbackBaseCase(pred); + if (!pred.caseFile.escalatoryCase) pred.caseFile.escalatoryCase = buildFallbackEscalatoryCase(pred); + if (!pred.caseFile.contrarianCase) pred.caseFile.contrarianCase = buildFallbackContrarianCase(pred); + if (!pred.caseFile.changeItems?.length || !pred.caseFile.changeSummary) { + const fallbackItems = buildChangeItems(pred, null); + pred.caseFile.changeItems = fallbackItems; + pred.caseFile.changeSummary = buildChangeSummary(pred, null, fallbackItems); + } + if (!pred.scenario) pred.scenario = buildFallbackScenario(pred); + if (!pred.feedSummary) pred.feedSummary = buildFeedSummary(pred); + if (!pred.perspectives) pred.perspectives = buildFallbackPerspectives(pred); + if (!pred.traceMeta) { + applyTraceMeta(pred, { + narrativeSource: 'fallback', + llmCached: false, + llmProvider: '', + llmModel: '', + branchSource: 'deterministic', + }); + } + } } async function enrichScenariosWithLLM(predictions) { @@ -1191,8 +2192,23 @@ async function enrichScenariosWithLLM(predictions) { if (cached?.items) { for (const item of cached.items) { if (item.index >= 0 && item.index < topWithPerspectives.length) { + applyTraceMeta(topWithPerspectives[item.index], { + narrativeSource: 'llm_combined_cache', + llmCached: true, + llmProvider: 'cache', + llmModel: 'cache', + branchSource: 'deterministic', + }); if (item.scenario) topWithPerspectives[item.index].scenario = item.scenario; if (item.strategic) topWithPerspectives[item.index].perspectives = { strategic: item.strategic, regional: item.regional, contrarian: item.contrarian }; + if (item.baseCase || item.escalatoryCase || item.contrarianCase) { + topWithPerspectives[item.index].caseFile = { + ...(topWithPerspectives[item.index].caseFile || buildForecastCase(topWithPerspectives[item.index])), + baseCase: item.baseCase || topWithPerspectives[item.index].caseFile?.baseCase || '', + escalatoryCase: item.escalatoryCase || topWithPerspectives[item.index].caseFile?.escalatoryCase || '', + contrarianCase: item.contrarianCase || topWithPerspectives[item.index].caseFile?.contrarianCase || '', + }; + } } } console.log(JSON.stringify({ event: 'llm_combined', cached: true, count: cached.items.length, hash })); @@ -1203,13 +2219,29 @@ async function enrichScenariosWithLLM(predictions) { const raw = parseLLMScenarios(result.text); const validScenarios = validateScenarios(raw, topWithPerspectives); const validPerspectives = validatePerspectives(raw, topWithPerspectives); + const validCases = validateCaseNarratives(raw, topWithPerspectives); for (const s of validScenarios) { + applyTraceMeta(topWithPerspectives[s.index], { + narrativeSource: 'llm_combined', + llmCached: false, + llmProvider: result.provider, + llmModel: result.model, + branchSource: 'deterministic', + }); topWithPerspectives[s.index].scenario = s.scenario; } for (const p of validPerspectives) { topWithPerspectives[p.index].perspectives = { strategic: p.strategic, regional: p.regional, contrarian: p.contrarian }; } + for (const c of validCases) { + topWithPerspectives[c.index].caseFile = { + ...(topWithPerspectives[c.index].caseFile || buildForecastCase(topWithPerspectives[c.index])), + baseCase: c.baseCase, + escalatoryCase: c.escalatoryCase, + contrarianCase: c.contrarianCase, + }; + } // Cache only validated items (not raw) to prevent persisting invalid LLM output const items = []; @@ -1217,13 +2249,19 @@ async function enrichScenariosWithLLM(predictions) { const entry = { index: s.index, scenario: s.scenario }; const p = validPerspectives.find(vp => vp.index === s.index); if (p) { entry.strategic = p.strategic; entry.regional = p.regional; entry.contrarian = p.contrarian; } + const c = validCases.find(vc => vc.index === s.index); + if (c) { + entry.baseCase = c.baseCase; + entry.escalatoryCase = c.escalatoryCase; + entry.contrarianCase = c.contrarianCase; + } items.push(entry); } console.log(JSON.stringify({ event: 'llm_combined', provider: result.provider, model: result.model, hash, count: topWithPerspectives.length, - scenarios: validScenarios.length, perspectives: validPerspectives.length, + scenarios: validScenarios.length, perspectives: validPerspectives.length, cases: validCases.length, latencyMs: Math.round(Date.now() - t0), cached: false, })); @@ -1243,8 +2281,23 @@ async function enrichScenariosWithLLM(predictions) { if (cached?.scenarios) { for (const s of cached.scenarios) { if (s.index >= 0 && s.index < scenarioOnly.length && s.scenario) { + applyTraceMeta(scenarioOnly[s.index], { + narrativeSource: 'llm_scenario_cache', + llmCached: true, + llmProvider: 'cache', + llmModel: 'cache', + branchSource: 'deterministic', + }); scenarioOnly[s.index].scenario = s.scenario; } + if (s.index >= 0 && s.index < scenarioOnly.length && (s.baseCase || s.escalatoryCase || s.contrarianCase)) { + scenarioOnly[s.index].caseFile = { + ...(scenarioOnly[s.index].caseFile || buildForecastCase(scenarioOnly[s.index])), + baseCase: s.baseCase || scenarioOnly[s.index].caseFile?.baseCase || '', + escalatoryCase: s.escalatoryCase || scenarioOnly[s.index].caseFile?.escalatoryCase || '', + contrarianCase: s.contrarianCase || scenarioOnly[s.index].caseFile?.contrarianCase || '', + }; + } } console.log(JSON.stringify({ event: 'llm_scenario', cached: true, count: cached.scenarios.length, hash })); } else { @@ -1253,15 +2306,58 @@ async function enrichScenariosWithLLM(predictions) { if (result) { const raw = parseLLMScenarios(result.text); const valid = validateScenarios(raw, scenarioOnly); - for (const s of valid) { scenarioOnly[s.index].scenario = s.scenario; } + const validCases = validateCaseNarratives(raw, scenarioOnly); + for (const s of valid) { + applyTraceMeta(scenarioOnly[s.index], { + narrativeSource: 'llm_scenario', + llmCached: false, + llmProvider: result.provider, + llmModel: result.model, + branchSource: 'deterministic', + }); + scenarioOnly[s.index].scenario = s.scenario; + } + for (const c of validCases) { + scenarioOnly[c.index].caseFile = { + ...(scenarioOnly[c.index].caseFile || buildForecastCase(scenarioOnly[c.index])), + baseCase: c.baseCase, + escalatoryCase: c.escalatoryCase, + contrarianCase: c.contrarianCase, + }; + } console.log(JSON.stringify({ event: 'llm_scenario', provider: result.provider, model: result.model, - hash, count: scenarioOnly.length, scenarios: valid.length, + hash, count: scenarioOnly.length, scenarios: valid.length, cases: validCases.length, latencyMs: Math.round(Date.now() - t0), cached: false, })); - if (valid.length > 0) await redisSet(url, token, cacheKey, { scenarios: valid }, 3600); + if (valid.length > 0 || validCases.length > 0) { + const scenarios = []; + const seen = new Set(); + for (const s of valid) { + const item = { index: s.index, scenario: s.scenario }; + const c = validCases.find(vc => vc.index === s.index); + if (c) { + item.baseCase = c.baseCase; + item.escalatoryCase = c.escalatoryCase; + item.contrarianCase = c.contrarianCase; + } + scenarios.push(item); + seen.add(s.index); + } + for (const c of validCases) { + if (seen.has(c.index)) continue; + scenarios.push({ + index: c.index, + scenario: '', + baseCase: c.baseCase, + escalatoryCase: c.escalatoryCase, + contrarianCase: c.contrarianCase, + }); + } + await redisSet(url, token, cacheKey, { scenarios }, 3600); + } } } } @@ -1289,12 +2385,7 @@ async function fetchForecasts() { ...detectFromPredictionMarkets(inputs), ]; - // Log per-domain breakdown and top predictions for diagnostics - const byDomain = {}; - for (const p of predictions) byDomain[p.domain] = (byDomain[p.domain] || 0) + 1; - console.log(JSON.stringify({ event: 'detectors', total: predictions.length, byDomain })); - const top5 = [...predictions].sort((a, b) => b.probability - a.probability).slice(0, 5); - for (const p of top5) console.log(` top: ${p.domain} | ${p.region} | prob=${p.probability} | ${p.title.slice(0, 60)}`); + console.log(` Generated ${predictions.length} predictions`); attachNewsContext(predictions, inputs.newsInsights, inputs.newsDigest); calibrateWithMarkets(predictions, inputs.predictionMarkets); @@ -1304,10 +2395,13 @@ async function fetchForecasts() { resolveCascades(predictions, cascadeRules); discoverGraphCascades(predictions, loadEntityGraph()); computeTrends(predictions, prior); + buildForecastCases(predictions); + annotateForecastChanges(predictions, prior); - predictions.sort((a, b) => (b.probability * b.confidence) - (a.probability * a.confidence)); + rankForecastsForAnalysis(predictions); await enrichScenariosWithLLM(predictions); + populateFallbackNarratives(predictions); return { predictions, generatedAt: Date.now() }; } @@ -1317,11 +2411,30 @@ if (_isDirectRun) { ttlSeconds: TTL_SECONDS, lockTtlMs: 180_000, validateFn: (data) => Array.isArray(data?.predictions) && data.predictions.length > 0, + afterPublish: async (data, meta) => { + try { + const snapshot = await appendHistorySnapshot(data); + console.log(` History appended: ${snapshot.predictions.length} forecasts -> ${HISTORY_KEY}`); + } catch (err) { + console.warn(` [History] Append failed: ${err.message}`); + } + + try { + const pointer = await writeForecastTraceArtifacts(data, { runId: meta?.runId || `${Date.now()}` }); + if (pointer) { + console.log(` Trace artifacts written: ${pointer.summaryKey}`); + } else { + console.log(' Trace artifacts skipped: Cloudflare R2 trace storage not configured'); + } + } catch (err) { + console.warn(` [Trace] Export failed: ${err.message}`); + } + }, extraKeys: [ { key: PRIOR_KEY, transform: (data) => ({ - predictions: data.predictions.map(p => ({ id: p.id, probability: p.probability })), + predictions: data.predictions.map(buildPriorForecastSnapshot), }), ttl: 7200, }, @@ -1330,6 +2443,13 @@ if (_isDirectRun) { } export { + CANONICAL_KEY, + PRIOR_KEY, + HISTORY_KEY, + HISTORY_MAX_RUNS, + HISTORY_MAX_FORECASTS, + TRACE_LATEST_KEY, + TRACE_RUNS_KEY, forecastId, normalize, makePrediction, @@ -1350,7 +2470,41 @@ export { parseLLMScenarios, validateScenarios, validatePerspectives, + validateCaseNarratives, computeProjections, + computeHeadlineRelevance, + computeMarketMatchScore, + buildUserPrompt, + buildForecastCase, + buildForecastCases, + buildPriorForecastSnapshot, + buildHistoryForecastEntry, + buildHistorySnapshot, + appendHistorySnapshot, + getTraceMaxForecasts, + buildTraceRunPrefix, + buildForecastTraceRecord, + buildForecastTraceArtifacts, + writeForecastTraceArtifacts, + buildChangeItems, + buildChangeSummary, + annotateForecastChanges, + buildCounterEvidence, + buildCaseTriggers, + buildForecastActors, + buildForecastWorldState, + buildForecastBranches, + buildActorLenses, + scoreForecastReadiness, + computeAnalysisPriority, + rankForecastsForAnalysis, + buildFallbackScenario, + buildFallbackBaseCase, + buildFallbackEscalatoryCase, + buildFallbackContrarianCase, + buildFeedSummary, + buildFallbackPerspectives, + populateFallbackNarratives, loadCascadeRules, evaluateRuleConditions, SIGNAL_TO_SOURCE, diff --git a/scripts/seed-military-bases.mjs b/scripts/seed-military-bases.mjs index 295125f25..eed32fdcf 100755 --- a/scripts/seed-military-bases.mjs +++ b/scripts/seed-military-bases.mjs @@ -8,7 +8,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const BATCH_SIZE = 500; const R2_BUCKET_URL = 'https://api.cloudflare.com/client/v4/accounts/{acct}/r2/buckets/worldmonitor-data/objects/seed-data/military-bases-final.json'; -const CF_ACCOUNT_ID = 'c1dd10ed1008132d1e8d479b79a98b32'; const MAX_RETRIES = 3; const RETRY_BASE_MS = 1000; const PROGRESS_INTERVAL = 5000; @@ -247,10 +246,11 @@ async function main() { if (!dataPath) { const cfToken = process.env.CLOUDFLARE_R2_TOKEN || process.env.CLOUDFLARE_API_TOKEN || ''; - if (cfToken) { + const cfAccountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID || ''; + if (cfToken && cfAccountId) { console.log(' Local file not found — downloading from R2...'); try { - const r2Url = R2_BUCKET_URL.replace('{acct}', CF_ACCOUNT_ID); + const r2Url = R2_BUCKET_URL.replace('{acct}', cfAccountId); const resp = await fetch(r2Url, { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(60_000), @@ -267,6 +267,8 @@ async function main() { } catch (err) { console.log(` R2 download failed: ${err.message}`); } + } else if (cfToken) { + console.log(' R2 download skipped: missing CLOUDFLARE_R2_ACCOUNT_ID'); } } diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 354d6a8f7..0aabd4968 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -45,6 +45,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { gdeltIntel: 'intelligence:gdelt-intel:v1', correlationCards: 'correlation:cards-bootstrap:v1', securityAdvisories: 'intelligence:advisories-bootstrap:v1', + forecasts: 'forecast:predictions:v2', }; export const BOOTSTRAP_TIERS: Record = { @@ -63,4 +64,5 @@ export const BOOTSTRAP_TIERS: Record = { iranEvents: 'fast', temporalAnomalies: 'fast', weatherAlerts: 'fast', spending: 'fast', gdeltIntel: 'fast', correlationCards: 'fast', securityAdvisories: 'slow', + forecasts: 'fast', }; diff --git a/server/worldmonitor/forecast/v1/get-forecasts.ts b/server/worldmonitor/forecast/v1/get-forecasts.ts index b944da07c..b501b5d41 100644 --- a/server/worldmonitor/forecast/v1/get-forecasts.ts +++ b/server/worldmonitor/forecast/v1/get-forecasts.ts @@ -7,7 +7,7 @@ import type { } from '../../../../src/generated/server/worldmonitor/forecast/v1/service_server'; import { getCachedJson } from '../../../_shared/redis'; -const REDIS_KEY = 'forecast:predictions:v1'; +const REDIS_KEY = 'forecast:predictions:v2'; export const getForecasts: ForecastServiceHandler['getForecasts'] = async ( _ctx: ServerContext, diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 6f3298dbe..1d444cc2d 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -1365,6 +1365,11 @@ export class DataLoaderManager implements AppModule { async loadForecasts(): Promise { try { + const hydrated = getHydratedData('forecasts') as { predictions?: import('@/generated/client/worldmonitor/forecast/v1/service_client').Forecast[] } | undefined; + if (hydrated?.predictions?.length) { + this.callPanel('forecast', 'updateForecasts', hydrated.predictions); + return; + } const { fetchForecasts } = await import('@/services/forecast'); const forecasts = await fetchForecasts(); this.callPanel('forecast', 'updateForecasts', forecasts); diff --git a/src/components/ForecastPanel.ts b/src/components/ForecastPanel.ts index 4237dbedf..bc5b7eaab 100644 --- a/src/components/ForecastPanel.ts +++ b/src/components/ForecastPanel.ts @@ -41,8 +41,10 @@ function injectStyles(): void { .fc-signal { color: var(--text-secondary, #999); font-size: 11px; padding: 1px 0; } .fc-signal::before { content: ''; display: inline-block; width: 6px; height: 1px; background: var(--text-secondary, #666); margin-right: 6px; vertical-align: middle; } .fc-cascade { font-size: 11px; color: var(--accent-color, #3b82f6); margin-top: 3px; } + .fc-summary { font-size: 11px; color: var(--text-primary, #d7d7d7); margin: 6px 0 4px; line-height: 1.45; } .fc-scenario { font-size: 11px; color: var(--text-primary, #ccc); margin: 4px 0; font-style: italic; } .fc-hidden { display: none; } + .fc-toggle-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } .fc-toggle { cursor: pointer; color: var(--text-secondary, #888); font-size: 11px; } .fc-toggle:hover { color: var(--text-primary, #eee); } .fc-calibration { font-size: 10px; color: var(--text-secondary, #777); margin-top: 2px; } @@ -50,7 +52,17 @@ function injectStyles(): void { .fc-bar-fill { height: 100%; border-radius: 1.5px; } .fc-empty { padding: 20px; text-align: center; color: var(--text-secondary, #888); } .fc-projections { font-size: 10px; color: var(--text-secondary, #777); margin-top: 3px; font-variant-numeric: tabular-nums; } - .fc-perspectives { margin-top: 4px; } + .fc-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-color, #2a2a2a); } + .fc-detail-grid { display: grid; gap: 8px; } + .fc-section { display: grid; gap: 4px; } + .fc-section-title { color: var(--text-secondary, #888); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; } + .fc-section-copy { font-size: 11px; color: var(--text-primary, #d3d3d3); line-height: 1.45; } + .fc-list-block { display: grid; gap: 4px; } + .fc-list-item { font-size: 11px; color: var(--text-secondary, #a0a0a0); line-height: 1.4; } + .fc-list-item::before { content: ''; display: inline-block; width: 6px; height: 1px; background: var(--text-secondary, #666); margin-right: 6px; vertical-align: middle; } + .fc-chip-row { display: flex; flex-wrap: wrap; gap: 6px; } + .fc-chip { border: 1px solid var(--border-color, #363636); border-radius: 999px; padding: 2px 8px; font-size: 10px; color: var(--text-secondary, #9a9a9a); background: rgba(255,255,255,0.02); } + .fc-perspectives { margin-top: 2px; } .fc-perspective { font-size: 11px; color: var(--text-secondary, #999); padding: 2px 0; line-height: 1.4; } .fc-perspective strong { color: var(--text-primary, #ccc); font-weight: 600; } `; @@ -76,7 +88,9 @@ export class ForecastPanel extends Panel { const toggle = target.closest('[data-fc-toggle]') as HTMLElement; if (toggle) { - const details = toggle.nextElementSibling as HTMLElement; + const card = toggle.closest('.fc-card'); + const panelId = toggle.dataset.fcToggle; + const details = panelId ? card?.querySelector(`[data-fc-panel="${panelId}"]`) as HTMLElement | null : null; if (details) details.classList.toggle('fc-hidden'); return; } @@ -133,8 +147,8 @@ export class ForecastPanel extends Panel { ? `
Cascades: ${f.cascades.map(c => escapeHtml(c.domain)).join(', ')}
` : ''; - const scenarioHtml = f.scenario - ? `
${escapeHtml(f.scenario)}
` + const summaryHtml = (f.feedSummary || f.scenario) + ? `
${escapeHtml(f.feedSummary || f.scenario)}
` : ''; const calibrationHtml = f.calibration?.marketTitle @@ -146,15 +160,7 @@ export class ForecastPanel extends Panel { ? `
24h: ${Math.round(proj.h24 * 100)}% | 7d: ${Math.round(proj.d7 * 100)}% | 30d: ${Math.round(proj.d30 * 100)}%
` : ''; - const persp = f.perspectives; - const perspectivesHtml = persp?.strategic - ? `Perspectives -
-
Strategic: ${escapeHtml(persp.strategic)}
-
Regional: ${escapeHtml(persp.regional || '')}
-
Contrarian: ${escapeHtml(persp.contrarian || '')}
-
` - : ''; + const detailHtml = this.renderDetail(f); return `
@@ -165,13 +171,230 @@ export class ForecastPanel extends Panel {
${projectionsHtml}
${escapeHtml(f.region)} | ${escapeHtml(f.timeHorizon || '7d')} | ${f.trend || 'stable'}
- ${scenarioHtml} - ${perspectivesHtml} - Signals (${(f.signals || []).length}) -
${signalsHtml}
+ ${summaryHtml} +
+ Analysis + Signals (${(f.signals || []).length}) +
+ ${detailHtml} +
${signalsHtml}
${cascadesHtml} ${calibrationHtml}
`; } + + private renderList(items: string[] | undefined): string { + if (!items || items.length === 0) return ''; + return `
${items.map(item => `
${escapeHtml(item)}
`).join('')}
`; + } + + private renderEvidence(items: Array<{ summary?: string; weight?: number }> | undefined): string { + if (!items || items.length === 0) return ''; + return `
${items.map(item => { + const suffix = typeof item.weight === 'number' ? ` (${Math.round(item.weight * 100)}%)` : ''; + return `
${escapeHtml(`${item.summary || ''}${suffix}`.trim())}
`; + }).join('')}
`; + } + + private renderActors(items: Array<{ + name?: string; + category?: string; + role?: string; + objectives?: string[]; + constraints?: string[]; + likelyActions?: string[]; + influenceScore?: number; + }> | undefined): string { + if (!items || items.length === 0) return ''; + return `
${items.map(actor => { + const chips = [ + actor.category ? actor.category : '', + typeof actor.influenceScore === 'number' ? `Influence ${Math.round(actor.influenceScore * 100)}%` : '', + ].filter(Boolean).map(chip => `${escapeHtml(chip)}`).join(''); + const objective = actor.objectives?.[0] ? `
Objective: ${escapeHtml(actor.objectives[0])}
` : ''; + const constraint = actor.constraints?.[0] ? `
Constraint: ${escapeHtml(actor.constraints[0])}
` : ''; + const action = actor.likelyActions?.[0] ? `
Likely action: ${escapeHtml(actor.likelyActions[0])}
` : ''; + return ` +
+ ${escapeHtml(actor.name || 'Actor')} + ${chips ? `
${chips}
` : ''} + ${actor.role ? `
${escapeHtml(actor.role)}
` : ''} + ${objective} + ${constraint} + ${action} +
+ `; + }).join('')}
`; + } + + private renderBranches(items: Array<{ + kind?: string; + title?: string; + summary?: string; + outcome?: string; + projectedProbability?: number; + rounds?: Array<{ round?: number; focus?: string; developments?: string[]; actorMoves?: string[] }>; + }> | undefined): string { + if (!items || items.length === 0) return ''; + return `
${items.map(branch => { + const projected = typeof branch.projectedProbability === 'number' + ? `Projected ${Math.round(branch.projectedProbability * 100)}%` + : ''; + const rounds = (branch.rounds || []).slice(0, 3).map(round => { + const developments = (round.developments || []).slice(0, 2).join(' '); + const actorMoves = (round.actorMoves || []).slice(0, 1).join(' '); + const copy = [developments, actorMoves].filter(Boolean).join(' '); + return `
R${round.round || 0}: ${escapeHtml(copy || round.focus || '')}
`; + }).join(''); + return ` +
+ ${escapeHtml(branch.title || branch.kind || 'Branch')} +
${projected}
+ ${branch.summary ? `
${escapeHtml(branch.summary)}
` : ''} + ${branch.outcome ? `
Outcome: ${escapeHtml(branch.outcome)}
` : ''} + ${rounds} +
+ `; + }).join('')}
`; + } + + private renderDetail(f: Forecast): string { + const caseFile = f.caseFile; + const sections: string[] = []; + + if (f.scenario) { + sections.push(` +
+
Executive View
+
${escapeHtml(f.scenario)}
+
+ `); + } + + if (caseFile?.baseCase) { + sections.push(` +
+
Base Case
+
${escapeHtml(caseFile.baseCase)}
+
+ `); + } + + if (caseFile?.changeSummary || caseFile?.changeItems?.length) { + sections.push(` +
+
What Changed
+ ${caseFile?.changeSummary ? `
${escapeHtml(caseFile.changeSummary)}
` : ''} + ${caseFile?.changeItems?.length ? this.renderList(caseFile.changeItems) : ''} +
+ `); + } + + if (caseFile?.worldState?.summary || caseFile?.worldState?.activePressures?.length || caseFile?.worldState?.stabilizers?.length || caseFile?.worldState?.keyUnknowns?.length) { + sections.push(` +
+
World State
+ ${caseFile?.worldState?.summary ? `
${escapeHtml(caseFile.worldState.summary)}
` : ''} + ${caseFile?.worldState?.activePressures?.length ? `
Pressures:
${this.renderList(caseFile.worldState.activePressures)}` : ''} + ${caseFile?.worldState?.stabilizers?.length ? `
Stabilizers:
${this.renderList(caseFile.worldState.stabilizers)}` : ''} + ${caseFile?.worldState?.keyUnknowns?.length ? `
Key unknowns:
${this.renderList(caseFile.worldState.keyUnknowns)}` : ''} +
+ `); + } + + if (caseFile?.escalatoryCase || caseFile?.contrarianCase) { + sections.push(` +
+
Alternative Paths
+ ${caseFile?.escalatoryCase ? `
Escalatory: ${escapeHtml(caseFile.escalatoryCase)}
` : ''} + ${caseFile?.contrarianCase ? `
Contrarian: ${escapeHtml(caseFile.contrarianCase)}
` : ''} +
+ `); + } + + if (caseFile?.branches?.length) { + sections.push(` +
+
Simulated Branches
+ ${this.renderBranches(caseFile.branches)} +
+ `); + } + + if (caseFile?.supportingEvidence?.length) { + sections.push(` +
+
Supporting Evidence
+ ${this.renderEvidence(caseFile.supportingEvidence)} +
+ `); + } + + if (caseFile?.counterEvidence?.length) { + sections.push(` +
+
Counter Evidence
+ ${this.renderEvidence(caseFile.counterEvidence)} +
+ `); + } + + if (caseFile?.triggers?.length) { + sections.push(` +
+
Signals To Watch
+ ${this.renderList(caseFile.triggers)} +
+ `); + } + + if (caseFile?.actors?.length) { + sections.push(` +
+
Actors
+ ${this.renderActors(caseFile.actors)} +
+ `); + } else if (caseFile?.actorLenses?.length) { + sections.push(` +
+
Actor Lenses
+ ${this.renderList(caseFile.actorLenses)} +
+ `); + } + + if (f.perspectives?.strategic) { + sections.push(` +
+
Perspectives
+
+
Strategic: ${escapeHtml(f.perspectives.strategic)}
+
Regional: ${escapeHtml(f.perspectives.regional || '')}
+
Contrarian: ${escapeHtml(f.perspectives.contrarian || '')}
+
+
+ `); + } + + const chips = [ + f.calibration?.marketTitle ? `Market: ${f.calibration.marketTitle}` : '', + typeof f.priorProbability === 'number' ? `Prior: ${Math.round(f.priorProbability * 100)}%` : '', + f.cascades?.length ? `Cascades: ${f.cascades.length}` : '', + ].filter(Boolean); + + const chipHtml = chips.length > 0 + ? `
Context
${chips.map(chip => `${escapeHtml(chip)}`).join('')}
` + : ''; + + return ` +
+
+ ${sections.join('')} + ${chipHtml} +
+
+ `; + } } diff --git a/src/generated/client/worldmonitor/forecast/v1/service_client.ts b/src/generated/client/worldmonitor/forecast/v1/service_client.ts index bc8327ca0..5019812b5 100644 --- a/src/generated/client/worldmonitor/forecast/v1/service_client.ts +++ b/src/generated/client/worldmonitor/forecast/v1/service_client.ts @@ -18,6 +18,7 @@ export interface Forecast { region: string; title: string; scenario: string; + feedSummary: string; probability: number; confidence: number; timeHorizon: string; @@ -30,6 +31,7 @@ export interface Forecast { updatedAt: number; perspectives?: Perspectives; projections?: Projections; + caseFile?: ForecastCase; } export interface ForecastSignal { @@ -63,6 +65,62 @@ export interface Projections { d30: number; } +export interface ForecastCase { + supportingEvidence: ForecastCaseEvidence[]; + counterEvidence: ForecastCaseEvidence[]; + triggers: string[]; + actorLenses: string[]; + baseCase: string; + escalatoryCase: string; + contrarianCase: string; + changeSummary: string; + changeItems: string[]; + actors: ForecastActor[]; + worldState?: ForecastWorldState; + branches: ForecastBranch[]; +} + +export interface ForecastCaseEvidence { + type: string; + summary: string; + weight: number; +} + +export interface ForecastActor { + id: string; + name: string; + category: string; + role: string; + objectives: string[]; + constraints: string[]; + likelyActions: string[]; + influenceScore: number; +} + +export interface ForecastWorldState { + summary: string; + activePressures: string[]; + stabilizers: string[]; + keyUnknowns: string[]; +} + +export interface ForecastBranch { + kind: string; + title: string; + summary: string; + outcome: string; + projectedProbability: number; + rounds: ForecastBranchRound[]; +} + +export interface ForecastBranchRound { + round: number; + focus: string; + developments: string[]; + actorMoves: string[]; + probabilityShift: number; +} + export interface FieldViolation { field: string; description: string; diff --git a/src/generated/server/worldmonitor/forecast/v1/service_server.ts b/src/generated/server/worldmonitor/forecast/v1/service_server.ts index 56ff946c9..6a73c3070 100644 --- a/src/generated/server/worldmonitor/forecast/v1/service_server.ts +++ b/src/generated/server/worldmonitor/forecast/v1/service_server.ts @@ -18,6 +18,7 @@ export interface Forecast { region: string; title: string; scenario: string; + feedSummary: string; probability: number; confidence: number; timeHorizon: string; @@ -30,6 +31,7 @@ export interface Forecast { updatedAt: number; perspectives?: Perspectives; projections?: Projections; + caseFile?: ForecastCase; } export interface ForecastSignal { @@ -63,6 +65,62 @@ export interface Projections { d30: number; } +export interface ForecastCase { + supportingEvidence: ForecastCaseEvidence[]; + counterEvidence: ForecastCaseEvidence[]; + triggers: string[]; + actorLenses: string[]; + baseCase: string; + escalatoryCase: string; + contrarianCase: string; + changeSummary: string; + changeItems: string[]; + actors: ForecastActor[]; + worldState?: ForecastWorldState; + branches: ForecastBranch[]; +} + +export interface ForecastCaseEvidence { + type: string; + summary: string; + weight: number; +} + +export interface ForecastActor { + id: string; + name: string; + category: string; + role: string; + objectives: string[]; + constraints: string[]; + likelyActions: string[]; + influenceScore: number; +} + +export interface ForecastWorldState { + summary: string; + activePressures: string[]; + stabilizers: string[]; + keyUnknowns: string[]; +} + +export interface ForecastBranch { + kind: string; + title: string; + summary: string; + outcome: string; + projectedProbability: number; + rounds: ForecastBranchRound[]; +} + +export interface ForecastBranchRound { + round: number; + focus: string; + developments: string[]; + actorMoves: string[]; + probabilityShift: number; +} + export interface FieldViolation { field: string; description: string; diff --git a/tests/forecast-detectors.test.mjs b/tests/forecast-detectors.test.mjs index bda9c8cd8..106d0a744 100644 --- a/tests/forecast-detectors.test.mjs +++ b/tests/forecast-detectors.test.mjs @@ -24,11 +24,37 @@ import { discoverGraphCascades, attachNewsContext, computeConfidence, + computeHeadlineRelevance, + computeMarketMatchScore, sanitizeForPrompt, parseLLMScenarios, validateScenarios, validatePerspectives, + validateCaseNarratives, computeProjections, + buildUserPrompt, + buildForecastCase, + buildForecastCases, + buildPriorForecastSnapshot, + buildChangeItems, + buildChangeSummary, + annotateForecastChanges, + buildCounterEvidence, + buildCaseTriggers, + buildForecastActors, + buildForecastWorldState, + buildForecastBranches, + buildActorLenses, + scoreForecastReadiness, + computeAnalysisPriority, + rankForecastsForAnalysis, + buildFallbackScenario, + buildFallbackBaseCase, + buildFallbackEscalatoryCase, + buildFallbackContrarianCase, + buildFeedSummary, + buildFallbackPerspectives, + populateFallbackNarratives, loadCascadeRules, evaluateRuleConditions, SIGNAL_TO_SOURCE, @@ -217,6 +243,19 @@ describe('calibrateWithMarkets', () => { calibrateWithMarkets([pred], { crypto: [] }); assert.equal(pred.calibration, null); }); + + it('does not calibrate from unrelated same-region macro market', () => { + const pred = makePrediction( + 'conflict', 'Middle East', 'Escalation risk: Iran', + 0.7, 0.6, '7d', [], + ); + const markets = { + geopolitical: [{ title: 'Will Netanyahu remain prime minister through 2026?', yesPrice: 20, source: 'polymarket', volume: 100000 }], + }; + calibrateWithMarkets([pred], markets); + assert.equal(pred.calibration, null); + assert.equal(pred.probability, 0.7); + }); }); describe('computeTrends', () => { @@ -463,7 +502,7 @@ describe('attachNewsContext', () => { assert.equal(corr, undefined); }); - it('falls back to generic headlines when no match', () => { + it('does not attach unrelated generic headlines when no match', () => { const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])]; const news = { topStories: [ { primaryTitle: 'Unrelated headline about sports' }, @@ -472,7 +511,7 @@ describe('attachNewsContext', () => { { primaryTitle: 'Fourth unrelated story' }, ]}; attachNewsContext(preds, news); - assert.equal(preds[0].newsContext.length, 3); // fallback top-3 + assert.deepEqual(preds[0].newsContext, []); }); it('excludes commodity node names from matching (no false positives)', () => { @@ -509,6 +548,309 @@ describe('attachNewsContext', () => { attachNewsContext(preds, { topStories: [] }, null); assert.equal(preds[0].newsContext, undefined); }); + + it('prefers region-relevant headlines over generic domain-only matches', () => { + const preds = [makePrediction('supply_chain', 'Red Sea', 'Shipping disruption: Red Sea', 0.6, 0.4, '7d', [])]; + const news = { topStories: [ + { primaryTitle: 'Global shipping stocks rise despite broader market weakness' }, + { primaryTitle: 'Red Sea shipping disruption worsens after new attacks' }, + { primaryTitle: 'Freight rates react to Red Sea rerouting' }, + ]}; + attachNewsContext(preds, news); + assert.ok(preds[0].newsContext[0].includes('Red Sea')); + assert.ok(preds[0].newsContext.every(h => /Red Sea|rerouting/i.test(h))); + }); +}); + +describe('headline and market relevance helpers', () => { + it('scores region-specific headlines above generic domain headlines', () => { + const terms = ['Red Sea', 'Yemen']; + const specific = computeHeadlineRelevance('Red Sea shipping disruption worsens after new attacks', terms, 'supply_chain'); + const generic = computeHeadlineRelevance('Global shipping shares rise in New York trading', terms, 'supply_chain'); + assert.ok(specific > generic); + }); + + it('scores semantically aligned markets above broad regional ones', () => { + const pred = makePrediction('conflict', 'Middle East', 'Escalation risk: Iran', 0.7, 0.5, '7d', []); + const targeted = computeMarketMatchScore(pred, 'Will Iran conflict escalate before July?', ['Iran', 'Middle East']); + const broad = computeMarketMatchScore(pred, 'Will Netanyahu remain prime minister through 2026?', ['Iran', 'Middle East']); + assert.ok(targeted > broad); + }); +}); + +describe('forecast case assembly', () => { + it('buildForecastCase assembles evidence, triggers, and actors from current forecast data', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.42, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + pred.newsContext = ['Iran military drills intensify after border incident']; + pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.12, source: 'polymarket' }; + pred.cascades = [{ domain: 'market', effect: 'commodity price shock', probability: 0.41 }]; + pred.trend = 'falling'; + pred.priorProbability = 0.78; + + const caseFile = buildForecastCase(pred); + assert.ok(caseFile.supportingEvidence.some(item => item.type === 'cii')); + assert.ok(caseFile.supportingEvidence.some(item => item.type === 'headline')); + assert.ok(caseFile.supportingEvidence.some(item => item.type === 'market_calibration')); + assert.ok(caseFile.supportingEvidence.some(item => item.type === 'cascade')); + assert.ok(caseFile.counterEvidence.length >= 1); + assert.ok(caseFile.triggers.length >= 1); + assert.ok(caseFile.actorLenses.length >= 1); + assert.ok(caseFile.actors.length >= 1); + assert.ok(caseFile.worldState.summary.includes('Iran')); + assert.ok(caseFile.worldState.activePressures.length >= 1); + assert.equal(caseFile.branches.length, 3); + }); + + it('buildForecastCases populates the case file for every forecast', () => { + const a = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87', weight: 0.4 }, + ]); + const b = makePrediction('market', 'Red Sea', 'Shipping price shock', 0.55, 0.5, '30d', [ + { type: 'chokepoint', value: 'Red Sea risk: high', weight: 0.5 }, + ]); + buildForecastCases([a, b]); + assert.ok(a.caseFile); + assert.ok(b.caseFile); + }); + + it('helper functions return structured case ingredients', () => { + const pred = makePrediction('supply_chain', 'Red Sea', 'Supply chain disruption: Red Sea', 0.64, 0.35, '7d', [ + { type: 'chokepoint', value: 'Red Sea disruption detected', weight: 0.5 }, + { type: 'gps_jamming', value: 'GPS interference near Red Sea', weight: 0.2 }, + ]); + pred.trend = 'rising'; + pred.cascades = [{ domain: 'market', effect: 'supply shortage pricing', probability: 0.38 }]; + + const counter = buildCounterEvidence(pred); + const triggers = buildCaseTriggers(pred); + const structuredActors = buildForecastActors(pred); + const worldState = buildForecastWorldState(pred, structuredActors, triggers, counter); + const branches = buildForecastBranches(pred, { + actors: structuredActors, + triggers, + counterEvidence: counter, + worldState, + }); + const actorLenses = buildActorLenses(pred); + assert.ok(Array.isArray(counter)); + assert.ok(triggers.length >= 1); + assert.ok(structuredActors.length >= 1); + assert.ok(worldState.summary.includes('Red Sea')); + assert.ok(worldState.activePressures.length >= 1); + assert.equal(branches.length, 3); + assert.ok(branches[0].rounds.length >= 3); + assert.ok(actorLenses.length >= 1); + }); +}); + +describe('forecast evaluation and ranking', () => { + it('scores evidence-rich forecasts above thin forecasts', () => { + const rich = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.62, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + { type: 'theater', value: 'Middle East theater posture elevated', weight: 0.2 }, + ]); + rich.newsContext = ['Iran military drills intensify after border incident']; + rich.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.04, source: 'polymarket' }; + rich.cascades = [{ domain: 'market', effect: 'commodity price shock', probability: 0.41 }]; + rich.trend = 'rising'; + buildForecastCase(rich); + + const thin = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.7, 0.62, '7d', [ + { type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 }, + ]); + thin.trend = 'stable'; + buildForecastCase(thin); + + const richScore = scoreForecastReadiness(rich); + const thinScore = scoreForecastReadiness(thin); + assert.ok(richScore.overall > thinScore.overall); + assert.ok(richScore.groundingScore > thinScore.groundingScore); + }); + + it('uses readiness to rank better-grounded forecasts ahead of thinner peers', () => { + const rich = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.66, 0.58, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + rich.newsContext = ['Iran military drills intensify after border incident']; + rich.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.57, drift: 0.03, source: 'polymarket' }; + rich.trend = 'rising'; + buildForecastCase(rich); + + const thin = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.69, 0.58, '7d', [ + { type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 }, + ]); + thin.trend = 'stable'; + buildForecastCase(thin); + + assert.ok(computeAnalysisPriority(rich) > computeAnalysisPriority(thin)); + + const ranked = [thin, rich]; + rankForecastsForAnalysis(ranked); + assert.equal(ranked[0].title, rich.title); + }); +}); + +describe('forecast change tracking', () => { + it('builds prior snapshots with enough context for evidence diffs', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + ]); + pred.newsContext = ['Iran military drills intensify after border incident']; + pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.04, source: 'polymarket' }; + const snapshot = buildPriorForecastSnapshot(pred); + assert.equal(snapshot.id, pred.id); + assert.deepEqual(snapshot.signals, ['Iran CII 87 (critical)']); + assert.deepEqual(snapshot.newsContext, ['Iran military drills intensify after border incident']); + assert.equal(snapshot.calibration.marketTitle, 'Will Iran conflict escalate before July?'); + }); + + it('annotates what changed versus the prior run', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.72, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + pred.newsContext = [ + 'Iran military drills intensify after border incident', + 'Regional officials warn of retaliation risk', + ]; + pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.64, drift: 0.04, source: 'polymarket' }; + buildForecastCase(pred); + + const prior = { + predictions: [{ + id: pred.id, + probability: 0.58, + signals: ['Iran CII 87 (critical)'], + newsContext: ['Iran military drills intensify after border incident'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.53 }, + }], + }; + + annotateForecastChanges([pred], prior); + assert.match(pred.caseFile.changeSummary, /Probability rose from 58% to 72%/); + assert.ok(pred.caseFile.changeItems.some(item => item.includes('New signal: 3 UCDP conflict events'))); + assert.ok(pred.caseFile.changeItems.some(item => item.includes('New reporting: Regional officials warn of retaliation risk'))); + assert.ok(pred.caseFile.changeItems.some(item => item.includes('Market moved from 53% to 64%'))); + }); + + it('marks newly surfaced forecasts clearly', () => { + const pred = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.55, 0.5, '30d', [ + { type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 }, + ]); + buildForecastCase(pred); + const items = buildChangeItems(pred, null); + const summary = buildChangeSummary(pred, null, items); + assert.match(summary, /new in the current run/i); + assert.ok(items[0].includes('New forecast surfaced')); + }); +}); + +describe('forecast narrative fallbacks', () => { + it('buildUserPrompt keeps headlines scoped to each prediction', () => { + const a = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87', weight: 0.4 }, + ]); + a.newsContext = ['Iran military drills intensify']; + a.projections = { h24: 0.6, d7: 0.7, d30: 0.5 }; + buildForecastCase(a); + + const b = makePrediction('market', 'Europe', 'Gas price shock in Europe', 0.55, 0.5, '30d', [ + { type: 'market', value: 'EU gas futures spike', weight: 0.3 }, + ]); + b.newsContext = ['European gas storage draw accelerates']; + b.projections = { h24: 0.5, d7: 0.55, d30: 0.6 }; + buildForecastCase(b); + + const prompt = buildUserPrompt([a, b]); + assert.match(prompt, /\[0\][\s\S]*Iran military drills intensify/); + assert.match(prompt, /\[1\][\s\S]*European gas storage draw accelerates/); + assert.ok(!prompt.includes('Current top headlines:')); + assert.match(prompt, /\[SUPPORTING_EVIDENCE\]/); + assert.match(prompt, /\[ACTORS\]/); + assert.match(prompt, /\[WORLD_STATE\]/); + assert.match(prompt, /\[SIMULATED_BRANCHES\]/); + }); + + it('populateFallbackNarratives fills missing scenario, perspectives, and case narratives', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + pred.trend = 'rising'; + populateFallbackNarratives([pred]); + assert.match(pred.scenario, /Iran CII 87|central path/i); + assert.ok(pred.perspectives?.strategic); + assert.ok(pred.perspectives?.regional); + assert.ok(pred.perspectives?.contrarian); + assert.ok(pred.caseFile?.baseCase); + assert.ok(pred.caseFile?.escalatoryCase); + assert.ok(pred.caseFile?.contrarianCase); + assert.equal(pred.caseFile?.branches?.length, 3); + assert.ok(pred.feedSummary); + }); + + it('fallback perspective references calibration when present', () => { + const pred = makePrediction('market', 'Middle East', 'Oil price impact', 0.65, 0.5, '30d', [ + { type: 'chokepoint', value: 'Hormuz disruption detected', weight: 0.5 }, + ]); + pred.calibration = { marketTitle: 'Will oil close above $90?', marketPrice: 0.62, drift: 0.03, source: 'polymarket' }; + const perspectives = buildFallbackPerspectives(pred); + assert.match(perspectives.contrarian, /Will oil close above \$90/); + }); + + it('fallback scenario stays concise and evidence-led', () => { + const pred = makePrediction('infrastructure', 'France', 'Infrastructure cascade risk: France', 0.48, 0.4, '24h', [ + { type: 'outage', value: 'France major outage', weight: 0.4 }, + ]); + const scenario = buildFallbackScenario(pred); + assert.match(scenario, /France major outage/); + assert.ok(scenario.length <= 500); + }); + + it('fallback case narratives stay evidence-led and concise', () => { + const pred = makePrediction('infrastructure', 'France', 'Infrastructure cascade risk: France', 0.48, 0.4, '24h', [ + { type: 'outage', value: 'France major outage', weight: 0.4 }, + ]); + buildForecastCase(pred); + const baseCase = buildFallbackBaseCase(pred); + const escalatoryCase = buildFallbackEscalatoryCase(pred); + const contrarianCase = buildFallbackContrarianCase(pred); + assert.match(baseCase, /France major outage/); + assert.ok(escalatoryCase.length <= 500); + assert.ok(contrarianCase.length <= 500); + }); + + it('buildFeedSummary stays compact and distinct from the deeper case output', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + buildForecastCase(pred); + pred.caseFile.baseCase = 'Iran CII 87 (critical) and 3 UCDP conflict events keep the base path elevated over the next 7d with persistent force pressure.'; + const summary = buildFeedSummary(pred); + assert.ok(summary.length <= 180); + assert.match(summary, /Iran CII 87/); + }); +}); + +describe('validateCaseNarratives', () => { + it('accepts valid case narratives', () => { + const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87', weight: 0.4 }, + ]); + const valid = validateCaseNarratives([{ + index: 0, + baseCase: 'Iran CII 87 remains the main anchor for the base path in the next 7d.', + escalatoryCase: 'A further rise in Iran CII 87 and added conflict-event reporting would move risk materially higher.', + contrarianCase: 'If no new corroborating headlines appear, the current path would lose support and flatten out.', + }], [pred]); + assert.equal(valid.length, 1); + }); }); describe('computeConfidence', () => { @@ -628,7 +970,33 @@ describe('validateScenarios', () => { assert.equal(valid.length, 1); }); - it('rejects scenario without signal reference', () => { + it('accepts scenario with headline reference', () => { + preds[0].newsContext = ['Iran military drills intensify after border incident']; + const scenarios = [{ index: 0, scenario: 'Iran military drills intensify after border incident, keeping escalation pressure elevated over the next 7d.' }]; + const valid = validateScenarios(scenarios, preds); + assert.equal(valid.length, 1); + delete preds[0].newsContext; + }); + + it('accepts scenario with market cue and trigger reference', () => { + preds[0].calibration = { marketTitle: 'Will oil close above $90?', marketPrice: 0.62, drift: 0.03, source: 'polymarket' }; + preds[0].caseFile = { + supportingEvidence: [], + counterEvidence: [], + triggers: ['A market repricing of 8-10 points would be a meaningful confirmation or rejection signal.'], + actorLenses: [], + baseCase: '', + escalatoryCase: '', + contrarianCase: '', + }; + const scenarios = [{ index: 0, scenario: 'Will oil close above $90? remains a live market cue, and a market repricing of 8-10 points would confirm the current path.' }]; + const valid = validateScenarios(scenarios, preds); + assert.equal(valid.length, 1); + delete preds[0].calibration; + delete preds[0].caseFile; + }); + + it('rejects scenario without any evidence reference', () => { const scenarios = [{ index: 0, scenario: 'Tensions continue to rise in the region due to various geopolitical factors and ongoing disputes.' }]; const valid = validateScenarios(scenarios, preds); assert.equal(valid.length, 0); diff --git a/tests/forecast-history.test.mjs b/tests/forecast-history.test.mjs new file mode 100644 index 000000000..54fc083ac --- /dev/null +++ b/tests/forecast-history.test.mjs @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + makePrediction, + buildHistorySnapshot, + buildForecastCase, +} from '../scripts/seed-forecasts.mjs'; + +import { + selectBenchmarkCandidates, + summarizeObservedChange, +} from '../scripts/extract-forecast-benchmark-candidates.mjs'; + +import { + toHistoricalBenchmarkEntry, + mergeHistoricalBenchmarks, + createJsonPatch, + buildPreviewPayload, +} from '../scripts/promote-forecast-benchmark-candidate.mjs'; + +describe('forecast history snapshot', () => { + it('buildHistorySnapshot stores a compact rolling snapshot', () => { + const rich = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [ + { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }, + ]); + rich.newsContext = ['Iran military drills intensify after border incident']; + buildForecastCase(rich); + + const thin = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.5, 0.4, '30d', [ + { type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 }, + ]); + buildForecastCase(thin); + + const snapshot = buildHistorySnapshot({ generatedAt: 1234, predictions: [rich, thin] }, { maxForecasts: 1 }); + assert.equal(snapshot.generatedAt, 1234); + assert.equal(snapshot.predictions.length, 1); + assert.equal(snapshot.predictions[0].title, rich.title); + assert.deepEqual(snapshot.predictions[0].signals[0], { type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 }); + }); +}); + +describe('forecast history candidate extraction', () => { + it('summarizes observed change across consecutive snapshots', () => { + const prior = { + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.5, + confidence: 0.55, + timeHorizon: '7d', + trend: 'stable', + signals: [{ type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }], + newsContext: ['Iran military drills intensify after border incident'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.45 }, + cascades: [], + }; + const current = { + ...prior, + probability: 0.68, + trend: 'rising', + signals: [ + ...prior.signals, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ], + newsContext: [...prior.newsContext, 'Regional officials warn of retaliation risk'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.66 }, + }; + + const observed = summarizeObservedChange(current, prior); + assert.equal(observed.deltaProbability, 0.18); + assert.deepEqual(observed.newSignals, ['3 UCDP conflict events']); + assert.deepEqual(observed.newHeadlines, ['Regional officials warn of retaliation risk']); + assert.equal(observed.marketMove, 0.21); + }); + + it('selects benchmark candidates from rolling history', () => { + const newest = { + generatedAt: Date.parse('2024-04-14T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.74, + confidence: 0.64, + timeHorizon: '7d', + trend: 'rising', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ], + newsContext: [ + 'Iran military drills intensify after border incident', + 'Regional officials warn of retaliation risk', + ], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.71 }, + cascades: [], + }], + }; + const prior = { + generatedAt: Date.parse('2024-04-13T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.46, + confidence: 0.55, + timeHorizon: '7d', + trend: 'stable', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + ], + newsContext: ['Iran military drills intensify after border incident'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.45 }, + cascades: [], + }], + }; + + const candidates = selectBenchmarkCandidates([newest, prior], { maxCandidates: 5 }); + assert.equal(candidates.length, 1); + assert.match(candidates[0].name, /escalation_risk_iran_2024_04_14/); + assert.equal(candidates[0].observedChange.deltaProbability, 0.28); + assert.ok(candidates[0].interestingness > 0.2); + }); + + it('ignores headline churn when there is no meaningful state change', () => { + const newest = { + generatedAt: Date.parse('2024-04-14T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.46, + confidence: 0.55, + timeHorizon: '7d', + trend: 'stable', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'news_corroboration', value: '6 headline(s) mention Iran or linked entities', weight: 0.15 }, + ], + newsContext: [ + 'Regional officials warn of retaliation risk', + 'Fresh commentary on Iranian posture appears', + ], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.46 }, + cascades: [], + }], + }; + const prior = { + generatedAt: Date.parse('2024-04-13T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.455, + confidence: 0.55, + timeHorizon: '7d', + trend: 'stable', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'news_corroboration', value: '60 headline(s) mention Iran or linked entities', weight: 0.15 }, + ], + newsContext: [ + 'Earlier commentary on Iranian posture appears', + ], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.455 }, + cascades: [], + }], + }; + + const candidates = selectBenchmarkCandidates([newest, prior], { maxCandidates: 5 }); + assert.equal(candidates.length, 0); + }); +}); + +describe('forecast benchmark promotion', () => { + it('builds a historical benchmark entry with derived thresholds', () => { + const newest = { + generatedAt: Date.parse('2024-04-14T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.74, + confidence: 0.64, + timeHorizon: '7d', + trend: 'rising', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ], + newsContext: [ + 'Iran military drills intensify after border incident', + 'Regional officials warn of retaliation risk', + ], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.71 }, + cascades: [], + }], + }; + const prior = { + generatedAt: Date.parse('2024-04-13T12:00:00Z'), + predictions: [{ + id: 'fc-conflict-1', + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.46, + confidence: 0.55, + timeHorizon: '7d', + trend: 'stable', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + ], + newsContext: ['Iran military drills intensify after border incident'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.45 }, + cascades: [], + }], + }; + + const [candidate] = selectBenchmarkCandidates([newest, prior], { maxCandidates: 5 }); + const entry = toHistoricalBenchmarkEntry(candidate); + + assert.equal(entry.name, candidate.name); + assert.equal(entry.thresholds.trend, 'rising'); + assert.match(entry.thresholds.changeSummaryIncludes[0], /rose from 46% to 74%/); + assert.ok(entry.thresholds.overallMin <= entry.thresholds.overallMax); + assert.ok(entry.thresholds.priorityMin <= entry.thresholds.priorityMax); + assert.ok(entry.thresholds.changeItemsInclude.some(item => item.includes('New signal: 3 UCDP conflict events'))); + }); + + it('merges a promoted historical entry by append or replace', () => { + const existing = [ + { name: 'red_sea_shipping_disruption_2024_01_15', eventDate: '2024-01-15' }, + ]; + const nextEntry = { + name: 'iran_exchange_2024_04_14', + eventDate: '2024-04-14', + description: 'desc', + forecast: {}, + thresholds: {}, + }; + + const appended = mergeHistoricalBenchmarks(existing, nextEntry); + assert.equal(appended.length, 2); + assert.equal(appended[1].name, 'iran_exchange_2024_04_14'); + + assert.throws(() => mergeHistoricalBenchmarks(appended, nextEntry), /already exists/); + + const replaced = mergeHistoricalBenchmarks(appended, { ...nextEntry, description: 'updated' }, { replace: true }); + assert.equal(replaced.length, 2); + assert.equal(replaced[1].description, 'updated'); + }); + + it('emits JSON patch previews and unified diffs without writing files', () => { + const existing = [ + { + name: 'red_sea_shipping_disruption_2024_01_15', + eventDate: '2024-01-15', + description: 'old', + }, + ]; + const candidate = { + name: 'iran_exchange_2024_04_14', + eventDate: '2024-04-14', + description: 'Iran escalation risk jumps', + priorForecast: { + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.46, + confidence: 0.55, + timeHorizon: '7d', + signals: [{ type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }], + }, + forecast: { + domain: 'conflict', + region: 'Iran', + title: 'Escalation risk: Iran', + probability: 0.74, + confidence: 0.64, + timeHorizon: '7d', + trend: 'rising', + signals: [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ], + newsContext: ['Regional officials warn of retaliation risk'], + calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.71 }, + }, + }; + + const nextEntry = toHistoricalBenchmarkEntry(candidate); + const patch = createJsonPatch(existing, nextEntry); + assert.deepEqual(patch[0].op, 'add'); + assert.deepEqual(patch[0].path, '/1'); + + const jsonPreview = buildPreviewPayload( + { format: 'json-patch', output: '/tmp/forecast-historical-benchmark.json', replace: false }, + candidate, + nextEntry, + existing, + ); + assert.equal(jsonPreview.format, 'json-patch'); + assert.equal(jsonPreview.patch[0].op, 'add'); + + const diffPreview = buildPreviewPayload( + { format: 'diff', output: '/tmp/forecast-historical-benchmark.json', replace: false }, + candidate, + nextEntry, + existing, + ); + assert.equal(diffPreview.format, 'diff'); + assert.match(diffPreview.diff, /Escalation risk: Iran/); + assert.match(diffPreview.diff, /Iran escalation risk jumps/); + }); +}); diff --git a/tests/forecast-trace-export.test.mjs b/tests/forecast-trace-export.test.mjs new file mode 100644 index 000000000..e91522d2e --- /dev/null +++ b/tests/forecast-trace-export.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + makePrediction, + buildForecastCase, + populateFallbackNarratives, + buildForecastTraceArtifacts, +} from '../scripts/seed-forecasts.mjs'; + +import { + resolveR2StorageConfig, +} from '../scripts/_r2-storage.mjs'; + +describe('forecast trace storage config', () => { + it('resolves Cloudflare R2 trace env vars and derives the endpoint from account id', () => { + const config = resolveR2StorageConfig({ + CLOUDFLARE_R2_ACCOUNT_ID: 'acct123', + CLOUDFLARE_R2_TRACE_BUCKET: 'trace-bucket', + CLOUDFLARE_R2_ACCESS_KEY_ID: 'abc', + CLOUDFLARE_R2_SECRET_ACCESS_KEY: 'def', + CLOUDFLARE_R2_REGION: 'auto', + CLOUDFLARE_R2_TRACE_PREFIX: 'custom-prefix', + CLOUDFLARE_R2_FORCE_PATH_STYLE: 'true', + }); + assert.equal(config.bucket, 'trace-bucket'); + assert.equal(config.endpoint, 'https://acct123.r2.cloudflarestorage.com'); + assert.equal(config.region, 'auto'); + assert.equal(config.basePrefix, 'custom-prefix'); + assert.equal(config.forcePathStyle, true); + }); + + it('falls back to a shared Cloudflare R2 bucket env var', () => { + const config = resolveR2StorageConfig({ + CLOUDFLARE_R2_ACCOUNT_ID: 'acct123', + CLOUDFLARE_R2_BUCKET: 'shared-bucket', + CLOUDFLARE_R2_ACCESS_KEY_ID: 'abc', + CLOUDFLARE_R2_SECRET_ACCESS_KEY: 'def', + }); + assert.equal(config.bucket, 'shared-bucket'); + assert.equal(config.endpoint, 'https://acct123.r2.cloudflarestorage.com'); + }); +}); + +describe('forecast trace artifact builder', () => { + it('builds manifest, summary, and per-forecast trace artifacts', () => { + const a = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.74, 0.64, '7d', [ + { type: 'cii', value: 'Iran CII 79 (high)', weight: 0.4 }, + { type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 }, + ]); + a.newsContext = ['Regional officials warn of retaliation risk']; + a.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.71, drift: 0.03, source: 'polymarket' }; + a.trend = 'rising'; + buildForecastCase(a); + + const b = makePrediction('supply_chain', 'Red Sea', 'Shipping disruption: Red Sea', 0.68, 0.59, '7d', [ + { type: 'chokepoint', value: 'Red Sea disruption detected', weight: 0.5 }, + { type: 'gps_jamming', value: 'GPS interference near Red Sea', weight: 0.2 }, + ]); + b.newsContext = ['Freight rates react to Red Sea rerouting']; + b.trend = 'rising'; + buildForecastCase(b); + + populateFallbackNarratives([a, b]); + + const artifacts = buildForecastTraceArtifacts( + { generatedAt: Date.parse('2026-03-15T08:00:00Z'), predictions: [a, b] }, + { runId: 'run-123' }, + { basePrefix: 'forecast-runs', maxForecasts: 1 }, + ); + + assert.equal(artifacts.manifest.runId, 'run-123'); + assert.equal(artifacts.manifest.forecastCount, 2); + assert.equal(artifacts.manifest.tracedForecastCount, 1); + assert.match(artifacts.manifestKey, /forecast-runs\/2026\/03\/15\/run-123\/manifest\.json/); + assert.match(artifacts.summaryKey, /forecast-runs\/2026\/03\/15\/run-123\/summary\.json/); + assert.equal(artifacts.forecasts.length, 1); + assert.equal(artifacts.summary.topForecasts[0].id, a.id); + assert.ok(artifacts.forecasts[0].payload.caseFile.worldState.summary.includes('Iran')); + assert.equal(artifacts.forecasts[0].payload.caseFile.branches.length, 3); + assert.equal(artifacts.forecasts[0].payload.traceMeta.narrativeSource, 'fallback'); + }); +});