vaticinus commited on
Commit
b814c28
·
verified ·
1 Parent(s): a875dec

Simplify Vaticinus to one forecast form and six OpenRouter models

Browse files

Remove 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 CHANGED
@@ -1,36 +1,28 @@
1
  ---
2
- title: Vaticinus Forecast Workbench
3
  colorFrom: blue
4
  colorTo: gray
5
  sdk: docker
6
  app_port: 7860
7
  license: mit
8
- short_description: Forecast with your model or try the free scenario tools.
9
  ---
10
 
11
- # Vaticinus forecast workbench
12
 
13
- Bring a question and the evidence you have. The AI workflow uses your own OpenRouter key; the scenario tools work without one.
14
 
15
- [Open the workbench](https://vaticinus-forecast-stack.hf.space) · [Vaticinus](https://vaticinus.com) · [Source code](https://github.com/vaticinus/vati/tree/main/forecast-stack)
16
 
17
- ## Make a forecast
18
 
19
- Enter a future deadline and the rule that will settle the question. Paste your evidence, including source dates and URLs where you have them. The app does not browse or verify those sources.
20
 
21
- Choose a model and a spending ceiling between $0.01 and $0.50 per attempt. Direct mode uses one model call. Reviewed mode can use up to four, including a correction. Current OpenRouter prices determine the token-price caps; there is no automatic retry or hosted-key fallback. Extra review has not been shown to improve forecasting accuracy reliably.
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. It sends the key and your input over HTTPS to this Space, which calls OpenRouter. Temporary question and response traces are deleted when the request finishes. Hugging Face, OpenRouter and the selected model provider have their own infrastructure and policies. Do not submit confidential material. A separately limited OpenRouter key is sensible for a public demo.
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 runs as the non-root `node` user on port 7860. It needs no GPU. Hugging Face currently requires PRO to host this Docker Space on CPU Basic; the owner enabled that subscription separately.
44
 
45
- The [evaluation guide](https://github.com/vaticinus/vati/blob/main/forecast-stack/docs/SUPERFORECASTING.md) explains the current evidence. This workbench has not demonstrated superforecaster performance or suitability for major decisions.
 
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": "79b4d3653937b1aa131b3e1fe9c0576c71d7b6ae24f833b6748de342949f05fd",
25
- "bytes": 2914
26
  },
27
  {
28
  "path": "forecast-core/LICENSE",
@@ -36,8 +36,8 @@
36
  },
37
  {
38
  "path": "forecast-core/src/lib/coherence.ts",
39
- "sha256": "fa0a12b344c044e1815b9db7b9a7189698c6cad976f56c3041f9abe8ea276e2a",
40
- "bytes": 25464
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": "a375def6d1e21f8e12cba0ffea7d79676c72367ae7fc45e803c5caec1f9c2786",
85
- "bytes": 13859
86
  },
87
  {
88
  "path": "forecast-core/src/lib/workflow.ts",
@@ -91,23 +91,23 @@
91
  },
92
  {
93
  "path": "space/app.js",
94
- "sha256": "98ca06b2b460390eb1ce95e78cee47147e8e9995aed0e9c5c5c774e95081934f",
95
- "bytes": 16580
96
  },
97
  {
98
  "path": "space/index.html",
99
- "sha256": "9310cfa4a338044a229633609a8f92dbf1ccafc13fe7285243bfecfa773ade0a",
100
- "bytes": 10994
101
  },
102
  {
103
  "path": "space/server.mts",
104
- "sha256": "81398fab749cb79f162c88d763fa31c14a480a749b13a78cc26536693f27c1ca",
105
- "bytes": 9512
106
  },
107
  {
108
  "path": "space/style.css",
109
- "sha256": "dc75fb699293d180184436011593db67aef4c563b0f2559de6e7eecab0d28f08",
110
- "bytes": 10562
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 input = Number(item.pricing?.prompt) * 1e6, output = Number(item.pricing?.completion) * 1e6;
 
47
  if (!finite(input) || !finite(output)) throw new RuntimeError('Model prices are unavailable', 'configuration');
48
- // This executor meters text tokens only; do not silently accept per-request/tool/image fees.
49
- for (const [name, amount] of Object.entries(item.pricing ?? {})) {
50
- if (!['prompt', 'completion', 'input_cache_read', 'input_cache_write', 'discount'].includes(name) && Number(amount) > 0)
51
- throw new RuntimeError(`Unsupported additional model charge: ${name}`, 'configuration');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = value => new Intl.NumberFormat('en', { style: 'percent', maximumFractionDigits: 1 }).format(value);
3
- const dollars = value => `$${Number(value).toFixed(4)}`;
4
- const deadline = days => { const date = new Date(); date.setUTCDate(date.getUTCDate() + days); return date.toISOString().slice(0, 10); };
5
- const templates = {
6
- conditional: {
7
- title: 'Compare two scenarios', description: "Set each scenario's chance and the event's probability within it. Scenario B takes the remaining weight.",
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 payoff() {
74
- const gain = Number(get('upside').value), loss = Number(get('downside').value);
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 = availableModels.find(item => item.id === get('model').value);
145
- get('model-pricing').textContent = model ? `Current token caps: $${model.rates.input}/M input · $${model.rates.output}/M output. Uncertain charges stay reserved. Set an account limit in OpenRouter too.` : 'No supported model available. The free scenario lab still works.';
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(); if (!response.ok) throw new Error(data.error || 'Model discovery unavailable.');
152
- availableModels = data.models; get('model').replaceChildren();
153
- for (const model of availableModels) { const option = document.createElement('option'); option.value = model.id; option.textContent = model.name; get('model').append(option); }
154
- if (!availableModels.length) throw new Error('No supported model is currently available.');
155
  get('model').disabled = false; get('forecast-submit').disabled = false; pricing();
156
- } catch (error) { get('model-pricing').textContent = `${error.message} You can still use the free scenario lab.`; get('reload-models').hidden = false; }
157
  }
158
- get('reload-models').addEventListener('click', loadModels); get('model').addEventListener('change', pricing);
 
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 = deadline(1);
162
  for (const field of get('forecast-form').querySelectorAll('input, textarea, select')) field.addEventListener('input', invalidate);
163
- const starters = {
164
- launch: ['Will our product reach 1,000 paying customers by the deadline?', 'YES if the billing system records at least 1,000 active paying customers at 23:59 UTC on the resolution date. Exclude free trials and cancelled accounts.'],
165
- policy: ['Will the central bank announce a lower policy rate by the deadline?', 'Name the central bank, current comparison rate and official release. Specify whether any cut by the deadline or only a particular meeting counts.'],
166
- research: ['Will the research milestone pass external validation by the deadline?', 'Name the milestone, independent evaluator and published acceptance criteria. Specify how delays or unavailable results resolve.'],
167
  };
168
- for (const button of document.querySelectorAll('[data-prompt]')) button.addEventListener('click', () => {
169
- const [question, rule] = starters[button.dataset.prompt]; get('forecast-question').value = question; get('resolution-rule').value = rule; get('resolution-date').value = deadline(90); invalidate(); get('evidence').focus();
170
  });
 
 
 
 
 
 
 
 
 
 
 
171
  get('cancel').addEventListener('click', () => active?.abort());
172
  get('forecast-form').addEventListener('submit', async event => {
173
- event.preventDefault(); invalidate(); const current = revision, controller = new AbortController(); active = controller;
174
- const started = Date.now(); let timer;
175
- get('forecast-submit').disabled = true; get('cancel').hidden = false; panels('loading');
176
- const arm = get('forecast-arm').value;
177
- const status = () => { get('loading-status').textContent = `${Math.floor((Date.now() - started) / 1000)}s elapsed. ${arm === 'harness' ? 'Generating and reviewing with your selected model.' : 'Waiting for one bounded model response.'}`; };
178
- status(); timer = setInterval(status, 1000);
 
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, resolutionRule: get('resolution-rule').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 || 'Forecast request failed.');
185
- record = data; const { run, cost } = data;
186
- const costText = `${dollars(cost.accounted_usd)} accounted across ${cost.calls} model call(s). ${cost.unknown_charge_calls ? 'Some charges are uncertain and remain reserved.' : 'Provider usage received.'}`;
187
- if (run.status !== 'issued' || typeof run.probability !== 'number') { showError(run.issues.join('\n') || `This attempt was ${run.status}. No forecast probability was substituted.`, costText); return; }
188
- showResult(run.probability, run.question.question, 'A model estimate from your supplied context. Check the evidence and assumptions before using it.', arm === 'harness' ? 'AI estimate · model-reviewed' : 'AI estimate · direct');
189
- metadata([['Settles', run.question.resolution_date], ['Model', run.model], ['Accounted cost', dollars(cost.accounted_usd)], ['Evidence', 'User-supplied; not independently verified']]);
190
- list('assumptions', 'assumptions-section', run.spec?.assumptions);
191
- list('cruxes', 'cruxes-section', run.spec?.kill_criteria);
192
- get('explanation').textContent = run.answer; get('explanation-section').hidden = false;
193
- get('details').textContent = JSON.stringify({ spec: run.spec, resolution_rule: run.question.dated_metric, cost }, null, 2);
194
- } catch (error) {
195
- if (current === revision) showError(error.name === 'AbortError' ? 'Request stopped. Work already processed may still be charged. Check your OpenRouter activity before running again.' : error.message, 'If the connection failed after processing began, the final charge may be unknown. No automatic retry was made.');
196
- } finally {
197
- clearInterval(timer);
198
- if (active === controller) { active = null; get('cancel').hidden = true; get('forecast-submit').disabled = availableModels.length === 0; document.querySelector('.result-panel').setAttribute('aria-busy', 'false'); }
199
- }
 
 
 
 
 
 
200
  });
201
- resetLab(); loadModels();
 
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="Ask a sharper question. Build a forecast with your own model, explore its assumptions, and take the record with you.">
6
- <meta name="theme-color" content="#F4F0E8"><title>Vaticinus | Forecast workbench</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="#workspace">Skip to workbench</a>
11
- <header class="site-header">
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
- <section class="hero" aria-labelledby="page-title">
17
- <div><p class="eyebrow">THE OPEN FORECAST WORKBENCH</p><h1 id="page-title">What does your<br>next decision hinge on?</h1><p class="hero-copy">Bring a question and the evidence you have. Use your own model, or try the free scenario tools.</p></div>
18
- <figure class="hero-art"><img src="https://vaticinus.com/images/vaticinus-harness-dark.webp" alt="Layered dark surfaces crossed by blue signal lines" width="640" height="420" fetchpriority="high"></figure>
19
- </section>
20
- <section id="workspace" class="workspace" aria-label="Forecast workspace">
21
- <div class="workspace-heading"><div class="tabs" role="tablist" aria-label="Workbench mode"><button id="forecast-tab" role="tab" aria-selected="true" aria-controls="forecast-panel">AI forecast</button><button id="lab-tab" role="tab" aria-selected="false" aria-controls="lab-panel" tabindex="-1">Scenario lab <span>Free</span></button></div><p id="mode-caption">Uses your OpenRouter key and credits.</p></div>
22
- <div class="workbench">
23
- <div class="input-panel">
24
- <section id="forecast-panel" role="tabpanel" aria-labelledby="forecast-tab">
25
- <form id="forecast-form" method="post">
26
- <label for="forecast-question">The question</label><textarea id="forecast-question" rows="2" maxlength="1000" required placeholder="Will our product reach 1,000 paying customers by the deadline?"></textarea>
27
- <div class="prompts" aria-label="Question starters"><button type="button" data-prompt="launch">Product launch</button><button type="button" data-prompt="policy">Policy decision</button><button type="button" data-prompt="research">Research milestone</button></div>
28
- <div class="field-row"><div><label for="resolution-date">When does it settle?</label><input id="resolution-date" type="date" required></div><div><label for="forecast-arm">How much review?</label><select id="forecast-arm"><option value="direct">Direct · one model call</option><option value="harness">Reviewed · up to four calls</option></select></div></div>
29
- <label for="resolution-rule">What exactly counts as YES?</label><textarea id="resolution-rule" rows="2" maxlength="2000" required placeholder="Name the observable outcome, its source, and the deadline. Missing data is not automatically NO."></textarea>
30
- <label for="evidence">What do you know so far?</label><textarea id="evidence" rows="5" maxlength="12000" required placeholder="Paste the evidence, source URLs and dates, base rates, and constraints. Separate observations from assumptions."></textarea>
31
- <p class="hint">This workbench uses the context you provide. It does not browse the web or independently verify sources.</p>
32
- <details class="connection" open><summary>Your OpenRouter connection</summary>
33
- <label for="api-key">API key</label><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" class="quiet" type="button">Clear</button></div>
34
- <p id="key-help" class="hint">Not saved in this browser. Sent over HTTPS to this Space and OpenRouter for the request. <a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener noreferrer">Get a limited key ↗</a></p>
35
- <div class="field-row"><div><label for="model">Model</label><select id="model" required disabled><option value="">Loading available models…</option></select></div><div><label for="max-cost">Spending ceiling / forecast</label><select id="max-cost"><option value="0.05">$0.05</option><option value="0.10" selected>$0.10</option><option value="0.25">$0.25</option><option value="0.50">$0.50</option></select></div></div>
36
- <p id="model-pricing" class="hint" role="status">Checking current OpenRouter prices.</p><button id="reload-models" type="button" class="quiet" hidden>Retry model discovery</button>
37
- </details>
38
- <div class="submit-row"><button id="forecast-submit" class="primary" type="submit" disabled>Make a forecast <span aria-hidden="true">↗</span></button><button id="cancel" class="secondary" type="button" hidden>Stop request</button></div>
39
- <p class="hint">Uses your OpenRouter credits. No hosted-key fallback or automatic retries. A ceiling is not a quote; extra review is not proven more accurate.</p>
40
- </form>
41
- </section>
42
- <section id="lab-panel" role="tabpanel" aria-labelledby="lab-tab" hidden>
43
- <form id="model-form">
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><span>Vaticinus · Forecasting tools</span><a href="https://github.com/vaticinus/vati/tree/main/forecast-stack" target="_blank" rel="noopener noreferrer">MIT-licensed source ↗</a></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: 'deepseek/deepseek-v4.1-flash', name: 'DeepSeek 4.1 Flash' },
22
- { id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
 
 
 
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' https://vaticinus.com; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action '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 (!apiKey.startsWith('sk-or-')) throw new InputError('Use an OpenRouter API key. No hosted key is available.');
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 = text(input.evidence, 'Evidence or assumptions', 12000);
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: maxCost, calls: runtime.traces.length,
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 safe = JSON.stringify({ run, cost }).split(apiKey).join('[REDACTED]');
 
 
 
 
 
 
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 { transition: background .16s ease, transform .16s ease; }
22
- button:active:not(:disabled) { transform: translateY(1px); }
23
- button:disabled { opacity: .55; cursor: not-allowed; }
24
- :focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
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
- nav { display: flex; align-items: center; gap: 28px; }
31
- nav a { font-size: 13px; text-decoration: none; color: var(--ink); }
32
- .site-link { border-bottom: 1px solid var(--ink); padding-bottom: 4px; }
33
- .site-link span { margin-left: 10px; }
34
- .hero { display: grid; grid-template-columns: 1.35fr .65fr; align-items: center; gap: 48px; padding: 54px 0 44px; }
35
- .eyebrow { font-size: 10px; font-weight: 700; letter-spacing: .14em; color: var(--accent); margin: 0 0 18px; }
36
- h1 { margin: 0; font-size: clamp(34px, 4.3vw, 58px); line-height: 1.07; font-weight: 550; letter-spacing: -.055em; }
37
- .hero-copy { font-size: 16px; line-height: 1.6; max-width: 490px; color: var(--muted); margin: 20px 0 0; }
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
- .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 20px 0; }
56
- #resolution-rule { margin-bottom: 20px; }
57
- .prompts { display: flex; gap: 7px; flex-wrap: wrap; margin: 10px 0 22px; }
58
- .prompts button { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 6px; padding: 6px 9px; font-size: 11px; }
59
- .prompts button:hover { border-color: var(--accent); color: var(--accent); }
60
- p { line-height: 1.65; }
61
- .hint { font-size: 11px; color: var(--muted); line-height: 1.65; margin: 8px 0 16px; overflow-wrap: anywhere; }
62
- .connection { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 16px 0 4px; margin: 22px 0; }
63
- summary { font-size: 12px; font-weight: 600; color: var(--accent); line-height: 1.6; }
64
- .connection summary { color: var(--ink); margin-bottom: 16px; }
65
- .key-row { display: flex; gap: 10px; align-items: center; }
66
- .key-row input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
67
- .primary, .secondary { border-radius: 6px; padding: 12px 17px; font-size: 13px; font-weight: 600; white-space: nowrap; }
68
- .primary { background: var(--accent); color: #fff; border: 1px solid var(--accent); }
 
69
  .primary:hover:not(:disabled) { background: #1a3b69; }
70
- .primary span { margin-left: 20px; }
71
- .secondary { border: 1px solid var(--line); color: var(--ink); background: var(--surface); }
72
- .secondary:hover { border-color: var(--ink); }
73
- .quiet { border: 0; background: transparent; color: var(--accent); font-size: 12px; padding: 8px 0; white-space: nowrap; }
74
- .submit-row, .result-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
75
- .reset { margin-left: 16px; }
76
- details { margin-top: 20px; }
77
- details textarea { margin-top: 12px; }
78
- #spec, pre { font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; }
79
- pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 400px; overflow-y: auto; }
80
- .lab-intro { margin: 28px 0 24px; }
81
- .lab-intro h2 { font-size: 24px; line-height: 1.2; letter-spacing: -.7px; font-weight: 500; }
82
- .lab-intro p { color: var(--muted); font-size: 13px; }
83
- #lab-fields { display: grid; gap: 20px; margin-bottom: 25px; }
84
- #lab-fields input { font-variant-numeric: tabular-nums; font-size: 19px; }
85
- .result-panel { background: var(--wash); border-left: 1px solid var(--line); padding: 30px; min-width: 0; }
86
- .result-top { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: center; }
87
- .result-top h2 { font-size: 12px; margin: 0; font-weight: 600; }
88
- .result-label { font-size: 10px; color: var(--muted); }
89
- .empty-state { padding: 42px 0 30px; max-width: 340px; }
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%; } }