Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,8 @@
const tlFmtFor = q => (q && q.value_kind === "loss" ? tlFmtBig : tlFmt);

// Colors belong to prompt conditions, never to models or their array order.
// New condition IDs stay neutral until explicitly assigned a color here.
// This is an explicit publication allowlist, not a fallback color registry.
// Shadow/unknown conditions in retained snapshots must never reach the UI.
const TL_CONDITION_START = "2026-10-21";
const TL_CONDITIONS = {
none: { label: "Baseline", color: "#0969da" },
Expand All @@ -529,14 +530,46 @@
};
const tlVariants = (q, horizon, snapshots) => {
const info = Object.fromEntries(((TL && TL.variants) || []).map(v => [v.id, v]));
return Object.entries(q.variants || {}).map(([id, hs]) => ({
id, ...(TL_CONDITIONS[id] || { label: id, color: "var(--ink-soft)" }),
return Object.entries(q.variants || {}).filter(([id]) => Object.hasOwn(TL_CONDITIONS, id)).map(([id, hs]) => ({
id, ...TL_CONDITIONS[id],
vals: (hs[horizon] || []).map((v, i) => snapshots[i].date >= TL_CONDITION_START ? v : null),
models: ((q.variantModels || {})[id] || {})[horizon] || [],
text: (info[id] || {}).text,
})).filter(v => v.vals.some(val => val != null));
};

// Old published snapshots may include a shadow in both condition dots and
// pooled model means. Repair the displayed aggregates from public condition
// answers on affected dates only; leave the immutable snapshot/downloads alone.
function tlPublicQuestion(q) {
const hidden = Object.keys(q.variants || {}).filter(id => !Object.hasOwn(TL_CONDITIONS, id));
if (!hidden.length) return q;
const clean = JSON.parse(JSON.stringify(q));
for (const h of q.horizons) {
for (let i = 0; i < (q.median[h] || []).length; i++) {
if (!hidden.some(id => q.variants[id][h]?.[i] != null)) continue;
for (const model of clean.series) {
const values = Object.keys(TL_CONDITIONS).map(id =>
q.variantModels?.[id]?.[h]?.[i]?.[model.label]).filter(v => v != null);
const mean = values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length * 1000) / 1000 : null;
model.ps[h][i] = mean;
model.draws[h][i] = values.length ? values : null;
model.lo[h][i] = values.length ? Math.min(...values) : null;
model.hi[h][i] = values.length ? Math.max(...values) : null;
}
const values = clean.series.map(s => s.ps[h][i]).filter(v => v != null).sort((a, b) => a - b);
const mid = Math.floor(values.length / 2);
clean.median[h][i] = values.length ? Math.round((values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2) * 1000) / 1000 : null;
clean.rerun[h][i] = null;
}
}
for (const id of hidden) {
delete clean.variants[id];
delete clean.variantModels?.[id];
}
return clean;
}

/* ---------------- time-series chart ---------------- */
// Before Oct 21 the Baseline median is blue until hovered or selected; then
// model answers appear as ink-gray circles. Since Oct 21 each prompt condition
Expand Down Expand Up @@ -871,7 +904,7 @@
}
function TimelinePanelCurrent({ qid = TL_HEADLINE, title = "Probability of an AI catastrophe", sub = "Forecasts over time" } = {}) {
const snapshots = TL.snapshots;
const q = TL.questions.find(x => x.id === qid) || TL.questions[0];
const q = useMemo(() => tlPublicQuestion(TL.questions.find(x => x.id === qid) || TL.questions[0]), [qid]);
// The horizon picker, Graph 2's toggle: opens on 2030 like every other
// horizon toggle (Nick, 2026-08-27), and is what the line, the dots, the
// numbers below and the table all read.
Expand Down
22 changes: 17 additions & 5 deletions redlines/views/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,27 @@ def noise_floor(by_day, days):
return None


def public_timeline_rows(rows):
"""Fail closed: only the published experiment's pooled conditions may plot.

Apply before the current/legacy split: old instruments can contain shadow
calls too. Keep raw rows in downloads, never in displayed aggregates.
"""
allowed = set(load_experiment(PUBLISHED)["pooled"])
return [r for r in rows
if r.get("pooled", True) and r.get("publish", True)
and (r.get("condition") or r.get("variant") or "none") in allowed]


def condition_cells(rows):
"""{(date, question_id): {condition: {horizon: {model: answer, in %}}}}
for dates asked under more than one condition (redlines.experiments;
airo: the five pooled prompts), else {}. A model asked a condition more
than once that day gets the mean. An old row's condition is its prompt
variant (redlines.logs.read_row)."""
by = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(list))))
for r in rows:
v = r.get("condition") or "none"
for r in public_timeline_rows(rows):
v = r.get("condition") or r.get("variant") or "none"
for f in r["forecasts"]:
by[(elicitation_date(r), r["question_id"])][v][f["horizon"]][r["label"]].append(100 * f["probability"])
return {key: {v: {h: {m: round(statistics.mean(ps), 3) for m, ps in ms.items()} for h, ms in hs.items()}
Expand All @@ -158,11 +170,11 @@ def condition_variants(rows):

def condition_legend(variants):
"""The variant dots' labels and hover text: the published experiment's
pooled conditions in its order, then any other the rows carry. `text` is
pooled conditions in its order, never arbitrary IDs from the rows. `text` is
the instruction its variant adds. An unpooled condition (the sixth call)
is never shown."""
config = load_experiment(PUBLISHED)["conditions"]
ids = [c for c, cfg in config.items() if cfg["pooled"]] + [c for c in variants if c not in config]
ids = [c for c, cfg in config.items() if cfg["pooled"] and cfg["publish"]]
return [{"id": c, "text": VARIANTS.get(variants.get(c) or config[c]["variant"])} for c in ids]


Expand Down Expand Up @@ -302,7 +314,7 @@ def build():
# severity legs) re-versions the whole joint call, so the loss rows below
# use only current-version rows; the headline questions, whose wording did
# not change, show their earlier readings as explicitly legacy points.
all_rows = load_runlog(panel_only=False)
all_rows = public_timeline_rows(load_runlog(panel_only=False))
current_rows_all = instrument_rows(all_rows)
legacy_probability_ids = {q["id"] for q in qs if not q.get("cause")}
legacy_rows = [r for r in all_rows
Expand Down
24 changes: 23 additions & 1 deletion tests/test_experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,29 @@ def test_the_legend_is_the_published_experiments_pooled_conditions_in_its_order(
self.assertNotIn("ai-banned", pooled) # the sixth is never shown
self.assertEqual(legend[0]["text"], ru.VARIANTS["none"])
old = condition_legend({"retired": "recent"})
self.assertEqual((old[-1]["id"], old[-1]["text"]), ("retired", ru.VARIANTS["recent"]))
self.assertEqual([e["id"] for e in old], pooled) # unknown IDs fail closed
self.assertEqual([e["id"] for e in condition_legend({"ai-banned": "ai-banned"})], pooled)

def test_shadow_rows_never_enter_current_or_legacy_timeline_aggregates(self):
from redlines.views.timeline import public_timeline_rows, condition_cells
from redlines.runlog import pool
mk = lambda c, version, **kw: {
"condition": c, "instrument_version": version, "label": "A",
"question_id": "catastrophe:ai", "run_date": "2026-10-21",
"elicited_at": "2026-10-21T12:00:00+00:00",
"forecasts": [{"horizon": "2030", "probability": 0.01}], **kw}
for version in ("legacy", "airo-incidents-prospective-v3"):
public = [mk("none", version), mk("independent", version)]
shadows = [mk("ai-banned", version), # even without pooled stamp
mk("recent-shadow-noairo", version),
mk("future-shadow", version),
mk("critical", version, pooled=False),
mk("recent", version, publish=False)]
kept = public_timeline_rows(public + shadows)
self.assertEqual(kept, public)
self.assertEqual(pool(kept)["forecasts"][0]["probability"], 0.01)
self.assertEqual(set(condition_cells(public + shadows)[("2026-10-21", "catastrophe:ai")]),
{"none", "independent"})


class TestPooledRows(unittest.TestCase):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_tooltips.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,28 @@ test('all app tooltip callers use the shared implementation', () => {
assert.ok(!source.includes('position: "absolute"'), file);
}
});

test('timeline rejects shadow/unknown conditions in retained snapshots and aggregates', () => {
const source = fs.readFileSync(path.join(__dirname, '../web/demo/19-live-timeline.jsx'), 'utf8')
.split('/* ---------------- time-series chart ---------------- */')[0];
const context = vm.createContext({ window: { __TIMELINE__: { variants: [{ id: 'ai-banned' }] } } });
vm.runInContext(source, context);
const values = v => ({ '2030': [v] });
const answers = v => ({ '2030': [{ A: v, B: v + 2 }] });
const q = { horizons: ['2030'], median: values(99), rerun: values(null),
variants: { none: values(2), independent: values(4), 'ai-banned': values(99), 'future-shadow': values(88) },
variantModels: { none: answers(1), independent: answers(3), 'ai-banned': answers(99), 'future-shadow': answers(88) },
series: ['A', 'B'].map(label => ({ label, ps: values(99), draws: values([99]), lo: values(99), hi: values(99) })) };
context.q = q;
context.snapshots = [{ date: '2026-10-21' }];
assert.equal(vm.runInContext('tlVariants(q, "2030", snapshots).map(v => v.id).join(",")', context), 'none,independent');
const clean = vm.runInContext('tlPublicQuestion(q)', context);
assert.equal(clean.median['2030'][0], 3); // median of public-only model means 2,4
assert.equal(clean.series[0].ps['2030'][0], 2);
assert.equal(clean.series[1].ps['2030'][0], 4);
assert.equal(Object.keys(clean.variants).join(','), 'none,independent');
assert.equal(Object.keys(clean.variantModels).join(','), 'none,independent');
assert.equal(q.median['2030'][0], 99); // do not mutate the stored snapshot
context.clean = clean;
assert.equal(vm.runInContext('tlPublicQuestion(clean) === clean', context), true);
});
41 changes: 37 additions & 4 deletions web/demo/19-live-timeline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ const tlFmtRatio = r => (r == null || !isFinite(r) ? "—" : "×" + (r >= 10 ? r
const tlFmtFor = q => (q && q.value_kind === "loss" ? tlFmtBig : tlFmt);

// Colors belong to prompt conditions, never to models or their array order.
// New condition IDs stay neutral until explicitly assigned a color here.
// This is an explicit publication allowlist, not a fallback color registry.
// Shadow/unknown conditions in retained snapshots must never reach the UI.
const TL_CONDITION_START = "2026-10-21";
const TL_CONDITIONS = {
none: { label: "Baseline", color: "#0969da" },
Expand All @@ -41,14 +42,46 @@ const TL_CONDITIONS = {
};
const tlVariants = (q, horizon, snapshots) => {
const info = Object.fromEntries(((TL && TL.variants) || []).map(v => [v.id, v]));
return Object.entries(q.variants || {}).map(([id, hs]) => ({
id, ...(TL_CONDITIONS[id] || { label: id, color: "var(--ink-soft)" }),
return Object.entries(q.variants || {}).filter(([id]) => Object.hasOwn(TL_CONDITIONS, id)).map(([id, hs]) => ({
id, ...TL_CONDITIONS[id],
vals: (hs[horizon] || []).map((v, i) => snapshots[i].date >= TL_CONDITION_START ? v : null),
models: ((q.variantModels || {})[id] || {})[horizon] || [],
text: (info[id] || {}).text,
})).filter(v => v.vals.some(val => val != null));
};

// Old published snapshots may include a shadow in both condition dots and
// pooled model means. Repair the displayed aggregates from public condition
// answers on affected dates only; leave the immutable snapshot/downloads alone.
function tlPublicQuestion(q) {
const hidden = Object.keys(q.variants || {}).filter(id => !Object.hasOwn(TL_CONDITIONS, id));
if (!hidden.length) return q;
const clean = JSON.parse(JSON.stringify(q));
for (const h of q.horizons) {
for (let i = 0; i < (q.median[h] || []).length; i++) {
if (!hidden.some(id => q.variants[id][h]?.[i] != null)) continue;
for (const model of clean.series) {
const values = Object.keys(TL_CONDITIONS).map(id =>
q.variantModels?.[id]?.[h]?.[i]?.[model.label]).filter(v => v != null);
const mean = values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length * 1000) / 1000 : null;
model.ps[h][i] = mean;
model.draws[h][i] = values.length ? values : null;
model.lo[h][i] = values.length ? Math.min(...values) : null;
model.hi[h][i] = values.length ? Math.max(...values) : null;
}
const values = clean.series.map(s => s.ps[h][i]).filter(v => v != null).sort((a, b) => a - b);
const mid = Math.floor(values.length / 2);
clean.median[h][i] = values.length ? Math.round((values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2) * 1000) / 1000 : null;
clean.rerun[h][i] = null;
}
}
for (const id of hidden) {
delete clean.variants[id];
delete clean.variantModels?.[id];
}
return clean;
}

/* ---------------- time-series chart ---------------- */
// Before Oct 21 the Baseline median is blue until hovered or selected; then
// model answers appear as ink-gray circles. Since Oct 21 each prompt condition
Expand Down Expand Up @@ -383,7 +416,7 @@ function TimelinePanel(props = {}) {
}
function TimelinePanelCurrent({ qid = TL_HEADLINE, title = "Probability of an AI catastrophe", sub = "Forecasts over time" } = {}) {
const snapshots = TL.snapshots;
const q = TL.questions.find(x => x.id === qid) || TL.questions[0];
const q = useMemo(() => tlPublicQuestion(TL.questions.find(x => x.id === qid) || TL.questions[0]), [qid]);
// The horizon picker, Graph 2's toggle: opens on 2030 like every other
// horizon toggle (Nick, 2026-08-27), and is what the line, the dots, the
// numbers below and the table all read.
Expand Down
Loading