Spaces:
Paused
Paused
Simplify Vaticinus to one forecast form and six OpenRouter models
Browse filesRemove hero image, slogans, Scenario Lab, review and spending controls. Add event examples, optional context, yes/no results and a curated budget-to-premium model list. Fix text-only catalog discovery and preserve request limits, key privacy and cancellation. Verified with 74 offline tests.
- README.md +11 -19
- RELEASE_MANIFEST.json +14 -14
- forecast-core/src/lib/runtime.ts +21 -6
- space/app.js +66 -180
- space/index.html +35 -63
- space/server.mts +26 -14
- space/style.css +50 -143
README.md
CHANGED
|
@@ -1,36 +1,28 @@
|
|
| 1 |
---
|
| 2 |
-
title: Vaticinus Forecast
|
| 3 |
colorFrom: blue
|
| 4 |
colorTo: gray
|
| 5 |
sdk: docker
|
| 6 |
app_port: 7860
|
| 7 |
license: mit
|
| 8 |
-
short_description:
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# Vaticinus
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
[Open the
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
You can download the result with its assumptions, evidence and accounted cost. If an attempt fails, it remains a failed attempt with no substituted probability. You can download that record too. Cancelling stops the request but cannot undo charges for work the provider has already processed.
|
| 24 |
-
|
| 25 |
-
## Try the scenario tools
|
| 26 |
-
|
| 27 |
-
Change the weights in a two-scenario model, update an estimate with Bayes' rule, or calculate a normal threshold probability. These examples are synthetic. The optional payoff check assumes two outcomes and zero payoff for inaction; it is not investment advice or a decision recommendation.
|
| 28 |
|
| 29 |
## Keys and privacy
|
| 30 |
|
| 31 |
-
The app does not save your key in browser storage or a credential file.
|
| 32 |
-
|
| 33 |
-
Brand imagery loads from vaticinus.com. The app has no accounts, analytics integration, durable user history, or automatic external submissions.
|
| 34 |
|
| 35 |
## Run locally
|
| 36 |
|
|
@@ -40,6 +32,6 @@ With Node 22.18 or newer, from `forecast-stack/`:
|
|
| 40 |
node --experimental-strip-types space/server.mts
|
| 41 |
```
|
| 42 |
|
| 43 |
-
Open `http://localhost:7860`. The Docker image
|
| 44 |
|
| 45 |
-
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Vaticinus Forecast
|
| 3 |
colorFrom: blue
|
| 4 |
colorTo: gray
|
| 5 |
sdk: docker
|
| 6 |
app_port: 7860
|
| 7 |
license: mit
|
| 8 |
+
short_description: Event forecasts with your OpenRouter key.
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Vaticinus
|
| 12 |
|
| 13 |
+
Ask a yes-or-no question about a future event. Set a deadline, enter your OpenRouter key, and choose a model. The result includes probabilities for Yes and No, the model's reasoning, and its assumptions.
|
| 14 |
|
| 15 |
+
[Open the app](https://vaticinus-forecast-stack.hf.space) · [Vaticinus](https://vaticinus.com) · [Source code](https://github.com/vaticinus/vati/tree/main/forecast-stack)
|
| 16 |
|
| 17 |
+
For example: “Will SpaceX land an uncrewed Starship on Mars before 2030?” You can add facts or excerpts under **Add context or sources**. The app uses model knowledge and the text you supply. It does not search the web or read pasted links.
|
| 18 |
|
| 19 |
+
The model list spans budget and premium options, informed by the [Artificial Analysis leaderboard](https://artificialanalysis.ai/leaderboards/models) and checked against OpenRouter's model catalog on September 22, 2026. General model benchmarks do not establish forecasting accuracy. Current token prices appear below the selector.
|
| 20 |
|
| 21 |
+
Each forecast uses one model call, billed to your OpenRouter account. The app limits output length and checks the returned probability and calculation. It does not automatically retry failed requests. Cancellation cannot undo charges for processing that has already happened. You can copy the forecast or download its record.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
## Keys and privacy
|
| 24 |
|
| 25 |
+
The app does not save your key in browser storage or a credential file. Your key and input travel over HTTPS through this Space to OpenRouter and your selected model provider. Temporary processing files are deleted after each request. Provider infrastructure and privacy policies also apply.
|
|
|
|
|
|
|
| 26 |
|
| 27 |
## Run locally
|
| 28 |
|
|
|
|
| 32 |
node --experimental-strip-types space/server.mts
|
| 33 |
```
|
| 34 |
|
| 35 |
+
Open `http://localhost:7860`. The Docker image uses CPU Basic and needs no GPU.
|
| 36 |
|
| 37 |
+
See the [evaluation guide](https://github.com/vaticinus/vati/blob/main/forecast-stack/docs/SUPERFORECASTING.md) for the forecasting tests and their limits.
|
RELEASE_MANIFEST.json
CHANGED
|
@@ -21,8 +21,8 @@
|
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"path": "README.md",
|
| 24 |
-
"sha256": "
|
| 25 |
-
"bytes":
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"path": "forecast-core/LICENSE",
|
|
@@ -36,8 +36,8 @@
|
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "forecast-core/src/lib/coherence.ts",
|
| 39 |
-
"sha256": "
|
| 40 |
-
"bytes":
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"path": "forecast-core/src/lib/evidence.ts",
|
|
@@ -81,8 +81,8 @@
|
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"path": "forecast-core/src/lib/runtime.ts",
|
| 84 |
-
"sha256": "
|
| 85 |
-
"bytes":
|
| 86 |
},
|
| 87 |
{
|
| 88 |
"path": "forecast-core/src/lib/workflow.ts",
|
|
@@ -91,23 +91,23 @@
|
|
| 91 |
},
|
| 92 |
{
|
| 93 |
"path": "space/app.js",
|
| 94 |
-
"sha256": "
|
| 95 |
-
"bytes":
|
| 96 |
},
|
| 97 |
{
|
| 98 |
"path": "space/index.html",
|
| 99 |
-
"sha256": "
|
| 100 |
-
"bytes":
|
| 101 |
},
|
| 102 |
{
|
| 103 |
"path": "space/server.mts",
|
| 104 |
-
"sha256": "
|
| 105 |
-
"bytes":
|
| 106 |
},
|
| 107 |
{
|
| 108 |
"path": "space/style.css",
|
| 109 |
-
"sha256": "
|
| 110 |
-
"bytes":
|
| 111 |
}
|
| 112 |
]
|
| 113 |
}
|
|
|
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"path": "README.md",
|
| 24 |
+
"sha256": "feba2aca507c97a9f4cacba210fb064f46c92961e34875c094f46fe41df575d4",
|
| 25 |
+
"bytes": 2142
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"path": "forecast-core/LICENSE",
|
|
|
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "forecast-core/src/lib/coherence.ts",
|
| 39 |
+
"sha256": "2fbb937bf36ddc5c2b6101d5da38da869cee10efabf1d252f708b645e559cfc9",
|
| 40 |
+
"bytes": 26759
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"path": "forecast-core/src/lib/evidence.ts",
|
|
|
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"path": "forecast-core/src/lib/runtime.ts",
|
| 84 |
+
"sha256": "cfc048f5232fa724b93f21db5e6b5d2920d1ef51f1f139203e5df73fc2c163e0",
|
| 85 |
+
"bytes": 14882
|
| 86 |
},
|
| 87 |
{
|
| 88 |
"path": "forecast-core/src/lib/workflow.ts",
|
|
|
|
| 91 |
},
|
| 92 |
{
|
| 93 |
"path": "space/app.js",
|
| 94 |
+
"sha256": "8e416acfba02be8d882136c7474b9e8d0fe07aafd502ff7b3247a7a70cf8c618",
|
| 95 |
+
"bytes": 7073
|
| 96 |
},
|
| 97 |
{
|
| 98 |
"path": "space/index.html",
|
| 99 |
+
"sha256": "1f1f5704f795d5af3a7716048bad7c29cb06a0590caf16215fc630a73a4d2c80",
|
| 100 |
+
"bytes": 4920
|
| 101 |
},
|
| 102 |
{
|
| 103 |
"path": "space/server.mts",
|
| 104 |
+
"sha256": "9c8b1630fb116cd9049db664c2cfd5cf2c0a1e3f65bba0ba6de394312748be22",
|
| 105 |
+
"bytes": 11127
|
| 106 |
},
|
| 107 |
{
|
| 108 |
"path": "space/style.css",
|
| 109 |
+
"sha256": "a9ad475e4354a370cab2315b6d2312213a819302483b884105194d27a1a0f804",
|
| 110 |
+
"bytes": 4628
|
| 111 |
}
|
| 112 |
]
|
| 113 |
}
|
forecast-core/src/lib/runtime.ts
CHANGED
|
@@ -37,18 +37,33 @@ export type RuntimeOptions = {
|
|
| 37 |
};
|
| 38 |
|
| 39 |
/** Read-only discovery. Price caps remain explicit; discovery is not spending authorization. */
|
| 40 |
-
export async function openRouterModel(model: string, request = fetch): Promise<{rates: Rates; reasoning: boolean; context: number}> {
|
| 41 |
const response = await request('https://openrouter.ai/api/v1/models', {signal: AbortSignal.timeout(20000)});
|
| 42 |
if (!response.ok) throw new RuntimeError(`Model catalog HTTP ${response.status}`, 'configuration');
|
| 43 |
const data = await response.json() as {data?: Array<Record<string, any>>};
|
| 44 |
const item = data.data?.find(x => x.id === model);
|
| 45 |
if (!item) throw new RuntimeError(`Model is absent from the provider catalog: ${model}`, 'configuration');
|
| 46 |
-
const
|
|
|
|
| 47 |
if (!finite(input) || !finite(output)) throw new RuntimeError('Model prices are unavailable', 'configuration');
|
| 48 |
-
//
|
| 49 |
-
for (const
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
return {rates: {input, output}, reasoning: item.supported_parameters?.includes('reasoning') === true,
|
| 54 |
context: Number(item.context_length) || 0};
|
|
|
|
| 37 |
};
|
| 38 |
|
| 39 |
/** Read-only discovery. Price caps remain explicit; discovery is not spending authorization. */
|
| 40 |
+
export async function openRouterModel(model: string, request = fetch, options: {textOnly?: boolean} = {}): Promise<{rates: Rates; reasoning: boolean; context: number}> {
|
| 41 |
const response = await request('https://openrouter.ai/api/v1/models', {signal: AbortSignal.timeout(20000)});
|
| 42 |
if (!response.ok) throw new RuntimeError(`Model catalog HTTP ${response.status}`, 'configuration');
|
| 43 |
const data = await response.json() as {data?: Array<Record<string, any>>};
|
| 44 |
const item = data.data?.find(x => x.id === model);
|
| 45 |
if (!item) throw new RuntimeError(`Model is absent from the provider catalog: ${model}`, 'configuration');
|
| 46 |
+
const prices = item.pricing ?? {};
|
| 47 |
+
let input = Number(prices.prompt) * 1e6, output = Number(prices.completion) * 1e6;
|
| 48 |
if (!finite(input) || !finite(output)) throw new RuntimeError('Model prices are unavailable', 'configuration');
|
| 49 |
+
// Reserve the highest time/context tier, even when catalog discovery happens off-peak.
|
| 50 |
+
for (const tier of prices.overrides ?? []) {
|
| 51 |
+
const tierInput = Number(tier.prompt ?? prices.prompt) * 1e6;
|
| 52 |
+
const tierOutput = Number(tier.completion ?? prices.completion) * 1e6;
|
| 53 |
+
if (!finite(tierInput) || !finite(tierOutput)) throw new RuntimeError('Invalid tier prices', 'configuration');
|
| 54 |
+
input = Math.max(input, tierInput); output = Math.max(output, tierOutput);
|
| 55 |
+
}
|
| 56 |
+
const tokenFields = ['prompt', 'completion', 'input_cache_read', 'input_cache_write', 'input_cache_write_1h', 'discount', 'overrides'];
|
| 57 |
+
// These features are never sent by the text-only Space (no tools, images, audio or plugins).
|
| 58 |
+
const optionalFeatures = ['web_search', 'image', 'audio', 'input_audio_cache', 'image_output', 'audio_output'];
|
| 59 |
+
for (const [name, amount] of Object.entries(prices)) {
|
| 60 |
+
if (tokenFields.includes(name) || (options.textOnly && optionalFeatures.includes(name))) continue;
|
| 61 |
+
if (options.textOnly && name === 'internal_reasoning') {
|
| 62 |
+
const rate = Number(amount) * 1e6;
|
| 63 |
+
if (!finite(rate)) throw new RuntimeError('Invalid reasoning price', 'configuration');
|
| 64 |
+
output = Math.max(output, rate); continue;
|
| 65 |
+
}
|
| 66 |
+
if (Number(amount) > 0) throw new RuntimeError(`Unsupported additional model charge: ${name}`, 'configuration');
|
| 67 |
}
|
| 68 |
return {rates: {input, output}, reasoning: item.supported_parameters?.includes('reasoning') === true,
|
| 69 |
context: Number(item.context_length) || 0};
|
space/app.js
CHANGED
|
@@ -1,201 +1,87 @@
|
|
| 1 |
const get = id => document.getElementById(id);
|
| 2 |
-
const percent =
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
spec: { kind: 'conditional', question: 'Will the project finish by the deadline?', resolution_date: deadline(90), dated_metric: 'Completion entered in the project register by the deadline.', partition: 'Scenario A or scenario B', branches: [{ condition: 'Scenario A', weight: 0.3, p_yes: 0.6 }, { condition: 'Scenario B', weight: 0.7, p_yes: 0.1 }] },
|
| 9 |
-
fields: [['weight', 'Chance of scenario A (%)', 30, 0, 100, 1], ['yesA', 'Chance of completion in A (%)', 60, 0, 100, 1], ['yesB', 'Chance of completion in B (%)', 10, 0, 100, 1]],
|
| 10 |
-
},
|
| 11 |
-
bayes: {
|
| 12 |
-
title: 'Update an estimate with new evidence', description: 'Start with the base rate. Then enter how often a positive signal appears in defective and working devices.',
|
| 13 |
-
spec: { kind: 'bayes', question: 'Is the screened device defective, given a positive result?', prior: 0.01, observation: 'One positive screening result', likelihood_yes: 0.8, likelihood_no: 0.1 },
|
| 14 |
-
fields: [['prior', 'Base rate of defects (%)', 1, 0, 100, 0.1], ['signalYes', 'Positive signal when defective (%)', 80, 0, 100, 1], ['signalNo', 'Positive signal when not defective (%)', 10, 0, 100, 1]],
|
| 15 |
-
},
|
| 16 |
-
normal: {
|
| 17 |
-
title: 'Estimate the chance of reaching a target', description: 'Enter the expected outcome and its spread. This assumes a normal distribution; the spread describes possible outcomes, not confidence in the estimate.',
|
| 18 |
-
spec: { kind: 'normal', question: 'Will final capacity reach at least 110 MW?', mean: 100, sd: 10, threshold: 110, threshold_dir: '>=', ci_unit: 'MW' },
|
| 19 |
-
fields: [['mean', 'Expected capacity (MW)', 100, -1000000, 1000000, 1], ['sd', 'Standard deviation (MW)', 10, 0.001, 1000000, 0.1], ['threshold', 'Target capacity (MW)', 110, -1000000, 1000000, 1]],
|
| 20 |
-
},
|
| 21 |
-
binary: {
|
| 22 |
-
title: 'Record your own estimate', description: 'Enter the probability you would assign to the event. The calculation preserves your judgment as supplied.',
|
| 23 |
-
spec: { kind: 'binary', question: 'Will your precisely defined event occur?', p_yes: 0.35 },
|
| 24 |
-
fields: [['judgment', 'Your probability (%)', 35, 0, 100, 1]],
|
| 25 |
-
},
|
| 26 |
-
};
|
| 27 |
-
let record = null;
|
| 28 |
-
let probability = null;
|
| 29 |
-
let revision = 0;
|
| 30 |
-
let active = null;
|
| 31 |
-
let availableModels = [];
|
| 32 |
-
let mode = 'forecast';
|
| 33 |
-
function panels(show) {
|
| 34 |
-
for (const name of ['empty', 'loading', 'result', 'error-panel']) get(name).hidden = name !== show;
|
| 35 |
-
document.querySelector('.result-panel').setAttribute('aria-busy', String(show === 'loading'));
|
| 36 |
-
}
|
| 37 |
-
function invalidate() {
|
| 38 |
-
revision += 1; record = null; probability = null;
|
| 39 |
-
if (active) active.abort();
|
| 40 |
-
panels('empty'); get('copy-status').textContent = ''; get('result-label').textContent = 'Ready when you are';
|
| 41 |
-
}
|
| 42 |
-
function setMode(next) {
|
| 43 |
-
if (mode !== next) invalidate();
|
| 44 |
-
mode = next;
|
| 45 |
-
for (const name of ['forecast', 'lab']) {
|
| 46 |
-
get(`${name}-tab`).setAttribute('aria-selected', String(name === next));
|
| 47 |
-
get(`${name}-tab`).tabIndex = name === next ? 0 : -1;
|
| 48 |
-
get(`${name}-panel`).hidden = name !== next;
|
| 49 |
-
}
|
| 50 |
-
get('mode-caption').textContent = next === 'forecast' ? 'Uses your OpenRouter key and credits.' : 'No key or model credits needed.';
|
| 51 |
-
}
|
| 52 |
-
for (const name of ['forecast', 'lab']) {
|
| 53 |
-
get(`${name}-tab`).addEventListener('click', () => setMode(name));
|
| 54 |
-
get(`${name}-tab`).addEventListener('keydown', event => {
|
| 55 |
-
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
| 56 |
-
event.preventDefault(); const next = event.key === 'Home' ? 'forecast' : event.key === 'End' ? 'lab' : name === 'forecast' ? 'lab' : 'forecast';
|
| 57 |
-
setMode(next); get(`${next}-tab`).focus();
|
| 58 |
-
}
|
| 59 |
-
});
|
| 60 |
-
}
|
| 61 |
-
function metadata(entries) {
|
| 62 |
-
get('result-meta').replaceChildren();
|
| 63 |
-
for (const [label, value] of entries) {
|
| 64 |
-
const term = document.createElement('dt'), detail = document.createElement('dd');
|
| 65 |
-
term.textContent = label; detail.textContent = value; get('result-meta').append(term, detail);
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
-
function list(id, section, values) {
|
| 69 |
-
const items = Array.isArray(values) ? values.slice(0, 12) : [];
|
| 70 |
-
get(id).replaceChildren(); get(section).hidden = items.length === 0;
|
| 71 |
-
for (const value of items) { const li = document.createElement('li'); li.textContent = typeof value === 'string' ? value : JSON.stringify(value); get(id).append(li); }
|
| 72 |
}
|
| 73 |
-
function
|
| 74 |
-
|
| 75 |
-
if (probability === null || !get('upside').value || !get('downside').value || !Number.isFinite(gain + loss) || gain < 0 || loss < 0 || gain + loss === 0) {
|
| 76 |
-
get('decision-output').textContent = 'Enter nonnegative payoffs, with at least one above zero.'; return;
|
| 77 |
-
}
|
| 78 |
-
const expected = probability * gain - (1 - probability) * loss;
|
| 79 |
-
get('decision-output').textContent = `Break-even probability: ${percent(loss / (gain + loss))}. Under this estimate, expected net payoff is ${expected.toLocaleString('en', { maximumFractionDigits: 2 })} in your chosen units, before other costs.`;
|
| 80 |
-
}
|
| 81 |
-
get('upside').addEventListener('input', payoff); get('downside').addEventListener('input', payoff);
|
| 82 |
-
function showResult(p, question, method, label) {
|
| 83 |
-
probability = p; get('probability').textContent = percent(p); get('question').textContent = question;
|
| 84 |
-
get('method').textContent = method; get('result-label').textContent = label; panels('result'); payoff();
|
| 85 |
-
}
|
| 86 |
-
function showError(message, cost = '') {
|
| 87 |
-
get('error').textContent = message; get('error-cost').textContent = cost;
|
| 88 |
-
get('download-failure').hidden = !record; get('result-label').textContent = 'No probability substituted'; panels('error-panel');
|
| 89 |
-
}
|
| 90 |
-
function download() {
|
| 91 |
-
if (!record) return;
|
| 92 |
-
const url = URL.createObjectURL(new Blob([JSON.stringify(record, null, 2) + '\n'], { type: 'application/json' }));
|
| 93 |
-
const link = document.createElement('a'); link.href = url; link.download = 'vaticinus-forecast-record.json'; link.click();
|
| 94 |
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 95 |
-
}
|
| 96 |
-
get('download').addEventListener('click', download); get('download-failure').addEventListener('click', download);
|
| 97 |
-
get('copy-result').addEventListener('click', async () => {
|
| 98 |
-
try { await navigator.clipboard.writeText(`${get('question').textContent}\nProbability: ${get('probability').textContent}\n${get('method').textContent}\nMade with Vaticinus: https://vaticinus.com`); get('copy-status').textContent = 'Summary copied.'; }
|
| 99 |
-
catch { get('copy-status').textContent = 'Clipboard unavailable. Use Download record instead.'; }
|
| 100 |
-
});
|
| 101 |
-
function resetLab() {
|
| 102 |
-
const template = templates[get('example').value];
|
| 103 |
-
get('lab-title').textContent = template.title; get('lab-description').textContent = template.description;
|
| 104 |
-
get('lab-fields').replaceChildren();
|
| 105 |
-
for (const [id, label, value, min, max, step] of template.fields) {
|
| 106 |
-
const wrapper = document.createElement('div'), caption = document.createElement('label'), input = document.createElement('input');
|
| 107 |
-
caption.htmlFor = `lab-${id}`; caption.textContent = label;
|
| 108 |
-
Object.assign(input, { id: `lab-${id}`, type: 'number', value: String(value), min: String(min), max: String(max), step: String(step), required: true });
|
| 109 |
-
input.addEventListener('input', updateLab); wrapper.append(caption, input); get('lab-fields').append(wrapper);
|
| 110 |
-
}
|
| 111 |
-
get('spec').value = JSON.stringify(template.spec, null, 2); invalidate();
|
| 112 |
-
}
|
| 113 |
-
function updateLab() {
|
| 114 |
-
const kind = get('example').value, spec = structuredClone(templates[kind].spec);
|
| 115 |
-
const value = id => Number(get(`lab-${id}`).value);
|
| 116 |
-
if (kind === 'conditional') { spec.branches[0].weight = value('weight') / 100; spec.branches[1].weight = 1 - spec.branches[0].weight; spec.branches[0].p_yes = value('yesA') / 100; spec.branches[1].p_yes = value('yesB') / 100; }
|
| 117 |
-
if (kind === 'bayes') { spec.prior = value('prior') / 100; spec.likelihood_yes = value('signalYes') / 100; spec.likelihood_no = value('signalNo') / 100; }
|
| 118 |
-
if (kind === 'normal') { spec.mean = value('mean'); spec.sd = value('sd'); spec.threshold = value('threshold'); spec.question = `Will final capacity reach at least ${spec.threshold} MW?`; }
|
| 119 |
-
if (kind === 'binary') spec.p_yes = value('judgment') / 100;
|
| 120 |
-
get('spec').value = JSON.stringify(spec, null, 2); invalidate();
|
| 121 |
-
}
|
| 122 |
-
get('example').addEventListener('change', resetLab); get('reset').addEventListener('click', resetLab);
|
| 123 |
-
get('spec').addEventListener('input', invalidate);
|
| 124 |
-
async function calculate(event) {
|
| 125 |
-
event?.preventDefault(); invalidate(); const current = revision;
|
| 126 |
-
get('compute').disabled = true;
|
| 127 |
-
try {
|
| 128 |
-
const spec = JSON.parse(get('spec').value);
|
| 129 |
-
const response = await fetch('/api/compute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(spec), signal: AbortSignal.timeout(20000) });
|
| 130 |
-
const data = await response.json(); if (current !== revision) return;
|
| 131 |
-
if (!response.ok) throw new Error(data.error || 'The model could not be computed.');
|
| 132 |
-
if (!data.result.ok || typeof data.result.probability !== 'number') throw new Error(data.result.reason || data.result.error || 'These assumptions do not identify a valid probability.');
|
| 133 |
-
record = data.snapshot; showResult(data.result.probability, spec.question, data.result.method, 'Synthetic scenario');
|
| 134 |
-
metadata([['Model', spec.kind], ['Calculated', new Date(data.snapshot.computed_forecast.computed_at).toLocaleString()], ['Model cost', '$0.00']]);
|
| 135 |
-
list('assumptions', 'assumptions-section', spec.kind === 'conditional' ? spec.branches.map(branch => `${branch.condition}: ${percent(branch.weight)} weight × ${percent(branch.p_yes)} event chance`) : spec.assumptions);
|
| 136 |
-
list('cruxes', 'cruxes-section', []); get('explanation-section').hidden = true;
|
| 137 |
-
get('details').textContent = JSON.stringify(data.snapshot, null, 2);
|
| 138 |
-
} catch (error) { if (current === revision) showError(error.message); }
|
| 139 |
-
finally { get('compute').disabled = false; }
|
| 140 |
-
}
|
| 141 |
-
get('model-form').addEventListener('submit', calculate);
|
| 142 |
-
get('try-example').addEventListener('click', () => { setMode('lab'); get('example').value = 'conditional'; resetLab(); calculate(); });
|
| 143 |
function pricing() {
|
| 144 |
-
const model =
|
| 145 |
-
get('model-pricing').textContent = model ? `
|
| 146 |
}
|
| 147 |
async function loadModels() {
|
| 148 |
get('model').disabled = true; get('forecast-submit').disabled = true; get('reload-models').hidden = true;
|
| 149 |
try {
|
| 150 |
const response = await fetch('/api/models', { signal: AbortSignal.timeout(25000) });
|
| 151 |
-
const data = await response.json();
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
get('model').disabled = false; get('forecast-submit').disabled = false; pricing();
|
| 156 |
-
} catch
|
| 157 |
}
|
| 158 |
-
get('reload-models').addEventListener('click', loadModels);
|
|
|
|
| 159 |
get('forget-key').addEventListener('click', () => { get('api-key').value = ''; invalidate(); get('api-key').focus(); });
|
| 160 |
window.addEventListener('pagehide', () => { get('api-key').value = ''; active?.abort(); });
|
| 161 |
-
get('resolution-date').min =
|
| 162 |
for (const field of get('forecast-form').querySelectorAll('input, textarea, select')) field.addEventListener('input', invalidate);
|
| 163 |
-
const
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
};
|
| 168 |
-
for (const button of document.querySelectorAll('[data-
|
| 169 |
-
const [question,
|
| 170 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
get('cancel').addEventListener('click', () => active?.abort());
|
| 172 |
get('forecast-form').addEventListener('submit', async event => {
|
| 173 |
-
event.preventDefault();
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
const
|
| 178 |
-
|
|
|
|
| 179 |
try {
|
| 180 |
const response = await fetch('/api/forecast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
|
| 181 |
-
body: JSON.stringify({ question: get('forecast-question').value, resolutionDate: get('resolution-date').value,
|
| 182 |
-
evidence: get('evidence').value, apiKey: get('api-key').value.trim(), model: get('model').value, arm, maxCost: Number(get('max-cost').value) }) });
|
| 183 |
const data = await response.json(); if (current !== revision) return;
|
| 184 |
-
if (!response.ok) throw new Error(data.error || '
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
get('
|
| 193 |
-
|
| 194 |
-
} catch (
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
});
|
| 201 |
-
|
|
|
|
| 1 |
const get = id => document.getElementById(id);
|
| 2 |
+
const percent = p => `${(p * 100).toLocaleString('en', { maximumFractionDigits: 1 })}%`;
|
| 3 |
+
let models = [], record = null, revision = 0, active = null;
|
| 4 |
+
function panel(name) {
|
| 5 |
+
get('output').hidden = !name;
|
| 6 |
+
for (const id of ['loading', 'result', 'error-panel']) get(id).hidden = id !== name;
|
| 7 |
+
get('output').setAttribute('aria-busy', String(name === 'loading'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
+
function invalidate() { revision++; record = null; active?.abort(); panel(null); get('copy-status').textContent = ''; }
|
| 10 |
+
function error(message, cost = '') { get('error').textContent = message; get('error-cost').textContent = cost; panel('error-panel'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
function pricing() {
|
| 12 |
+
const model = models.find(m => m.id === get('model').value);
|
| 13 |
+
get('model-pricing').textContent = model ? `$${model.rates.input.toFixed(2)} input / $${model.rates.output.toFixed(2)} output per million tokens.` : '';
|
| 14 |
}
|
| 15 |
async function loadModels() {
|
| 16 |
get('model').disabled = true; get('forecast-submit').disabled = true; get('reload-models').hidden = true;
|
| 17 |
try {
|
| 18 |
const response = await fetch('/api/models', { signal: AbortSignal.timeout(25000) });
|
| 19 |
+
const data = await response.json();
|
| 20 |
+
if (!response.ok || !Array.isArray(data.models) || !data.models.length) throw new Error('Models are unavailable. Please retry.');
|
| 21 |
+
models = data.models; get('model').replaceChildren();
|
| 22 |
+
for (const m of models) { const option = document.createElement('option'); option.value = m.id; option.textContent = `${m.name} · ${m.tier}`; get('model').append(option); }
|
| 23 |
get('model').disabled = false; get('forecast-submit').disabled = false; pricing();
|
| 24 |
+
} catch { get('model-pricing').textContent = 'Couldn’t load the model list.'; get('reload-models').hidden = false; }
|
| 25 |
}
|
| 26 |
+
get('reload-models').addEventListener('click', loadModels);
|
| 27 |
+
get('model').addEventListener('change', () => { invalidate(); pricing(); });
|
| 28 |
get('forget-key').addEventListener('click', () => { get('api-key').value = ''; invalidate(); get('api-key').focus(); });
|
| 29 |
window.addEventListener('pagehide', () => { get('api-key').value = ''; active?.abort(); });
|
| 30 |
+
get('resolution-date').min = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
| 31 |
for (const field of get('forecast-form').querySelectorAll('input, textarea, select')) field.addEventListener('input', invalidate);
|
| 32 |
+
const examples = {
|
| 33 |
+
space: ['Will SpaceX land an uncrewed Starship on Mars before 2030?', '2029-12-31'],
|
| 34 |
+
energy: ['Will a fusion power plant supply electricity to a public grid before 2035?', '2034-12-31'],
|
| 35 |
+
policy: ['Will the United States introduce a federal carbon tax before 2030?', '2029-12-31'],
|
| 36 |
};
|
| 37 |
+
for (const button of document.querySelectorAll('[data-example]')) button.addEventListener('click', () => {
|
| 38 |
+
const [question, date] = examples[button.dataset.example]; get('forecast-question').value = question; get('resolution-date').value = date; invalidate(); get('forecast-question').focus();
|
| 39 |
});
|
| 40 |
+
function list(id, values) {
|
| 41 |
+
const items = Array.isArray(values) ? values.slice(0, 8) : [];
|
| 42 |
+
get(id).replaceChildren(); get(`${id}-section`).hidden = !items.length;
|
| 43 |
+
for (const value of items) { const li = document.createElement('li'); li.textContent = typeof value === 'string' ? value : JSON.stringify(value); get(id).append(li); }
|
| 44 |
+
}
|
| 45 |
+
function explanation(answer) {
|
| 46 |
+
// Provider text stays text: never interpret its HTML or execute links/scripts.
|
| 47 |
+
const prose = answer.replace(/```(?:vaticinus-forecast|json)\s*[\s\S]*?```/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').trim();
|
| 48 |
+
get('explanation').replaceChildren();
|
| 49 |
+
for (const text of prose.split(/\n\s*\n/).filter(Boolean)) { const p = document.createElement('p'); p.textContent = text; get('explanation').append(p); }
|
| 50 |
+
}
|
| 51 |
get('cancel').addEventListener('click', () => active?.abort());
|
| 52 |
get('forecast-form').addEventListener('submit', async event => {
|
| 53 |
+
event.preventDefault();
|
| 54 |
+
if (active) return;
|
| 55 |
+
invalidate(); const current = revision, controller = new AbortController(); active = controller;
|
| 56 |
+
get('forecast-submit').disabled = true; get('cancel').hidden = false; panel('loading');
|
| 57 |
+
const started = Date.now();
|
| 58 |
+
const update = () => { get('loading-status').textContent = `Generating forecast… ${Math.floor((Date.now() - started) / 1000)}s`; };
|
| 59 |
+
update(); const timer = setInterval(update, 1000);
|
| 60 |
try {
|
| 61 |
const response = await fetch('/api/forecast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
|
| 62 |
+
body: JSON.stringify({ question: get('forecast-question').value, resolutionDate: get('resolution-date').value, evidence: get('evidence').value, apiKey: get('api-key').value.trim(), model: get('model').value }) });
|
|
|
|
| 63 |
const data = await response.json(); if (current !== revision) return;
|
| 64 |
+
if (!response.ok) throw new Error(data.error || 'The request failed. Please try again.');
|
| 65 |
+
const { run, cost } = data;
|
| 66 |
+
if (run.status !== 'issued' || typeof run.probability !== 'number' || !Number.isFinite(run.probability) || run.probability < 0 || run.probability > 1) {
|
| 67 |
+
error(data.message || 'The model didn’t return a valid forecast. Try a more specific question or another model.', cost.calls ? 'The provider may have charged for this attempt. Check your OpenRouter activity.' : 'No model call was made.'); return;
|
| 68 |
+
}
|
| 69 |
+
record = data; get('result-question').textContent = run.question.question;
|
| 70 |
+
get('probability').textContent = percent(run.probability); get('probability-no').textContent = percent(1 - run.probability);
|
| 71 |
+
const name = models.find(m => m.id === run.model)?.name || run.model;
|
| 72 |
+
get('result-meta').textContent = `${name} · Deadline ${run.question.resolution_date} · ${new Date(run.created_at).toLocaleDateString()}`;
|
| 73 |
+
explanation(run.answer); list('assumptions', run.spec?.assumptions); list('cruxes', run.spec?.kill_criteria); panel('result');
|
| 74 |
+
} catch (e) { if (current === revision) error(e.name === 'AbortError' ? 'Request cancelled.' : e.message, 'If processing started, OpenRouter may still charge for it.'); }
|
| 75 |
+
finally { clearInterval(timer); if (active === controller) { active = null; get('cancel').hidden = true; get('forecast-submit').disabled = !models.length; get('output').setAttribute('aria-busy', 'false'); } }
|
| 76 |
+
});
|
| 77 |
+
get('copy-result').addEventListener('click', async () => {
|
| 78 |
+
if (!record) return;
|
| 79 |
+
try { await navigator.clipboard.writeText(`${record.run.question.question}\nYes: ${percent(record.run.probability)} · No: ${percent(1 - record.run.probability)}\n${get('explanation').textContent}\nhttps://vaticinus.com`); get('copy-status').textContent = 'Copied.'; }
|
| 80 |
+
catch { get('copy-status').textContent = 'Clipboard unavailable. Use Download.'; }
|
| 81 |
+
});
|
| 82 |
+
get('download').addEventListener('click', () => {
|
| 83 |
+
if (!record) return;
|
| 84 |
+
const url = URL.createObjectURL(new Blob([JSON.stringify(record, null, 2) + '\n'], { type: 'application/json' }));
|
| 85 |
+
const a = document.createElement('a'); a.href = url; a.download = 'vaticinus-forecast.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 86 |
});
|
| 87 |
+
loadModels();
|
space/index.html
CHANGED
|
@@ -2,75 +2,47 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
-
<meta name="description" content="
|
| 6 |
-
<meta name="theme-color" content="#
|
| 7 |
<link rel="stylesheet" href="/style.css"><script type="module" src="/app.js"></script>
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
-
<a class="skip-link" href="#
|
| 11 |
-
<header class="
|
| 12 |
-
<a class="wordmark" href="https://vaticinus.com" target="_blank" rel="noopener noreferrer">Vaticinus<span>.</span></a>
|
| 13 |
-
<nav aria-label="Main navigation"><a href="https://github.com/vaticinus/vati" target="_blank" rel="noopener noreferrer">Open source</a><a class="site-link" href="https://vaticinus.com" target="_blank" rel="noopener noreferrer">Visit Vaticinus <span aria-hidden="true">↗</span></a></nav>
|
| 14 |
-
</header>
|
| 15 |
<main>
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
<div class="
|
| 22 |
-
<div class="
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<label for="example">Explore a probability model</label><select id="example"><option value="conditional">Two scenarios</option><option value="bayes">Update with evidence</option><option value="normal">Cross a threshold</option><option value="binary">State your estimate</option></select>
|
| 45 |
-
<div class="lab-intro"><h2 id="lab-title">Compare two scenarios</h2><p id="lab-description">Set each scenario's chance and the event's probability within it. Scenario B takes the remaining weight.</p></div>
|
| 46 |
-
<div id="lab-fields"></div>
|
| 47 |
-
<button id="compute" class="primary" type="submit">Calculate <span aria-hidden="true">↗</span></button><button id="reset" type="button" class="quiet reset">Reset example</button>
|
| 48 |
-
<details><summary>Edit the full model</summary><label class="sr-only" for="spec">Assumptions as JSON</label><textarea id="spec" spellcheck="false" rows="12"></textarea></details>
|
| 49 |
-
</form>
|
| 50 |
-
<p class="hint">Synthetic examples. No API key, model calls or forecasting claims. Values describe your assumptions, not measured uncertainty about the estimate.</p>
|
| 51 |
-
</section>
|
| 52 |
-
</div>
|
| 53 |
-
<section class="result-panel" aria-labelledby="result-heading" aria-busy="false">
|
| 54 |
-
<div class="result-top"><h2 id="result-heading">Forecast</h2><span id="result-label" class="result-label">Waiting for input</span></div>
|
| 55 |
-
<div id="empty" class="empty-state"><p class="empty-symbol" aria-hidden="true">P<span>(event)</span></p><h3>Your result appears here.</h3><p>You'll be able to read the assumptions and download the forecast. To try the tools without a key, start with an example.</p><button id="try-example" type="button" class="secondary">Try a free example</button></div>
|
| 56 |
-
<div id="loading" class="loading-state" hidden><div class="loading-line" aria-hidden="true"></div><h3 id="loading-title">Working through your question</h3><p id="loading-status" role="status" aria-live="polite">Waiting for your model.</p><p class="hint">No repeated requests. Closing or cancelling may still incur charges for work already processed.</p></div>
|
| 57 |
-
<div id="result" hidden>
|
| 58 |
-
<p id="probability" role="status" aria-live="polite"></p><p id="question" class="result-question"></p><p id="method" class="result-method"></p>
|
| 59 |
-
<dl id="result-meta" class="result-meta"></dl>
|
| 60 |
-
<section id="assumptions-section" class="result-section" hidden><h3>What this rests on</h3><ul id="assumptions"></ul></section>
|
| 61 |
-
<section id="cruxes-section" class="result-section" hidden><h3>What would change the view</h3><ul id="cruxes"></ul></section>
|
| 62 |
-
<details id="decision-check"><summary>What would make acting worthwhile?</summary><p class="hint">A simplified two-outcome payoff check, before other costs. Inaction has payoff zero. This is not a recommendation.</p><div class="field-row"><div><label for="upside">Gain if YES</label><input id="upside" type="number" value="100" min="0" max="1000000000" step="any"></div><div><label for="downside">Loss if NO</label><input id="downside" type="number" value="100" min="0" max="1000000000" step="any"></div></div><p id="decision-output" aria-live="polite"></p></details>
|
| 63 |
-
<div class="result-actions"><button id="download" type="button" class="secondary">Download record</button><button id="copy-result" type="button" class="quiet">Copy summary</button></div>
|
| 64 |
-
<details id="explanation-section" hidden><summary>Model explanation · not independently verified</summary><pre id="explanation"></pre></details>
|
| 65 |
-
<details><summary>Inspect the saved calculation</summary><pre id="details"></pre></details>
|
| 66 |
-
</div>
|
| 67 |
-
<div id="error-panel" hidden><h3 id="error-heading">No forecast issued</h3><p id="error" role="alert"></p><p id="error-cost" class="hint"></p><button id="download-failure" type="button" class="secondary" hidden>Download attempt</button></div>
|
| 68 |
-
<p id="copy-status" class="hint" role="status"></p>
|
| 69 |
-
</section>
|
| 70 |
</div>
|
|
|
|
|
|
|
| 71 |
</section>
|
| 72 |
-
<section class="about"><div><h2>Working on a question<br>with real stakes?</h2><p>Vaticinus is building forecasting tools for research and investment decisions. If you have a question you'd like us to work on, tell us what you need to decide and when.</p><a class="text-link" href="https://vaticinus.com/#contact" target="_blank" rel="noopener noreferrer">Talk to Vaticinus ↗</a></div><div class="boundaries"><h3>Before you use a forecast</h3><p>Check the evidence yourself. A model reviewer can miss errors, and this system has not demonstrated forecasting superiority or suitability for major decisions.</p><p>The app does not save your key. It uses temporary files to process your question and response, then deletes them when the request finishes. Hugging Face, OpenRouter and the selected provider have their own infrastructure and policies. Do not submit confidential material.</p><a href="https://github.com/vaticinus/vati/blob/main/forecast-stack/docs/SUPERFORECASTING.md" target="_blank" rel="noopener noreferrer">Results and evaluation method ↗</a></div></section>
|
| 73 |
</main>
|
| 74 |
-
<footer><
|
| 75 |
</body>
|
| 76 |
</html>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<meta name="description" content="Forecast an event with Vaticinus. Choose a model, connect OpenRouter, and get a probability with the reasoning behind it.">
|
| 6 |
+
<meta name="theme-color" content="#f4f0e8"><title>Vaticinus | Forecast</title>
|
| 7 |
<link rel="stylesheet" href="/style.css"><script type="module" src="/app.js"></script>
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
+
<a class="skip-link" href="#forecast-question">Skip to question</a>
|
| 11 |
+
<header><a class="wordmark" href="https://vaticinus.com" target="_blank" rel="noopener noreferrer">Vaticinus<span>.</span></a><a href="https://github.com/vaticinus/vati" target="_blank" rel="noopener noreferrer">Source code</a></header>
|
|
|
|
|
|
|
|
|
|
| 12 |
<main>
|
| 13 |
+
<h1>Forecast an event.</h1>
|
| 14 |
+
<p class="intro">Ask a yes-or-no question. Get a probability and the reasoning behind it.</p>
|
| 15 |
+
<form id="forecast-form" method="post">
|
| 16 |
+
<label for="forecast-question">Question</label>
|
| 17 |
+
<textarea id="forecast-question" rows="3" maxlength="1000" required placeholder="Will SpaceX land an uncrewed Starship on Mars before 2030?"></textarea>
|
| 18 |
+
<div class="examples" aria-label="Example questions"><span>Try:</span><button type="button" data-example="space">Spaceflight</button><button type="button" data-example="energy">Energy</button><button type="button" data-example="policy">Policy</button></div>
|
| 19 |
+
<div class="deadline"><label for="resolution-date">Deadline</label><input id="resolution-date" type="date" required></div>
|
| 20 |
+
<details class="context"><summary>Add context or sources <span>(optional)</span></summary><label class="sr-only" for="evidence">Context or sources</label><textarea id="evidence" rows="4" maxlength="12000" placeholder="Paste relevant facts, dates, or excerpts. Links alone are not read."></textarea></details>
|
| 21 |
+
<div class="connection">
|
| 22 |
+
<label for="api-key">OpenRouter key <a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener noreferrer">Get a key</a></label>
|
| 23 |
+
<div class="key-row"><input id="api-key" type="password" autocomplete="off" spellcheck="false" autocapitalize="none" maxlength="512" required placeholder="sk-or-…" aria-describedby="key-help"><button id="forget-key" type="button" class="quiet">Clear</button></div>
|
| 24 |
+
<p id="key-help" class="hint">Used for this request. Not saved by this app.</p>
|
| 25 |
+
<label for="model">Model</label><select id="model" required disabled><option value="">Loading models…</option></select>
|
| 26 |
+
<p id="model-pricing" class="hint" role="status">Loading OpenRouter prices.</p><button id="reload-models" type="button" class="quiet" hidden>Retry</button>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="submit-row"><button id="forecast-submit" type="submit" class="primary" disabled>Forecast</button><button id="cancel" type="button" class="quiet" hidden>Cancel</button></div>
|
| 29 |
+
<p class="hint">Billed to your OpenRouter account. Uses model knowledge and any context you add; no live web search.</p>
|
| 30 |
+
</form>
|
| 31 |
+
<section id="output" aria-label="Forecast result" aria-busy="false" hidden>
|
| 32 |
+
<div id="loading" hidden><p id="loading-status" role="status" aria-live="polite">Generating forecast…</p></div>
|
| 33 |
+
<div id="result" hidden>
|
| 34 |
+
<h2 id="result-question"></h2><div class="odds"><p><strong id="probability"></strong><span>Yes</span></p><p><strong id="probability-no"></strong><span>No</span></p></div>
|
| 35 |
+
<p id="result-meta" class="hint"></p>
|
| 36 |
+
<div id="explanation" class="explanation"></div>
|
| 37 |
+
<section id="assumptions-section" hidden><h3>Assumptions</h3><ul id="assumptions"></ul></section>
|
| 38 |
+
<section id="cruxes-section" hidden><h3>What could change this estimate</h3><ul id="cruxes"></ul></section>
|
| 39 |
+
<div class="result-actions"><button id="copy-result" type="button" class="quiet">Copy forecast</button><button id="download" type="button" class="quiet">Download</button></div>
|
| 40 |
+
<p class="hint">A model estimate, not a verified fact. Check the sources and assumptions.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
+
<div id="error-panel" hidden><h2>Couldn’t complete the forecast</h2><p id="error" role="alert"></p><p id="error-cost" class="hint"></p></div>
|
| 43 |
+
<p id="copy-status" class="hint" role="status"></p>
|
| 44 |
</section>
|
|
|
|
| 45 |
</main>
|
| 46 |
+
<footer><a href="https://vaticinus.com" target="_blank" rel="noopener noreferrer">vaticinus.com</a><details><summary>About this demo</summary><p>Questions and keys are sent over HTTPS through this Space to OpenRouter and your selected model provider. Temporary processing files are deleted after the request. Provider policies also apply.</p><p>Models span budget and premium options, informed by <a href="https://artificialanalysis.ai/leaderboards/models" target="_blank" rel="noopener noreferrer">Artificial Analysis</a>. Those benchmarks do not establish forecasting accuracy. Prices come from OpenRouter.</p></details></footer>
|
| 47 |
</body>
|
| 48 |
</html>
|
space/server.mts
CHANGED
|
@@ -17,10 +17,14 @@ const assets = new Map([
|
|
| 17 |
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
| 18 |
]);
|
| 19 |
const choices = [
|
| 20 |
-
{ id: OPENROUTER_DEFAULT_MODEL, name: 'MiMo 2.6 Flash' },
|
| 21 |
-
{ id: '
|
| 22 |
-
{ id: '
|
|
|
|
|
|
|
|
|
|
| 23 |
];
|
|
|
|
| 24 |
class InputError extends Error {
|
| 25 |
readonly status: number;
|
| 26 |
constructor(message: string, status = 400) { super(message); this.status = status; }
|
|
@@ -48,7 +52,7 @@ function text(value: unknown, label: string, max: number): string {
|
|
| 48 |
/** Personal keys only. Injected transport is for offline acceptance, never a client-selected endpoint. */
|
| 49 |
export function createWorkbenchServer(request: typeof fetch = fetch) {
|
| 50 |
let active = 0;
|
| 51 |
-
let catalog: Promise<Array<{ id: string; name: string; rates: { input: number; output: number }; reasoning: boolean; context: number }>> | undefined;
|
| 52 |
let catalogAt = 0;
|
| 53 |
const models = () => {
|
| 54 |
if (!catalog || Date.now() - catalogAt > 300000) {
|
|
@@ -59,7 +63,7 @@ export function createWorkbenchServer(request: typeof fetch = fetch) {
|
|
| 59 |
const raw = await response.text();
|
| 60 |
if (raw.length > 8000000) throw new Error('Model catalog exceeded its size limit.');
|
| 61 |
const discovered = await Promise.all(choices.map(async choice => {
|
| 62 |
-
try { return { ...choice, ...await openRouterModel(choice.id, async () => new Response(raw, { status: 200 })) }; }
|
| 63 |
catch { return null; }
|
| 64 |
}));
|
| 65 |
return discovered.filter((value): value is NonNullable<typeof value> => value !== null);
|
|
@@ -69,7 +73,7 @@ export function createWorkbenchServer(request: typeof fetch = fetch) {
|
|
| 69 |
};
|
| 70 |
const server = createServer(async (req, res) => {
|
| 71 |
res.setHeader('X-Content-Type-Options', 'nosniff');
|
| 72 |
-
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'
|
| 73 |
res.setHeader('Referrer-Policy', 'no-referrer');
|
| 74 |
res.setHeader('Cache-Control', 'no-store');
|
| 75 |
const send = (status: number, value: unknown) => {
|
|
@@ -96,22 +100,22 @@ export function createWorkbenchServer(request: typeof fetch = fetch) {
|
|
| 96 |
send(200, { result, snapshot: freezeForecastSpec(spec, result) }); return;
|
| 97 |
}
|
| 98 |
const apiKey = text(input.apiKey, 'Your OpenRouter key', 512);
|
| 99 |
-
if (!
|
| 100 |
const q: ForecastQuestion = {
|
| 101 |
id: randomUUID(), question: text(input.question, 'Question', 1000),
|
| 102 |
resolution_date: text(input.resolutionDate, 'Resolution date', 10),
|
| 103 |
-
dated_metric: text(input.resolutionRule, 'Resolution rule', 2000),
|
| 104 |
event_type: 'occurrence', conditions: [],
|
| 105 |
};
|
| 106 |
validateQuestion(q);
|
| 107 |
const now = new Date();
|
| 108 |
if (q.resolution_date <= now.toISOString().slice(0, 10)) throw new InputError('Choose a future resolution date.');
|
| 109 |
-
const evidenceText =
|
| 110 |
if (JSON.stringify(q).includes(apiKey) || evidenceText.includes(apiKey)) throw new InputError('Remove your API key from the question and evidence.');
|
| 111 |
-
const arm = input.arm;
|
| 112 |
if (arm !== 'direct' && arm !== 'harness') throw new InputError('Choose direct or reviewed forecasting.');
|
| 113 |
const maxCost = input.maxCost;
|
| 114 |
-
if (typeof maxCost !== 'number' || !Number.isFinite(maxCost) || maxCost < 0.01 || maxCost > 0.5) throw new InputError('Set a per-forecast ceiling between $0.01 and $0.50.');
|
| 115 |
if (active >= 4) throw new InputError('The shared workbench is busy. No model call was made; try again shortly.', 429);
|
| 116 |
active += 1;
|
| 117 |
let directory: string | undefined;
|
|
@@ -122,6 +126,8 @@ export function createWorkbenchServer(request: typeof fetch = fetch) {
|
|
| 122 |
const model = (await models()).find(value => value.id === input.model);
|
| 123 |
if (!model) throw new InputError('Choose an available model from the list.');
|
| 124 |
if (cancelled.signal.aborted) return;
|
|
|
|
|
|
|
| 125 |
const captured = new Date().toISOString();
|
| 126 |
const source: Evidence = {
|
| 127 |
id: 'user-context', url: 'urn:vaticinus:user-provided-context', title: 'User-provided evidence and assumptions; not independently verified',
|
|
@@ -131,15 +137,21 @@ export function createWorkbenchServer(request: typeof fetch = fetch) {
|
|
| 131 |
directory = await mkdtemp(join(tmpdir(), 'vati-space-'));
|
| 132 |
const lifetime = AbortSignal.timeout(240000);
|
| 133 |
runtime = new MeteredRuntime({
|
| 134 |
-
apiKey, model: model.id, rates: model.rates, reasoning: model.reasoning, maxCost,
|
| 135 |
maxOutputTokens: 8400, timeoutMs: 180000, idleTimeoutMs: 45000, ledger: join(directory, 'budget.json'),
|
| 136 |
fetch: (url, options) => request(url, { ...options, signal: AbortSignal.any([cancelled.signal, lifetime, ...(options?.signal ? [options.signal] : [])]) }),
|
| 137 |
});
|
| 138 |
const run = await runForecast(q, packet([source]), runtime.complete, { model: model.id, arm, asOf: captured });
|
| 139 |
-
const cost = { accounted_usd: runtime.spent, ceiling_usd:
|
| 140 |
unknown_charge_calls: runtime.traces.filter(trace => !trace.usage).length };
|
| 141 |
// Provider diagnostics may include request content. Never reflect the authorization secret.
|
| 142 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
send(200, JSON.parse(safe));
|
| 144 |
} finally {
|
| 145 |
runtime?.close();
|
|
|
|
| 17 |
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
| 18 |
]);
|
| 19 |
const choices = [
|
| 20 |
+
{ id: OPENROUTER_DEFAULT_MODEL, name: 'MiMo 2.6 Flash', tier: 'Budget' },
|
| 21 |
+
{ id: 'xiaomi/mimo-v2.6-pro', name: 'MiMo 2.6 Pro', tier: 'Value' },
|
| 22 |
+
{ id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', tier: 'Budget' },
|
| 23 |
+
{ id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash', tier: 'Mid-range' },
|
| 24 |
+
{ id: 'openai/gpt-6-astra', name: 'GPT-6 Astra', tier: 'Premium' },
|
| 25 |
+
{ id: 'anthropic/claude-fable-5.1', name: 'Claude Fable 5.1', tier: 'Premium' },
|
| 26 |
];
|
| 27 |
+
|
| 28 |
class InputError extends Error {
|
| 29 |
readonly status: number;
|
| 30 |
constructor(message: string, status = 400) { super(message); this.status = status; }
|
|
|
|
| 52 |
/** Personal keys only. Injected transport is for offline acceptance, never a client-selected endpoint. */
|
| 53 |
export function createWorkbenchServer(request: typeof fetch = fetch) {
|
| 54 |
let active = 0;
|
| 55 |
+
let catalog: Promise<Array<{ id: string; name: string; tier: string; rates: { input: number; output: number }; reasoning: boolean; context: number }>> | undefined;
|
| 56 |
let catalogAt = 0;
|
| 57 |
const models = () => {
|
| 58 |
if (!catalog || Date.now() - catalogAt > 300000) {
|
|
|
|
| 63 |
const raw = await response.text();
|
| 64 |
if (raw.length > 8000000) throw new Error('Model catalog exceeded its size limit.');
|
| 65 |
const discovered = await Promise.all(choices.map(async choice => {
|
| 66 |
+
try { return { ...choice, ...await openRouterModel(choice.id, async () => new Response(raw, { status: 200 }), { textOnly: true }) }; }
|
| 67 |
catch { return null; }
|
| 68 |
}));
|
| 69 |
return discovered.filter((value): value is NonNullable<typeof value> => value !== null);
|
|
|
|
| 73 |
};
|
| 74 |
const server = createServer(async (req, res) => {
|
| 75 |
res.setHeader('X-Content-Type-Options', 'nosniff');
|
| 76 |
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'");
|
| 77 |
res.setHeader('Referrer-Policy', 'no-referrer');
|
| 78 |
res.setHeader('Cache-Control', 'no-store');
|
| 79 |
const send = (status: number, value: unknown) => {
|
|
|
|
| 100 |
send(200, { result, snapshot: freezeForecastSpec(spec, result) }); return;
|
| 101 |
}
|
| 102 |
const apiKey = text(input.apiKey, 'Your OpenRouter key', 512);
|
| 103 |
+
if (!/^sk-or-[A-Za-z0-9_-]+$/.test(apiKey)) throw new InputError('Enter a valid OpenRouter API key.');
|
| 104 |
const q: ForecastQuestion = {
|
| 105 |
id: randomUUID(), question: text(input.question, 'Question', 1000),
|
| 106 |
resolution_date: text(input.resolutionDate, 'Resolution date', 10),
|
| 107 |
+
dated_metric: input.resolutionRule ? text(input.resolutionRule, 'Resolution rule', 2000) : 'YES if the event described in the question occurs by 23:59 UTC on the deadline, according to a primary public source. Otherwise NO. Missing or disputed evidence remains unresolved.',
|
| 108 |
event_type: 'occurrence', conditions: [],
|
| 109 |
};
|
| 110 |
validateQuestion(q);
|
| 111 |
const now = new Date();
|
| 112 |
if (q.resolution_date <= now.toISOString().slice(0, 10)) throw new InputError('Choose a future resolution date.');
|
| 113 |
+
const evidenceText = input.evidence === undefined || input.evidence === '' ? 'No additional context supplied. Use model knowledge, state its limits, and do not invent current facts, source URLs, or claim to have searched the web.' : text(input.evidence, 'Context', 12000);
|
| 114 |
if (JSON.stringify(q).includes(apiKey) || evidenceText.includes(apiKey)) throw new InputError('Remove your API key from the question and evidence.');
|
| 115 |
+
const arm = input.arm ?? 'direct';
|
| 116 |
if (arm !== 'direct' && arm !== 'harness') throw new InputError('Choose direct or reviewed forecasting.');
|
| 117 |
const maxCost = input.maxCost;
|
| 118 |
+
if (maxCost !== undefined && (typeof maxCost !== 'number' || !Number.isFinite(maxCost) || maxCost < 0.01 || maxCost > 0.5)) throw new InputError('Set a per-forecast ceiling between $0.01 and $0.50.');
|
| 119 |
if (active >= 4) throw new InputError('The shared workbench is busy. No model call was made; try again shortly.', 429);
|
| 120 |
active += 1;
|
| 121 |
let directory: string | undefined;
|
|
|
|
| 126 |
const model = (await models()).find(value => value.id === input.model);
|
| 127 |
if (!model) throw new InputError('Choose an available model from the list.');
|
| 128 |
if (cancelled.signal.aborted) return;
|
| 129 |
+
// One bounded call by default; size the internal reservation to the selected model.
|
| 130 |
+
const budget = maxCost ?? Math.ceil(((60000 + Buffer.byteLength(evidenceText) + Buffer.byteLength(JSON.stringify(q))) * model.rates.input + 8400 * model.rates.output) / 10000) / 100;
|
| 131 |
const captured = new Date().toISOString();
|
| 132 |
const source: Evidence = {
|
| 133 |
id: 'user-context', url: 'urn:vaticinus:user-provided-context', title: 'User-provided evidence and assumptions; not independently verified',
|
|
|
|
| 137 |
directory = await mkdtemp(join(tmpdir(), 'vati-space-'));
|
| 138 |
const lifetime = AbortSignal.timeout(240000);
|
| 139 |
runtime = new MeteredRuntime({
|
| 140 |
+
apiKey, model: model.id, rates: model.rates, reasoning: model.reasoning, maxCost: budget,
|
| 141 |
maxOutputTokens: 8400, timeoutMs: 180000, idleTimeoutMs: 45000, ledger: join(directory, 'budget.json'),
|
| 142 |
fetch: (url, options) => request(url, { ...options, signal: AbortSignal.any([cancelled.signal, lifetime, ...(options?.signal ? [options.signal] : [])]) }),
|
| 143 |
});
|
| 144 |
const run = await runForecast(q, packet([source]), runtime.complete, { model: model.id, arm, asOf: captured });
|
| 145 |
+
const cost = { accounted_usd: runtime.spent, ceiling_usd: budget, calls: runtime.traces.length,
|
| 146 |
unknown_charge_calls: runtime.traces.filter(trace => !trace.usage).length };
|
| 147 |
// Provider diagnostics may include request content. Never reflect the authorization secret.
|
| 148 |
+
const diagnostic = run.issues.join(' ');
|
| 149 |
+
const message = /HTTP 401|HTTP 403/.test(diagnostic) ? 'OpenRouter rejected this key. Check that it is active and allowed to use the selected model.'
|
| 150 |
+
: /HTTP 402/.test(diagnostic) ? 'This OpenRouter key has no available credits. Add credits or check its spending limit.'
|
| 151 |
+
: /HTTP 429/.test(diagnostic) ? 'OpenRouter is rate-limiting this key. Wait a moment before trying again.'
|
| 152 |
+
: /HTTP 404|No endpoints/.test(diagnostic) ? 'This model is temporarily unavailable on OpenRouter. Choose another model.'
|
| 153 |
+
: undefined;
|
| 154 |
+
const safe = JSON.stringify({ run, cost, message }).split(apiKey).join('[REDACTED]');
|
| 155 |
send(200, JSON.parse(safe));
|
| 156 |
} finally {
|
| 157 |
runtime?.close();
|
space/style.css
CHANGED
|
@@ -1,151 +1,58 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
color-scheme: light;
|
| 3 |
-
--paper: #f4f0e8;
|
| 4 |
-
--surface: #fffdf8;
|
| 5 |
-
--ink: #202522;
|
| 6 |
-
--muted: #62665f;
|
| 7 |
-
--accent: #244d84;
|
| 8 |
-
--line: #d7d4cb;
|
| 9 |
-
--wash: #eceeea;
|
| 10 |
-
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 11 |
-
color: var(--ink);
|
| 12 |
-
background: var(--paper);
|
| 13 |
-
font-synthesis: none;
|
| 14 |
-
}
|
| 15 |
* { box-sizing: border-box; }
|
| 16 |
body { margin: 0; }
|
|
|
|
|
|
|
| 17 |
button, input, select, textarea { font: inherit; }
|
| 18 |
-
a { color: var(--accent); text-underline-offset: 4px; }
|
| 19 |
-
button, a, input, select, textarea, summary { -webkit-tap-highlight-color: transparent; }
|
| 20 |
button, summary { cursor: pointer; }
|
| 21 |
-
button {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
:
|
| 25 |
-
[hidden] { display: none !important; }
|
| 26 |
-
.site-header, main, footer { max-width: 1280px; margin: auto; padding-left: 40px; padding-right: 40px; }
|
| 27 |
-
.site-header { height: 82px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
| 28 |
.wordmark { color: var(--ink); font-size: 28px; font-weight: 750; letter-spacing: -1.5px; text-decoration: none; }
|
| 29 |
.wordmark span { color: var(--accent); }
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
.
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
.hero-art { margin: 0; overflow: hidden; border-radius: 8px; height: 215px; background: #161e25; }
|
| 39 |
-
.hero-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
| 40 |
-
.workspace { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); overflow: hidden; scroll-margin-top: 20px; }
|
| 41 |
-
.workspace-heading { display: flex; align-items: center; justify-content: space-between; padding: 0 26px; border-bottom: 1px solid var(--line); gap: 16px; }
|
| 42 |
-
.tabs { display: flex; gap: 24px; }
|
| 43 |
-
.tabs button { padding: 21px 0 18px; border: 0; border-bottom: 3px solid transparent; border-radius: 0; color: var(--muted); background: transparent; font-size: 14px; font-weight: 600; white-space: nowrap; }
|
| 44 |
-
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); }
|
| 45 |
-
.tabs span { font-weight: 450; font-size: 11px; margin-left: 5px; color: var(--muted); }
|
| 46 |
-
#mode-caption { font-size: 11px; color: var(--muted); text-align: right; }
|
| 47 |
-
.workbench { display: grid; grid-template-columns: 1.08fr .92fr; }
|
| 48 |
-
.input-panel { padding: 30px; min-width: 0; }
|
| 49 |
-
label { display: block; font-size: 12px; font-weight: 650; margin: 0 0 8px; }
|
| 50 |
-
input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); background: #fff; padding: 11px 12px; font-size: 14px; line-height: 1.5; min-width: 0; }
|
| 51 |
-
textarea { resize: vertical; }
|
| 52 |
-
textarea:focus, input:focus, select:focus { border-color: var(--accent); }
|
| 53 |
-
#forecast-question { font-size: 17px; font-weight: 450; line-height: 1.45; }
|
| 54 |
::placeholder { color: var(--muted); opacity: 1; }
|
| 55 |
-
.
|
| 56 |
-
|
| 57 |
-
.
|
| 58 |
-
.
|
| 59 |
-
.
|
| 60 |
-
|
| 61 |
-
.
|
| 62 |
-
|
| 63 |
-
summary {
|
| 64 |
-
.
|
| 65 |
-
.
|
| 66 |
-
.key-row
|
| 67 |
-
.
|
| 68 |
-
.
|
|
|
|
| 69 |
.primary:hover:not(:disabled) { background: #1a3b69; }
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
.
|
| 74 |
-
.
|
| 75 |
-
.
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
.
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
.
|
| 88 |
-
.
|
| 89 |
-
|
| 90 |
-
.empty-symbol { font-size: 76px; line-height: 1; letter-spacing: -6px; color: var(--accent); font-weight: 400; margin: 16px 0 42px; }
|
| 91 |
-
.empty-symbol span { font-size: 28px; letter-spacing: -1px; margin-left: 4px; }
|
| 92 |
-
h3 { font-size: 17px; font-weight: 550; letter-spacing: -.3px; margin: 0 0 10px; }
|
| 93 |
-
.empty-state > p:not(.empty-symbol) { font-size: 14px; color: var(--muted); margin-bottom: 24px; }
|
| 94 |
-
#probability { color: var(--accent); font-size: clamp(64px, 7vw, 96px); letter-spacing: -.075em; line-height: 1; font-weight: 450; font-variant-numeric: tabular-nums; margin: 34px 0 22px; }
|
| 95 |
-
.result-question { font-size: 21px; font-weight: 500; line-height: 1.35; letter-spacing: -.5px; overflow-wrap: anywhere; }
|
| 96 |
-
.result-method { font-size: 12px; color: var(--muted); }
|
| 97 |
-
.result-meta { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; padding: 20px 0; border-top: 1px solid #d0d4cc; border-bottom: 1px solid #d0d4cc; margin: 24px 0; font-size: 11px; }
|
| 98 |
-
.result-meta dt { color: var(--muted); }
|
| 99 |
-
.result-meta dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
| 100 |
-
.result-section { margin: 24px 0; }
|
| 101 |
-
.result-section h3 { font-size: 12px; }
|
| 102 |
-
.result-section ul { padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
| 103 |
-
.result-section li { margin-bottom: 7px; overflow-wrap: anywhere; }
|
| 104 |
-
.result-actions { margin-top: 28px; }
|
| 105 |
-
#decision-output { font-size: 13px; }
|
| 106 |
-
#error-panel { margin-top: 42px; }
|
| 107 |
-
#error { color: #803c2f; font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
| 108 |
-
.loading-state { padding-top: 48px; }
|
| 109 |
-
.loading-state > p { font-size: 13px; color: var(--muted); }
|
| 110 |
-
.loading-line { height: 2px; width: 54px; background: var(--accent); margin-bottom: 30px; animation: breathe 1.5s ease-in-out infinite alternate; }
|
| 111 |
-
@keyframes breathe { to { opacity: .2; } }
|
| 112 |
-
.about { display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 62px 0; }
|
| 113 |
-
.about h2 { font-size: 31px; font-weight: 500; line-height: 1.16; letter-spacing: -1px; margin: 0 0 18px; }
|
| 114 |
-
.about p { font-size: 13px; color: var(--muted); max-width: 430px; }
|
| 115 |
-
.text-link { display: inline-block; margin-top: 12px; font-size: 13px; font-weight: 550; }
|
| 116 |
-
.boundaries h3 { font-size: 13px; margin-top: 4px; }
|
| 117 |
-
.boundaries p, .boundaries a { font-size: 11px; }
|
| 118 |
-
footer { border-top: 1px solid var(--line); padding-top: 22px; padding-bottom: 28px; display: flex; justify-content: space-between; gap: 20px; font-size: 11px; color: var(--muted); }
|
| 119 |
-
.skip-link { position: absolute; top: -100px; left: 20px; padding: 10px; background: var(--surface); z-index: 5; }
|
| 120 |
-
.skip-link:focus { top: 10px; }
|
| 121 |
-
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
| 122 |
-
@media (max-width: 900px) {
|
| 123 |
-
.site-header, main, footer { padding-left: 24px; padding-right: 24px; }
|
| 124 |
-
.hero { gap: 24px; grid-template-columns: 1.45fr .55fr; }
|
| 125 |
-
.hero-art { height: 190px; }
|
| 126 |
-
.input-panel, .result-panel { padding: 22px; }
|
| 127 |
-
#mode-caption { display: none; }
|
| 128 |
-
.about { gap: 40px; }
|
| 129 |
-
}
|
| 130 |
-
@media (max-width: 680px) {
|
| 131 |
-
.site-header, main, footer { padding-left: 18px; padding-right: 18px; }
|
| 132 |
-
.site-header { height: 68px; }
|
| 133 |
-
.wordmark { font-size: 24px; }
|
| 134 |
-
nav { gap: 16px; } nav a { font-size: 11px; } nav a:first-child { display: none; }
|
| 135 |
-
.hero { grid-template-columns: 1fr; padding: 34px 0 28px; gap: 22px; }
|
| 136 |
-
h1 { font-size: clamp(32px, 8.4vw, 46px); }
|
| 137 |
-
.hero-copy { font-size: 14px; max-width: 360px; }
|
| 138 |
-
.hero-art { height: 90px; } .hero-art img { object-position: center 60%; }
|
| 139 |
-
.workspace-heading { padding: 0 18px; } .tabs { gap: 24px; }
|
| 140 |
-
.workbench { grid-template-columns: 1fr; }
|
| 141 |
-
.result-panel { border-left: 0; border-top: 1px solid var(--line); }
|
| 142 |
-
.input-panel, .result-panel { padding: 22px 18px; }
|
| 143 |
-
.field-row { gap: 12px; }
|
| 144 |
-
input, select, textarea { font-size: 16px; }
|
| 145 |
-
.field-row label { font-size: 11px; min-height: 28px; }
|
| 146 |
-
.empty-state { padding-top: 20px; } .empty-symbol { margin-bottom: 24px; }
|
| 147 |
-
.about { grid-template-columns: 1fr; gap: 30px; padding: 42px 0; }
|
| 148 |
-
.about h2 { font-size: 27px; }
|
| 149 |
-
footer { flex-direction: column; gap: 8px; }
|
| 150 |
-
}
|
| 151 |
-
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }
|
|
|
|
| 1 |
+
:root { color-scheme: light; --paper: #f4f0e8; --ink: #202522; --muted: #62665f; --accent: #244d84; --line: #d7d4cb; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: var(--paper); font-synthesis: none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
* { box-sizing: border-box; }
|
| 3 |
body { margin: 0; }
|
| 4 |
+
[hidden] { display: none !important; }
|
| 5 |
+
a { color: var(--accent); text-underline-offset: 3px; }
|
| 6 |
button, input, select, textarea { font: inherit; }
|
|
|
|
|
|
|
| 7 |
button, summary { cursor: pointer; }
|
| 8 |
+
button:disabled { opacity: .5; cursor: not-allowed; }
|
| 9 |
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
| 10 |
+
header { max-width: 1040px; margin: auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
| 11 |
+
header > a:last-child { font-size: 13px; text-decoration: none; }
|
|
|
|
|
|
|
|
|
|
| 12 |
.wordmark { color: var(--ink); font-size: 28px; font-weight: 750; letter-spacing: -1.5px; text-decoration: none; }
|
| 13 |
.wordmark span { color: var(--accent); }
|
| 14 |
+
main { max-width: 680px; padding: 44px 24px 16px; margin: auto; }
|
| 15 |
+
h1 { margin: 0; font-size: clamp(30px, 5vw, 40px); line-height: 1.2; letter-spacing: -1.4px; font-weight: 550; }
|
| 16 |
+
.intro { color: var(--muted); font-size: 15px; line-height: 1.6; margin: 12px 0 30px; }
|
| 17 |
+
label { display: block; font-size: 13px; font-weight: 600; margin: 0 0 8px; }
|
| 18 |
+
label a { float: right; font-weight: 400; font-size: 12px; }
|
| 19 |
+
input, select, textarea { width: 100%; min-width: 0; background: #fffdf8; color: var(--ink); border: 1px solid var(--line); border-radius: 5px; padding: 11px 12px; line-height: 1.5; font-size: 14px; }
|
| 20 |
+
textarea { resize: vertical; display: block; }
|
| 21 |
+
#forecast-question { font-size: 17px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
::placeholder { color: var(--muted); opacity: 1; }
|
| 23 |
+
.examples { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 12px; margin: 11px 0 22px; color: var(--muted); }
|
| 24 |
+
.examples button, .quiet { padding: 4px 0; background: transparent; border: 0; color: var(--accent); font-size: 12px; }
|
| 25 |
+
.examples button:hover, .quiet:hover { text-decoration: underline; text-underline-offset: 3px; }
|
| 26 |
+
.deadline { display: flex; gap: 20px; align-items: center; }
|
| 27 |
+
.deadline label { margin: 0; }
|
| 28 |
+
.deadline input { width: 180px; }
|
| 29 |
+
.context { margin: 22px 0; }
|
| 30 |
+
summary { font-size: 13px; line-height: 1.6; }
|
| 31 |
+
summary span { color: var(--muted); }
|
| 32 |
+
.context textarea { margin-top: 12px; }
|
| 33 |
+
.connection { border-top: 1px solid var(--line); padding-top: 22px; }
|
| 34 |
+
.key-row { display: flex; gap: 12px; align-items: center; }
|
| 35 |
+
.hint { font-size: 12px; line-height: 1.6; color: var(--muted); margin: 8px 0 20px; overflow-wrap: anywhere; }
|
| 36 |
+
.submit-row { display: flex; align-items: center; gap: 20px; }
|
| 37 |
+
.primary { background: var(--accent); color: #fff; border: 0; border-radius: 5px; font-size: 14px; font-weight: 600; padding: 13px 30px; }
|
| 38 |
.primary:hover:not(:disabled) { background: #1a3b69; }
|
| 39 |
+
#output { border-top: 1px solid var(--line); margin-top: 32px; padding-top: 28px; scroll-margin-top: 24px; }
|
| 40 |
+
h2 { font-size: 21px; line-height: 1.5; letter-spacing: -.3px; font-weight: 550; margin: 0 0 18px; overflow-wrap: anywhere; }
|
| 41 |
+
h3 { font-size: 14px; font-weight: 650; margin: 22px 0 10px; }
|
| 42 |
+
.odds { display: flex; gap: 56px; margin: 24px 0; }
|
| 43 |
+
.odds p { display: flex; flex-direction: column; gap: 8px; margin: 0; }
|
| 44 |
+
.odds strong { font-size: 52px; font-weight: 500; line-height: 1; letter-spacing: -2px; font-variant-numeric: tabular-nums; color: var(--accent); }
|
| 45 |
+
.odds p + p strong { color: var(--muted); }
|
| 46 |
+
.odds span { font-size: 13px; }
|
| 47 |
+
.explanation, li, #error { font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
|
| 48 |
+
.explanation p { margin: 0 0 14px; }
|
| 49 |
+
ul { padding-left: 20px; }
|
| 50 |
+
.result-actions { display: flex; gap: 24px; margin: 24px 0 10px; }
|
| 51 |
+
footer { max-width: 632px; margin: 16px auto 36px; padding-top: 18px; border-top: 1px solid var(--line); display: flex; gap: 24px; justify-content: space-between; font-size: 12px; }
|
| 52 |
+
footer > a { white-space: nowrap; }
|
| 53 |
+
footer details { max-width: 390px; }
|
| 54 |
+
footer summary { font-size: 12px; text-align: right; color: var(--muted); }
|
| 55 |
+
footer p { line-height: 1.7; color: var(--muted); }
|
| 56 |
+
.skip-link, .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
| 57 |
+
.skip-link:focus { width: auto; height: auto; clip-path: none; padding: 12px; background: white; z-index: 2; }
|
| 58 |
+
@media (max-width: 700px) { header { padding: 18px 20px; } main { padding: 30px 20px 12px; } footer { margin: 16px 20px 30px; } .intro { margin-bottom: 24px; } input, select, textarea { font-size: 16px; } footer details { max-width: 65%; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|