From 136082daca768dd3f76909072bd03d44b4984430 Mon Sep 17 00:00:00 2001 From: barneyjm Date: Fri, 31 Jul 2026 20:18:12 -0400 Subject: [PATCH 1/2] Board search, collapsed topic filters, paged contributions, task details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes to the public surfaces, all from the same complaint: the pages show either too much at once or too little to be true. Search (conjectures.html). The board already loads all 180 conjectures in one /leaderboard payload, so filtering is client-side and instant — no endpoint. Terms are AND-ed across name, slug and tags, and the query is hyphen-split the same way the haystack is: without that, "firstproof-c4" — the likeliest thing anyone pastes — matched nothing at all. Topic filters collapse. 27 tag chips filled an entire phone screen before the first conjecture card, on the page whose job is showing conjectures. They now sit behind one toggle that names the active topic when set, because a filtered board that looks unfiltered is worse than a busy one. Picking a topic collapses it again, and the empty state says which constraint emptied the board and offers a reset. Contribution paging. The feed was capped at LIMIT 10 server-side, which made "Every attempt, logged" false on any conjecture with more (firstproof-c4 has 39). listTargetContributions + GET /conjectures/:slug/contributions page the rest, sharing one row-builder with the embedded head so a paged-in row can never render differently from the ten above it. Ordered by id DESC, not created_at DESC: ids are monotonic, so an offset walk cannot skip or repeat a row when two contributions share a timestamp. Task details. The title on /tasks is now a disclosure button — not a clickable
  • , which would have nested the conjecture link inside a control — opening kind, deliverable, verifier, cap, posted date and short id. The full brief stays out by design; the panel says it arrives at checkout instead of leaving that unexplained. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QQgFEPRY4W74D4eqawQ4A6 --- site/conjecture.html | 100 +++++++++++++++---- site/conjectures.html | 119 +++++++++++++++++++++-- site/tasks.html | 75 ++++++++++++++- src/app.ts | 16 ++++ src/operations.ts | 179 ++++++++++++++++++++++++----------- test/target-progress.test.ts | 84 ++++++++++++++++ 6 files changed, 487 insertions(+), 86 deletions(-) diff --git a/site/conjecture.html b/site/conjecture.html index e4b7e63..e58da14 100644 --- a/site/conjecture.html +++ b/site/conjecture.html @@ -106,6 +106,12 @@ opacity:.85; } .feed .src-code:hover { opacity:1; border-color: var(--red); } .feed .when { margin-left:auto; font-family:'Space Mono',monospace; font-size:.75rem; opacity:.6; white-space:nowrap; } + .feed-more { display:flex; gap:.7rem; align-items:center; margin:.9rem 0 0; flex-wrap:wrap; } + .more-btn { font-family:'Space Mono',monospace; font-size:.72rem; text-transform:uppercase; + padding:.4rem .9rem; border: var(--line); background:#fff; cursor:pointer; color:inherit; } + .more-btn:hover:not(:disabled) { background: var(--yellow); } + .more-btn:disabled { opacity:.55; cursor:default; } + .more-count { font-family:'Space Mono',monospace; font-size:.72rem; opacity:.6; } .board-msg { font-weight:300; } @@ -211,8 +217,35 @@ h += '
    '; h += '
    Every attempt, logged

    Recent contributions.

    '; if (d.recent_contributions && d.recent_contributions.length) { - h += '
      '; - d.recent_contributions.forEach(function (r) { + h += '
        ' + d.recent_contributions.map(feedRow).join('') + '
      '; + // "Every attempt, logged" has to be true: the payload carries only the + // newest 10, so anything beyond that is paged in on demand rather than + // silently dropped. Count from metrics, which is the full total. + if (m.contributions > d.recent_contributions.length) { + h += '
      ' + + '' + + '' + + d.recent_contributions.length + ' of ' + m.contributions + '
      '; + } + } else { + h += '

      No contributions yet — be the first to chip away.

      '; + } + if (d.source_ref) { + // External references open in a new tab so the long URL can't hijack the + // page (and can't overflow the layout as raw text). + h += /^https?:\/\//.test(d.source_ref) + ? '

      ' + + esc(d.source_ref.indexOf('wikipedia.org') !== -1 ? 'Read more on Wikipedia ↗' : 'Source ↗') + '

      ' + : '

      ' + esc(d.source_ref) + '

      '; + } + page.innerHTML = h; + maybeAddVideo(slug); + maybeAddTasks(slug); + wireMore(slug, m.contributions); + } + + /** One feed row. Shared by the embedded first page and every paged-in row. */ + function feedRow(r) { // The feed is a work ledger, and the badge says only what actually // happened. Green "machine-verified" is reserved for a checker-confirmed // result; work a human reviewer accepted says just that; a failed check @@ -253,26 +286,51 @@ ' title="' + esc(r.code.repo) + ' @ ' + esc(r.code.sha) + '">⌘ ' + esc(file) + '@' + esc(r.code.sha.slice(0, 7)) + ''; } - h += '
    • ' + esc(label) + '' + - '' + who + esc(r.summary || '(no summary)') + code + '' + - '' + when(r.created_at) + '
    • '; - }); - h += '
    '; - } else { - h += '

    No contributions yet — be the first to chip away.

    '; - } - if (d.source_ref) { - // External references open in a new tab so the long URL can't hijack the - // page (and can't overflow the layout as raw text). - h += /^https?:\/\//.test(d.source_ref) - ? '

    ' + - esc(d.source_ref.indexOf('wikipedia.org') !== -1 ? 'Read more on Wikipedia ↗' : 'Source ↗') + '

    ' - : '

    ' + esc(d.source_ref) + '

    '; - } + return '
  • ' + esc(label) + '' + + '' + who + esc(r.summary || '(no summary)') + code + '' + + '' + when(r.created_at) + '
  • '; + } - page.innerHTML = h; - maybeAddVideo(slug); - maybeAddTasks(slug); + /** + * "Load more" pages the rest of the feed from /conjectures/:slug/contributions, + * appending under what is already there. Offset is driven by how many rows we + * have rendered, so a contribution submitted mid-browse cannot make the walk + * skip a row — the server orders by id, which is monotonic. + */ + function wireMore(slug, total) { + var btn = document.getElementById('cj-more'); + if (!btn) return; + var feed = document.getElementById('cj-feed'); + var count = document.getElementById('cj-more-count'); + var loading = false; + btn.addEventListener('click', function () { + if (loading) return; + loading = true; + btn.disabled = true; + btn.textContent = 'Loading…'; + var offset = feed.children.length; + fetch('/conjectures/' + encodeURIComponent(slug) + '/contributions?limit=25&offset=' + offset, + { headers: { accept: 'application/json' }, cache: 'no-store' }) + .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(function (p) { + feed.insertAdjacentHTML('beforeend', (p.contributions || []).map(feedRow).join('')); + count.textContent = feed.children.length + ' of ' + (p.total || total); + if (p.has_more) { + btn.disabled = false; + btn.textContent = 'Load more'; + } else { + btn.remove(); // nothing left to ask for + } + loading = false; + }) + .catch(function () { + // Never strand the reader on a dead button: say so and let them retry. + btn.disabled = false; + btn.textContent = 'Retry'; + count.textContent = 'Couldn’t load more just now.'; + loading = false; + }); + }); } // Show the explainer only if /videos/.mp4 exists (HEAD probe avoids a diff --git a/site/conjectures.html b/site/conjectures.html index 4031af0..7bbc50d 100644 --- a/site/conjectures.html +++ b/site/conjectures.html @@ -55,11 +55,30 @@ .cj .tags { display:flex; flex-wrap:wrap; gap:.35rem; margin:.6rem 0 0; padding:0; list-style:none; } .cj .tag { font-family:'Space Mono',monospace; font-size:.62rem; text-transform:uppercase; padding:.1rem .4rem; border:2px solid var(--ink); opacity:.8; } - .board-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin:0 0 .8rem; } + .board-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin:.8rem 0 0; } + .board-filters[hidden] { display:none; } .bf { font-family:'Space Mono',monospace; font-size:.72rem; text-transform:uppercase; padding:.3rem .7rem; border: var(--line); background:#fff; cursor:pointer; } .bf:hover { background: var(--yellow); } .bf[aria-pressed="true"] { background: var(--ink); color: var(--paper); } + /* Search. The board is fully client-side (all conjectures arrive in one + /leaderboard payload), so filtering is instant and needs no endpoint. */ + .board-search { display:flex; gap:.5rem; align-items:center; margin:0 0 .8rem; } + .board-search input { flex:1 1 auto; min-width:0; font-family:'Space Mono',monospace; + font-size:.85rem; padding:.55rem .7rem; border: var(--line); background:#fff; + color:inherit; } + .board-search input::placeholder { color:inherit; opacity:.45; text-transform:none; } + .board-search .clear { font-family:'Space Mono',monospace; font-size:.72rem; + text-transform:uppercase; padding:.3rem .6rem; border: var(--line); + background:#fff; cursor:pointer; } + .board-search .clear:hover { background: var(--red); color:#fff; } + /* Topic filters collapse behind a disclosure: 27 tag chips filled an entire + phone screen before a single conjecture card, which is what this page is + for. Collapsed by default; the toggle names the active topic so a filtered + board is never silently filtered. */ + .bf--toggle[aria-expanded="true"] { background: var(--yellow); } + .bf--toggle .caret { display:inline-block; margin-left:.35rem; } + .bf--toggle[aria-expanded="true"] .caret { transform: rotate(180deg); } .board-controls { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin:0 0 1.4rem; } .board-controls .lbl { font-family:'Space Mono',monospace; font-size:.72rem; text-transform:uppercase; opacity:.7; } .bf-select { font-family:'Space Mono',monospace; font-size:.72rem; text-transform:uppercase; @@ -154,7 +173,11 @@

    The unsolved, chipped away.

    On the board now.

    -
    + +

    Loading the board…

    @@ -308,6 +336,22 @@

    Propose an open problem.

    var controls = document.getElementById('board-controls'); var sortSel = document.getElementById('board-sort'); var activeStatus = null; // null | 'open' | 'settled' + var search = document.getElementById('board-search'); + var qInput = document.getElementById('board-q'); + var clearBtn = document.getElementById('board-clear'); + var topicsBtn = document.getElementById('board-topics'); + var topicsLabel = document.getElementById('board-topics-label'); + var q = ''; // normalized search query + var norm = function (s) { return String(s == null ? '' : s).toLowerCase(); }; + // A conjecture matches if every whitespace-separated term appears somewhere in + // its name, slug, or tags. Term-wise AND (not substring-of-the-whole) so + // "erdos graph" finds the Erdős graph-theory problems rather than nothing. + function matches(c, terms) { + var hay = norm(c.name) + ' ' + norm(c.slug).replace(/-/g, ' ') + ' ' + + (c.tags || []).map(function (t) { return norm(t).replace(/-/g, ' '); }).join(' '); + for (var i = 0; i < terms.length; i++) if (hay.indexOf(terms[i]) === -1) return false; + return true; + } var SORTS = { donated: function (a, b) { return b.compute_cents - a.compute_cents || a.name.localeCompare(b.name); }, contributions: function (a, b) { return b.contributions - a.contributions || a.name.localeCompare(b.name); }, @@ -334,23 +378,54 @@

    Propose an open problem.

    } function renderCards() { + // Split on hyphens as well as spaces: slugs are hyphenated and the haystack + // flattens them, so a pasted "firstproof-c4" has to become ["firstproof","c4"] + // or it matches nothing — the one query a user is most likely to paste. + var terms = q ? q.split(/[\s-]+/).filter(Boolean) : []; var shown = all.filter(function (c) { if (active && (c.tags || []).indexOf(active) === -1) return false; if (activeStatus === 'open' && !OPEN[c.status]) return false; if (activeStatus === 'settled' && OPEN[c.status]) return false; + if (terms.length && !matches(c, terms)) return false; return true; }); shown = shown.slice().sort(SORTS[sortSel.value] || SORTS.donated); - grid.innerHTML = shown.length - ? shown.map(card).join('') - : '

    Nothing on the board matches these filters.

    '; + if (shown.length) { + grid.innerHTML = shown.map(card).join(''); + return; + } + // Say which constraint emptied the board, and offer the way out — a dead + // end that doesn't name its cause reads as "the site is broken". + var why = q + ? 'No conjecture matches “' + esc(q) + '”' + (active || activeStatus ? ' with these filters' : '') + '.' + : 'Nothing on the board matches these filters.'; + grid.innerHTML = '

    ' + why + + '

    '; + var reset = document.getElementById('board-reset'); + if (reset) reset.addEventListener('click', function () { + q = ''; qInput.value = ''; active = null; activeStatus = null; + clearBtn.hidden = true; + controls.querySelectorAll('.bf[data-status]').forEach(function (b) { + b.setAttribute('aria-pressed', String(!b.getAttribute('data-status'))); + }); + syncTopics(); renderFilters(); renderCards(); + }); + } + + /** The collapsed toggle has to carry the active topic, or a filtered board looks empty for no reason. */ + function syncTopics() { + topicsLabel.textContent = active ? active.replace(/-/g, ' ') : 'Topics'; + topicsBtn.setAttribute('aria-pressed', String(!!active)); } function renderFilters() { var counts = {}; all.forEach(function (c) { (c.tags || []).forEach(function (t) { counts[t] = (counts[t] || 0) + 1; }); }); var tags = Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a] || a.localeCompare(b); }); - if (!tags.length) { filters.innerHTML = ''; return; } + // No tags anywhere -> no disclosure at all, rather than a toggle that opens + // an empty box. + topicsBtn.hidden = !tags.length; + if (!tags.length) { filters.innerHTML = ''; filters.hidden = true; return; } filters.innerHTML = '' + tags.map(function (t) { @@ -364,10 +439,40 @@

    Propose an open problem.

    if (!btn) return; active = btn.getAttribute('data-tag') || null; if (window.posthog) posthog.capture('conjecture_board_filtered', { filter_type: 'tag', tag: active }); + // Picking a topic is the end of picking a topic: collapse back so the + // result is on screen instead of below another screenful of chips. + filters.hidden = true; + topicsBtn.setAttribute('aria-expanded', 'false'); + syncTopics(); renderFilters(); renderCards(); }); + topicsBtn.addEventListener('click', function () { + var open = topicsBtn.getAttribute('aria-expanded') === 'true'; + topicsBtn.setAttribute('aria-expanded', String(!open)); + filters.hidden = open; + if (!open && window.posthog) posthog.capture('conjecture_board_topics_opened'); + }); + + // Search is instant — everything is already in memory, so there is nothing to + // debounce against and no request to spare. + qInput.addEventListener('input', function () { + q = qInput.value.trim().toLowerCase(); + clearBtn.hidden = !q; + renderCards(); + }); + qInput.addEventListener('search', function () { // native ✕ in the search field + q = qInput.value.trim().toLowerCase(); + clearBtn.hidden = !q; + renderCards(); + }); + clearBtn.addEventListener('click', function () { + qInput.value = ''; q = ''; clearBtn.hidden = true; + qInput.focus(); + renderCards(); + }); + controls.addEventListener('click', function (ev) { var btn = ev.target.closest('.bf[data-status]'); if (!btn) return; @@ -392,6 +497,8 @@

    Propose an open problem.

    dollars(t.compute_cents) + ' of compute donated'; all = d.conjectures || []; controls.hidden = !all.length; + search.hidden = !all.length; + syncTopics(); renderFilters(); renderCards(); if (!all.length) grid.innerHTML = '

    Nothing on the board yet — propose the first problem.

    '; diff --git a/site/tasks.html b/site/tasks.html index ba4585a..24d03f4 100644 --- a/site/tasks.html +++ b/site/tasks.html @@ -48,7 +48,29 @@ .tag--video { background: var(--blue); color:#fff; border-color: var(--blue); } .tag--math { background:#fff; } .tasks .main { grid-column:1; grid-row:2; min-width:0; } - .tasks .t { font-weight:600; overflow-wrap:anywhere; } + /* The title is the disclosure control. A ' + (t.angle ? '
    ' + esc(t.angle) + '
    ' : '') + '
    ' + 'up to $' + (t.max_cost_cents / 100).toFixed(2) + '' + + detail(t, did) + ''; }); board.innerHTML = h + ''; } + /** + * The expandable per-task panel. Everything here is already public on + * /tasks/available — this only stops it being invisible. The full brief is + * deliberately NOT here: it ships with the task at checkout, and the panel + * says so rather than leaving a reader wondering what they'd be signing up for. + */ + function detail(t, id) { + var rows = [ + ['Kind', kindLabel(t.kind)], + ['Deliverable', String(t.deliverable || '').replace(/_/g, ' ')], + ['Verified by', String(t.verify_via || 'human review').replace(/_/g, ' ')], + ['Budget cap', '$' + (t.max_cost_cents / 100).toFixed(2)], + ['Posted', fmtDate(t.created_at)], + ['Task id', String(t.id || '').slice(0, 8)], + ]; + return ''; + } + + function fmtDate(s) { + var d = new Date(s); + return isNaN(d) ? '—' : d.toISOString().slice(0, 10); + } + + // Delegated: rows are re-rendered on every filter change, so binding per-row + // listeners would leak and go stale. + document.getElementById('board').addEventListener('click', function (e) { + var btn = e.target.closest('.t[aria-controls]'); + if (!btn) return; + var panel = document.getElementById(btn.getAttribute('aria-controls')); + if (!panel) return; + var open = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', String(!open)); + panel.hidden = open; + if (!open && window.posthog) posthog.capture('task_details_opened'); + }); + document.getElementById('filters').addEventListener('click', function (e) { var b = e.target.closest('.chip'); if (!b) return; diff --git a/src/app.ts b/src/app.ts index 660b881..eedc486 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,6 +19,7 @@ import { isDevVerified, listAvailableTasks, listOpenTasks, + listTargetContributions, OpError, releaseTask, } from './operations.js'; @@ -322,6 +323,21 @@ app.get('/conjectures/:slug', (c) => { })(c); }); +// Page the contribution feed a conjecture's progress payload only sends the +// head of. Always JSON — there is no HTML view of a bare page, and the detail +// page appends these rows under the ten it already rendered. Public, like the +// progress payload it extends. +app.get('/conjectures/:slug/contributions', (c) => + handle(async () => { + const page = await listTargetContributions(c.req.param('slug'), { + limit: c.req.query('limit'), + offset: c.req.query('offset'), + }); + if (!page) throw new OpError(404, 'target_not_found', 'Unknown conjecture'); + return page; + })(c), +); + // Minimal embeddable video player for twitter:player cards — the conjecture's // explainer, full-bleed. Only serves when the video exists in R2. app.get('/embed/:slug', async (c) => { diff --git a/src/operations.ts b/src/operations.ts index ee1886b..2b1cc4e 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -2444,26 +2444,36 @@ export interface TargetProgress { state: unknown; // compacted working set (current frontier, next steps) created_at: string; metrics: TargetProgressMetrics; - recent_contributions: { - outcome: string; - summary: string; - /** Unambiguous state of this contribution — see ContributionStatus. */ - status: ContributionStatus; - verdict: string | null; - /** How the verdict was reached (auto_rerun, human_review, …), if verified. */ - verified_via: string | null; - /** The contributor's GitHub handle (public, as on the leaderboard), or null. */ - contributor: string | null; - /** - * For a work-unit contribution, the exact code that produced it, pinned by - * commit SHA — the provenance that makes a result reproducible and - * tamper-evident. Null for LLM/other contributions. - */ - code: { repo: string; sha: string; entrypoint: string } | null; - created_at: string; - }[]; + /** The newest page of the feed; page the rest via listTargetContributions. */ + recent_contributions: TargetContribution[]; +} + +/** One row of a conjecture's public contribution feed. */ +export interface TargetContribution { + outcome: string; + summary: string; + /** Unambiguous state of this contribution — see ContributionStatus. */ + status: ContributionStatus; + verdict: string | null; + /** How the verdict was reached (auto_rerun, human_review, …), if verified. */ + verified_via: string | null; + /** The contributor's GitHub handle (public, as on the leaderboard), or null. */ + contributor: string | null; + /** + * For a work-unit contribution, the exact code that produced it, pinned by + * commit SHA — the provenance that makes a result reproducible and + * tamper-evident. Null for LLM/other contributions. + */ + code: { repo: string; sha: string; entrypoint: string } | null; + created_at: string; } +/** How many contributions ride along in the progress payload itself. */ +const PROGRESS_FEED_PAGE = 10; +/** Paging bounds for GET /conjectures/:slug/contributions. */ +const CONTRIB_PAGE_DEFAULT = 25; +const CONTRIB_PAGE_MAX = 100; + // Only inherently-public kinds are exposed by slug. org_request work (the future // vetted-org path) is never served on the public progress page. const PUBLIC_TARGET_KINDS = ['conjecture', 'research_question']; @@ -2518,9 +2528,47 @@ export async function getTargetProgress(slug: string): Promise { // Each contribution carries its latest verification verdict (if any) so the // public feed can distinguish a machine-verified solution from a mere claim. - const recent = await query<{ + const { rows } = await query<{ outcome: string; summary: string; verdict: string | null; @@ -2551,45 +2599,64 @@ export async function getTargetProgress(slug: string): Promise ({ + outcome: r.outcome, + summary: r.summary, + status: contributionStatus(r.outcome, r.verdict), + verdict: r.verdict, + verified_via: r.verified_via, + contributor: r.contributor, + code: + r.code_repo && r.code_sha && r.code_entrypoint + ? { repo: r.code_repo, sha: r.code_sha, entrypoint: r.code_entrypoint } + : null, + created_at: new Date(r.created_at).toISOString(), + })); +} + +/** + * A page of the public contribution feed for one conjecture, by slug. Same + * ordering (newest first) and same row shape as the feed embedded in + * getTargetProgress, so the page can append without re-fetching what it has. + * `total` is the full count, so a caller knows when to stop asking. Returns + * null for an unknown slug or a non-public kind — identical to + * getTargetProgress, so an unknown conjecture 404s the same way on both. + * + * Ordering is by `c.id DESC`, not `created_at DESC`: ids are monotonic, so a + * paged walk can't repeat or skip a row when two contributions share a + * timestamp — the classic OFFSET-with-ties bug. + */ +export async function listTargetContributions( + slug: string, + // Strings as well as numbers: these come straight off the query string, and + // clampInt is what turns `?limit=abc` / `?offset=-5` into something safe. + opts: { limit?: number | string; offset?: number | string } = {}, +): Promise<{ contributions: TargetContribution[]; total: number; has_more: boolean } | null> { + const limit = clampInt(opts.limit, CONTRIB_PAGE_DEFAULT, 1, CONTRIB_PAGE_MAX); + const offset = clampInt(opts.offset, 0, 0, Number.MAX_SAFE_INTEGER); + const { rows } = await query<{ id: string }>( + `SELECT id FROM targets WHERE slug = $1 AND kind::text = ANY($2::text[])`, + [slug, PUBLIC_TARGET_KINDS], + ); + const t = rows[0]; + if (!t) return null; + const { rows: cnt } = await query<{ total: number }>( + 'SELECT count(*)::int AS total FROM contributions WHERE target_id = $1', [t.id], ); - const mr = m.rows[0]; - return { - slug: t.slug, - name: t.name, - kind: t.kind, - status: t.status, - statement_plain: t.statement_plain, - statement_formal: t.statement_formal, - source_ref: t.source_ref, - significance: t.significance, - tags: t.tags, - state: t.state, - created_at: new Date(t.created_at).toISOString(), - metrics: { - tasks_total: mr.tasks_total, - tasks_open: mr.tasks_open, - tasks_resolved: mr.tasks_resolved, - contributions: mr.contributions, - contributors: mr.contributors, - compute_cents: mr.compute_cents, - last_activity_at: mr.last_activity_at ? new Date(mr.last_activity_at).toISOString() : null, - }, - recent_contributions: recent.rows.map((r) => ({ - outcome: r.outcome, - summary: r.summary, - status: contributionStatus(r.outcome, r.verdict), - verdict: r.verdict, - verified_via: r.verified_via, - contributor: r.contributor, - code: - r.code_repo && r.code_sha && r.code_entrypoint - ? { repo: r.code_repo, sha: r.code_sha, entrypoint: r.code_entrypoint } - : null, - created_at: new Date(r.created_at).toISOString(), - })), - }; + const total = cnt[0].total; + const contributions = await contributionPage(t.id, limit, offset); + return { contributions, total, has_more: offset + contributions.length < total }; +} + +/** Coerce a caller-supplied paging number into range; anything unparseable → fallback. */ +function clampInt(v: unknown, fallback: number, min: number, max: number): number { + const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : Number.NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); } // --------------------------------------------------------------------------- diff --git a/test/target-progress.test.ts b/test/target-progress.test.ts index 7fbdb33..ab36311 100644 --- a/test/target-progress.test.ts +++ b/test/target-progress.test.ts @@ -113,6 +113,90 @@ describe('conjecture progress page', () => { }); }); +describe('paging the contribution feed', () => { + // The progress payload carries only the newest 10. A conjecture with 39 + // contributions (firstproof-c4's real shape) must be able to show the rest. + async function seedContributions(n: number) { + const create = await createTargetVia({ name: 'Paged', slug: 'paged', kind: 'conjecture' }); + const conj: any = await create.json(); + const dev = await createDev('pager'); + await setBudget(dev, 100_000); + for (let i = 0; i < n; i++) { + const task = await createTask(conj.id, { max: 500 }); + await checkoutTask(dev, task); + await submitResult(dev, task, { i }, 1, null, { + outcome: 'progress', + summary: `contribution ${i}`, + }); + } + return conj; + } + + it('embeds the newest 10 and pages the remainder, newest first, without gaps or repeats', async () => { + await seedContributions(23); + + const p: any = await (await req('/conjectures/paged')).json(); + expect(p.recent_contributions).toHaveLength(10); + expect(p.metrics.contributions).toBe(23); + expect(p.recent_contributions[0].summary).toBe('contribution 22'); // newest first + + const seen: string[] = p.recent_contributions.map((r: any) => r.summary); + let offset = 10; + for (;;) { + const page: any = await ( + await req(`/conjectures/paged/contributions?limit=10&offset=${offset}`) + ).json(); + expect(page.total).toBe(23); + seen.push(...page.contributions.map((r: any) => r.summary)); + if (!page.has_more) break; + offset += page.contributions.length; + } + // Every contribution exactly once, in strict newest-first order. + expect(seen).toHaveLength(23); + expect(new Set(seen).size).toBe(23); + expect(seen).toEqual(Array.from({ length: 23 }, (_, i) => `contribution ${22 - i}`)); + }); + + it('serves a paged row identically to the embedded one', async () => { + await seedContributions(11); + const p: any = await (await req('/conjectures/paged')).json(); + const page: any = await ( + await req('/conjectures/paged/contributions?limit=10&offset=0') + ).json(); + // Same shape and same values — "load more" must not render differently. + expect(page.contributions.slice(0, 10)).toEqual(p.recent_contributions); + }); + + it('clamps junk paging input instead of erroring or dumping the table', async () => { + await seedContributions(3); + const bad: any = await ( + await req('/conjectures/paged/contributions?limit=abc&offset=-5') + ).json(); + expect(bad.contributions).toHaveLength(3); // default page, offset floored to 0 + expect(bad.has_more).toBe(false); + + const huge: any = await (await req('/conjectures/paged/contributions?limit=99999')).json(); + expect(huge.contributions).toHaveLength(3); // capped, and only 3 exist + + const past: any = await (await req('/conjectures/paged/contributions?offset=999')).json(); + expect(past.contributions).toEqual([]); + expect(past.total).toBe(3); + expect(past.has_more).toBe(false); + }); + + it('404s for an unknown slug, like the progress payload it extends', async () => { + const res = await req('/conjectures/no-such-thing/contributions'); + expect(res.status).toBe(404); + }); + + it('does not page a non-public target kind', async () => { + const create = await createTargetVia({ name: 'Org', slug: 'org-x', kind: 'org_request' }); + expect(create.status).toBe(200); + const res = await req('/conjectures/org-x/contributions'); + expect(res.status).toBe(404); + }); +}); + describe('contributor attribution + profile', () => { it('attributes contributions to the handle and serves a shareable profile', async () => { const create = await createTargetVia({ From 1ac035eb07d8b957c0bbfadbc83c387f5031b8f3 Mon Sep 17 00:00:00 2001 From: barneyjm Date: Fri, 31 Jul 2026 20:30:08 -0400 Subject: [PATCH 2/2] Tasks as trees: how a conjecture actually decomposed, and who split it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard task is split into subtasks by a volunteer's agent and published only once a peer agent approves the split — but nothing on the site showed that structure. The open pool listed tasks flat, so an impetus task and the five pieces it became looked like six unrelated jobs. getTargetTaskTree + GET /conjectures/:slug/tree return the forest for one conjecture as flat nodes with parent_id; the page draws the tree. The edge is deliberately two hops — tasks.decomposed_from points at the *contribution* that proposed the split, and that contribution points at the parent task — so lineage carries who proposed each split, not just what came of it. Publication requires peer approval, so every edge that exists is by definition approved. Peer-review tasks are excluded. Their decomposed_from is null, so they would render as extra roots and read as impetus tasks nobody ever proposed; review_excluded reports how many were dropped rather than silently shrinking the count. Sibling order gets a title tiebreak: subtasks from one split are inserted in a single transaction and share created_at exactly, so without it Postgres order is unspecified and the tree reshuffles between page loads. Verified stable across repeated fetches. On the page, one caption per split rather than per child ("split out by @x, peer-approved") — three siblings from one proposal said it three times. Collapse state is derived from a set of collapsed ids instead of toggling rows: hiding descendants on collapse and showing them on expand loses the state of any subtree the reader had collapsed separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QQgFEPRY4W74D4eqawQ4A6 --- site/conjecture.html | 161 +++++++++++++++++++++++++++++++++++ src/app.ts | 12 +++ src/operations.ts | 107 +++++++++++++++++++++++ test/target-progress.test.ts | 89 +++++++++++++++++++ 4 files changed, 369 insertions(+) diff --git a/site/conjecture.html b/site/conjecture.html index e58da14..03e688d 100644 --- a/site/conjecture.html +++ b/site/conjecture.html @@ -106,6 +106,36 @@ opacity:.85; } .feed .src-code:hover { opacity:1; border-color: var(--red); } .feed .when { margin-left:auto; font-family:'Space Mono',monospace; font-size:.75rem; opacity:.6; white-space:nowrap; } + /* Decomposition tree. Rows are flat DOM with an indent variable rather than + nested
      s: depth can grow, and nesting would run the deepest rows off a + phone screen. The rail is drawn with a border on the indent spacer. */ + .tree { list-style:none; margin:.8rem 0 0; padding:0; border-top: var(--line); } + .tree li { border-bottom:1px solid #16131033; } + .tree .node { display:flex; align-items:baseline; gap:.5rem; padding:.7rem .2rem; + padding-left: calc(.2rem + var(--d) * 1.1rem); } + .tree .tw { flex:0 0 auto; width:1.1rem; font-family:'Space Mono',monospace; + font-size:.8rem; opacity:.5; background:none; border:0; padding:0; + color:inherit; cursor:pointer; text-align:left; } + .tree .tw[hidden] { visibility:hidden; display:inline-block; } + .tree .tt { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; font-weight:600; + font-size:.95rem; } + .tree .tt .kindb { font-family:'Space Mono',monospace; font-size:.62rem; + text-transform:uppercase; border:2px solid var(--ink); padding:.05rem .35rem; + margin-right:.4rem; font-weight:400; white-space:nowrap; } + .tree .st { font-family:'Space Mono',monospace; font-size:.66rem; text-transform:uppercase; + padding:.1rem .4rem; border:2px solid var(--ink); white-space:nowrap; } + .tree .st--open { background: var(--yellow); } + .tree .st--accepted { background:#1e7d46; color:#fff; border-color:#1e7d46; } + .tree .st--locked, .tree .st--submitted { background:#fff; } + .tree .st--rejected, .tree .st--expired { background: var(--red); color:#fff; } + .tree .cap2 { font-family:'Space Mono',monospace; font-size:.72rem; opacity:.55; + white-space:nowrap; } + /* The edge caption: why this task exists at all. */ + .tree .via { font-weight:300; font-size:.8rem; opacity:.7; margin:0 0 .5rem 0; + padding-left: calc(1.5rem + var(--d) * 1.1rem); } + .tree .via a { color:inherit; } + .tree-note { font-weight:300; font-size:.85rem; opacity:.7; margin:.7rem 0 0; } + .tree-roots { font-family:'Space Mono',monospace; font-size:.72rem; opacity:.6; margin:.6rem 0 0; } .feed-more { display:flex; gap:.7rem; align-items:center; margin:.9rem 0 0; flex-wrap:wrap; } .more-btn { font-family:'Space Mono',monospace; font-size:.72rem; text-transform:uppercase; padding:.4rem .9rem; border: var(--line); background:#fff; cursor:pointer; color:inherit; } @@ -215,6 +245,7 @@ esc(JSON.stringify(d.state, null, 1)) + ''; } h += '
      '; + h += '
      '; h += '
      Every attempt, logged

      Recent contributions.

      '; if (d.recent_contributions && d.recent_contributions.length) { h += '
        ' + d.recent_contributions.map(feedRow).join('') + '
      '; @@ -241,9 +272,139 @@ page.innerHTML = h; maybeAddVideo(slug); maybeAddTasks(slug); + maybeAddTree(slug); wireMore(slug, m.contributions); } + /** + * "How this decomposed" — the task forest. A hard task gets split into + * subtasks by a volunteer's agent and published only after a peer agent + * approves the split, so the tree is the record of how a conjecture was + * actually broken down, and by whom. Rendered only when there is real + * structure to show: a conjecture whose tasks were all seeded directly has + * no edges, and a flat list of roots is what the open-pool section above + * already is. + */ + function maybeAddTree(slug) { + fetch('/conjectures/' + encodeURIComponent(slug) + '/tree', + { headers: { accept: 'application/json' }, cache: 'no-store' }) + .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(function (d) { + var slot = document.getElementById('cj-tree'); + var nodes = (d && d.nodes) || []; + if (!slot || !nodes.length) return; + var hasEdges = nodes.some(function (n) { return n.parent_id; }); + if (!hasEdges) return; // nothing decomposed yet — no tree to explore + + var byId = {}, kids = {}; + nodes.forEach(function (n) { byId[n.id] = n; (kids[n.parent_id || ''] = kids[n.parent_id || ''] || []).push(n); }); + // A parent_id pointing at a task we didn't get (a review task, or one on + // another target) must not vanish the subtree — treat it as a root. + var roots = nodes.filter(function (n) { return !n.parent_id || !byId[n.parent_id]; }); + + var rows = []; + (function walk(list, depth, parentId) { + var lastVia = null; + list.forEach(function (n) { + // Siblings published by one proposal share one caption: repeating + // "split out by @x" on every child of the same split is noise. + var viaId = n.via && n.via.id; + var showVia = viaId && viaId !== lastVia; + lastVia = viaId || null; + if (showVia) rows.push(viaCaption(n.via, depth, parentId)); + rows.push(row(n, depth, (kids[n.id] || []).length)); + walk(kids[n.id] || [], depth + 1, n.id); + }); + })(roots, 0, null); + + var note = ''; + if (d.review_excluded) { + note = '

      ' + d.review_excluded + ' peer-review task' + + (d.review_excluded === 1 ? '' : 's') + ' not shown — they review a split ' + + 'rather than sitting in the tree.

      '; + } + slot.innerHTML = + '
      From impetus to pieces' + + '

      How this decomposed.

      ' + + '
        ' + rows.join('') + '
      ' + + '

      ' + roots.length + ' impetus task' + (roots.length === 1 ? '' : 's') + + ' · ' + nodes.length + ' task' + (nodes.length === 1 ? '' : 's') + ' total

      ' + note; + wireTree(); + }) + .catch(function () { /* the tree is an extra; never break the page for it */ }); + } + + /** + * The caption on a split: one line per proposal, above the group of siblings + * it produced. Publication requires a peer agent to approve the proposal, so + * every edge that exists is by definition peer-approved. + */ + function viaCaption(via, depth, parentId) { + return '
    • ' + + '

      ↳ split out by ' + + (via.proposed_by + ? '@' + esc(via.proposed_by) + '' + : 'a volunteer’s agent') + + ', peer-approved ' + when(via.proposed_at) + '

    • '; + } + + function row(n, depth, childCount) { + var st = String(n.status || ''); + return '
    • ' + + '
      ' + + '' + + '' + esc(String(n.kind || '').replace(/_/g, ' ')) + '' + + esc(n.title) + + (n.contributions ? ' · ' + n.contributions + ' contribution' + + (n.contributions === 1 ? '' : 's') + '' : '') + '' + + '' + esc(st) + '' + + '$' + (n.max_cost_cents / 100).toFixed(2) + '' + + '
    • '; + } + + /** + * Collapse/expand. Visibility is derived from the set of collapsed ids rather + * than toggled row-by-row: a row is visible iff no ancestor is collapsed. The + * naive version — hide descendants on collapse, show them on expand — loses + * the state of any subtree the reader had collapsed separately, re-opening it + * when its parent re-opens. + */ + function wireTree() { + var list = document.getElementById('cj-tree-list'); + if (!list) return; + var rows = Array.prototype.slice.call(list.children); + var parentOf = {}; + rows.forEach(function (li) { + var id = li.getAttribute('data-id'); + if (id) parentOf[id] = li.getAttribute('data-parent') || null; + }); + var collapsed = {}; + + // Walk up from the row's PARENT: a collapsed node stays visible itself and + // hides everything beneath it — including the split captions of its + // descendants, which carry data-parent for exactly this reason. + function hiddenUnder(parentId) { + for (var p = parentId; p; p = parentOf[p]) if (collapsed[p]) return true; + return false; + } + function apply() { + rows.forEach(function (li) { li.hidden = hiddenUnder(li.getAttribute('data-parent')); }); + } + + list.addEventListener('click', function (e) { + var btn = e.target.closest('.tw'); + if (!btn) return; + var id = btn.closest('li').getAttribute('data-id'); + var open = btn.getAttribute('aria-expanded') === 'true'; + if (open) collapsed[id] = 1; else delete collapsed[id]; + btn.setAttribute('aria-expanded', String(!open)); + btn.textContent = open ? '▸' : '▾'; + btn.setAttribute('aria-label', (open ? 'Expand' : 'Collapse') + ' subtasks'); + apply(); + }); + } + /** One feed row. Shared by the embedded first page and every paged-in row. */ function feedRow(r) { // The feed is a work ledger, and the badge says only what actually diff --git a/src/app.ts b/src/app.ts index eedc486..eccb3d1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import { getLeaderboard, getPublicTransparency, getTargetProgress, + getTargetTaskTree, heartbeatTask, isDevVerified, listAvailableTasks, @@ -327,6 +328,17 @@ app.get('/conjectures/:slug', (c) => { // head of. Always JSON — there is no HTML view of a bare page, and the detail // page appends these rows under the ten it already rendered. Public, like the // progress payload it extends. +// The decomposition forest for one conjecture: which task each task was split +// out of, and who proposed the split. Flat nodes with parent_id; the page draws +// the tree. Public, like everything else about a conjecture. +app.get('/conjectures/:slug/tree', (c) => + handle(async () => { + const tree = await getTargetTaskTree(c.req.param('slug')); + if (!tree) throw new OpError(404, 'target_not_found', 'Unknown conjecture'); + return tree; + })(c), +); + app.get('/conjectures/:slug/contributions', (c) => handle(async () => { const page = await listTargetContributions(c.req.param('slug'), { diff --git a/src/operations.ts b/src/operations.ts index 2b1cc4e..89f55b7 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -2652,6 +2652,113 @@ export async function listTargetContributions( return { contributions, total, has_more: offset + contributions.length < total }; } +/** One task in a conjecture's decomposition forest. */ +export interface TaskTreeNode { + id: string; + title: string; + kind: string; + status: string; + max_cost_cents: number; + created_at: string; + /** How many contributions have landed on this task. */ + contributions: number; + /** The task this one was split out of, or null for a root ("impetus") task. */ + parent_id: string | null; + /** + * The decomposition proposal that produced this task — the *reason* it + * exists. Null on roots. Publication only happens after a peer agent + * approves the proposal, so a present `via` implies it was peer-approved. + */ + via: { + /** The proposal contribution. Siblings from one split share it, so the UI can caption the split once. */ + id: string; + proposed_by: string | null; + proposed_at: string; + } | null; +} + +/** + * The decomposition forest for one conjecture: every task, plus the edge back + * to the task it was split out of. Flat, with `parent_id` — the caller builds + * the tree, which keeps the payload shallow and the recursion out of Postgres. + * + * The edge is deliberately two hops. `tasks.decomposed_from` points at the + * *contribution* that proposed the split, and that contribution points at the + * parent task, so the lineage carries who proposed the split as well as what + * came of it. + * + * Auto-minted peer-review tasks are excluded: their `decomposed_from` is null, + * so they would render as extra roots and read as impetus tasks that nobody + * ever proposed. They are process around an edge, not structure in the tree — + * `review_excluded` reports how many were dropped so the count is never a + * silent truncation. + */ +export async function getTargetTaskTree( + slug: string, +): Promise<{ nodes: TaskTreeNode[]; review_excluded: number } | null> { + const { rows } = await query<{ id: string }>( + `SELECT id FROM targets WHERE slug = $1 AND kind::text = ANY($2::text[])`, + [slug, PUBLIC_TARGET_KINDS], + ); + const t = rows[0]; + if (!t) return null; + const { rows: nodes } = await query<{ + id: string; + title: string; + kind: string; + status: string; + max_cost_cents: number; + created_at: string | Date; + contributions: number; + parent_id: string | null; + proposed_by: string | null; + proposed_at: string | Date | null; + via_id: string | null; + is_review: boolean; + }>( + `SELECT t.id, t.title, t.kind::text AS kind, t.status::text AS status, + t.max_cost_cents, t.created_at, + (SELECT count(*)::int FROM contributions c WHERE c.task_id = t.id) AS contributions, + p.task_id AS parent_id, + d.github_handle AS proposed_by, + p.created_at AS proposed_at, + p.id::text AS via_id, + (t.spec ? 'review_of') AS is_review + FROM tasks t + LEFT JOIN contributions p ON p.id = t.decomposed_from + LEFT JOIN devs d ON d.id = p.dev_id + WHERE t.target_id = $1 + -- title breaks the tie: subtasks from one split are inserted in a single + -- transaction and therefore share created_at exactly, so without it the + -- sibling order is unspecified and the tree can reshuffle between loads. + -- (The proposer's own ordering isn't recoverable — nothing records it.) + ORDER BY t.decomposition_depth, t.created_at, t.title`, + [t.id], + ); + const kept = nodes.filter((n) => !n.is_review); + return { + review_excluded: nodes.length - kept.length, + nodes: kept.map((n) => ({ + id: n.id, + title: n.title, + kind: n.kind, + status: n.status, + max_cost_cents: Number(n.max_cost_cents), + created_at: new Date(n.created_at).toISOString(), + contributions: n.contributions, + parent_id: n.parent_id, + via: + n.proposed_at && n.via_id + ? { + id: n.via_id, + proposed_by: n.proposed_by, + proposed_at: new Date(n.proposed_at).toISOString(), + } + : null, + })), + }; +} + /** Coerce a caller-supplied paging number into range; anything unparseable → fallback. */ function clampInt(v: unknown, fallback: number, min: number, max: number): number { const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : Number.NaN; diff --git a/test/target-progress.test.ts b/test/target-progress.test.ts index ab36311..41445b4 100644 --- a/test/target-progress.test.ts +++ b/test/target-progress.test.ts @@ -197,6 +197,95 @@ describe('paging the contribution feed', () => { }); }); +describe('decomposition tree', () => { + it('reports each task’s parent and the split that produced it, roots first', async () => { + const create = await createTargetVia({ name: 'Tree', slug: 'tree', kind: 'conjecture' }); + const conj: any = await create.json(); + const dev = await createDev('splitter'); + await setBudget(dev, 100_000); + + // An impetus task, split by a proposal that a peer approves. + const root = await createTask(conj.id, { max: 800 }); + await checkoutTask(dev, root); + const sub: any = await submitResult( + dev, + root, + { + decomposition: { + subtasks: [ + { title: 'Half one', prompt: 'do the first half', max_cost_cents: 400 }, + { title: 'Half two', prompt: 'do the second half', max_cost_cents: 400 }, + ], + }, + }, + 20, + null, + { outcome: 'decomposition', summary: 'too big for one run — splitting into two' }, + ); + // Approve the split via the auto-minted review task, which is what + // publishes the children. + expect(sub.review_task_id).toBeDefined(); + const reviewer = await createDev('approver'); + await setBudget(reviewer, 100_000); + await checkoutTask(reviewer, sub.review_task_id); + await submitResult(reviewer, sub.review_task_id, { approve: true }, 5, null); + + const res = await req('/conjectures/tree/tree'); + expect(res.status).toBe(200); + const t: any = await res.json(); + const byTitle: any = Object.fromEntries(t.nodes.map((n: any) => [n.title, n])); + const children = t.nodes.filter((n: any) => n.parent_id === root); + expect(children).toHaveLength(2); + expect(children.map((c: any) => c.title).sort()).toEqual(['Half one', 'Half two']); + // The edge names who proposed the split — the "why this task exists". + expect(byTitle['Half one'].via).toMatchObject({ proposed_by: 'splitter' }); + // The impetus task is a root, and carries the decomposition contribution. + const rootNode = t.nodes.find((n: any) => n.id === root); + expect(rootNode.parent_id).toBeNull(); + expect(rootNode.via).toBeNull(); + expect(rootNode.contributions).toBeGreaterThan(0); + // Roots sort before their children (ordered by decomposition depth). + expect(t.nodes.findIndex((n: any) => n.id === root)).toBeLessThan( + t.nodes.findIndex((n: any) => n.title === 'Half one'), + ); + }); + + it('keeps peer-review tasks out of the tree, and says how many it dropped', async () => { + const create = await createTargetVia({ name: 'Tree2', slug: 'tree2', kind: 'conjecture' }); + const conj: any = await create.json(); + const dev = await createDev('splitter2'); + await setBudget(dev, 100_000); + const root = await createTask(conj.id, { max: 800 }); + await checkoutTask(dev, root); + await submitResult( + dev, + root, + { decomposition: { subtasks: [{ title: 'Piece', prompt: 'p', max_cost_cents: 400 }] } }, + 20, + null, + { outcome: 'decomposition', summary: 'split' }, + ); + const t: any = await (await req('/conjectures/tree2/tree')).json(); + // A review task exists, but it has no decomposed_from and would otherwise + // render as a second impetus task nobody proposed. + expect(t.review_excluded).toBe(1); + expect(t.nodes.every((n: any) => !/^Review /.test(n.title))).toBe(true); + }); + + it('404s for an unknown slug and for a non-public kind', async () => { + expect((await req('/conjectures/nope/tree')).status).toBe(404); + await createTargetVia({ name: 'Org', slug: 'org-t', kind: 'org_request' }); + expect((await req('/conjectures/org-t/tree')).status).toBe(404); + }); + + it('returns an empty forest for a conjecture with no tasks', async () => { + await createTargetVia({ name: 'Bare', slug: 'bare', kind: 'conjecture' }); + const t: any = await (await req('/conjectures/bare/tree')).json(); + expect(t.nodes).toEqual([]); + expect(t.review_excluded).toBe(0); + }); +}); + describe('contributor attribution + profile', () => { it('attributes contributions to the handle and serves a shareable profile', async () => { const create = await createTargetVia({