diff --git a/site/conjecture.html b/site/conjecture.html index e4b7e63..03e688d 100644 --- a/site/conjecture.html +++ b/site/conjecture.html @@ -106,6 +106,42 @@ 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 '; } + /** + * 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..eccb3d1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,10 +15,12 @@ import { getLeaderboard, getPublicTransparency, getTargetProgress, + getTargetTaskTree, heartbeatTask, isDevVerified, listAvailableTasks, listOpenTasks, + listTargetContributions, OpError, releaseTask, } from './operations.js'; @@ -322,6 +324,32 @@ 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. +// 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'), { + 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..89f55b7 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,47 +2599,173 @@ 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]; + const total = cnt[0].total; + const contributions = await contributionPage(t.id, limit, offset); + 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 { - 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 } + 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, - created_at: new Date(r.created_at).toISOString(), })), }; } +/** 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))); +} + // --------------------------------------------------------------------------- // public leaderboard // --------------------------------------------------------------------------- diff --git a/test/target-progress.test.ts b/test/target-progress.test.ts index 7fbdb33..41445b4 100644 --- a/test/target-progress.test.ts +++ b/test/target-progress.test.ts @@ -113,6 +113,179 @@ 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('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({