diff --git a/captcha/.gitignore b/captcha/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/captcha/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/captcha/README.md b/captcha/README.md new file mode 100644 index 00000000..426310c6 --- /dev/null +++ b/captcha/README.md @@ -0,0 +1,87 @@ +# Quantus Captcha + +A proof-of-work captcha whose work is a **real Quantus mining share**. +No tracking, no image puzzles, no data labeling — the visitor's device does +~1 second of Poseidon2 hashing over the current block header, and the site +host earns any block the aggregate share stream happens to find. + +Components: + +- **Pool service** (`quantus-miner/crates/pool-service`) — connects to a + `quantus-node` as an external miner, hands out low-difficulty share + challenges with disjoint nonce ranges, verifies solves, mints single-use + tokens, submits network-difficulty shares as blocks. +- **WASM solver** (`quantus-miner/crates/solver-wasm`) — raw C-ABI WASM + module (no wasm-bindgen), built with plain `cargo build`. +- **Widget** (`widget/`) — drop-in JS: checkbox UI, Web Worker solver, + hidden token input, `quan-captcha-solved` event. +- **Demo** (`demo/`) — a spam-protected form. + +## Quick start (standalone demo, no node needed) + +```sh +# 1. Build the WASM solver into dist/ +./scripts/build-solver.sh + +# 2. Run the pool in standalone mode, serving this directory +cd ../../quantus-miner +cargo run -p pool-service -- --serve-dir ../quantus-apps/captcha + +# 3. Open the demo +open http://127.0.0.1:8787/demo/ +``` + +## Against a real node + +```sh +quantus-node --dev # external-miner QUIC endpoint on :9833 +cargo run -p pool-service -- \ + --node-addr 127.0.0.1:9833 \ + --share-difficulty 2000 \ + --site-secret "$(openssl rand -hex 16)" \ + --serve-dir ../quantus-apps/captcha +``` + +## Integrating on a website + +```html +
+ +
+ +
+ +``` + +Server-side, redeem the submitted `quan-captcha-token` exactly once +(mirrors the reCAPTCHA/Turnstile siteverify shape): + +```sh +curl -X POST https://pool.example.com/siteverify \ + -H 'content-type: application/json' \ + -d '{"secret": "", "response": ""}' +# -> {"success": true, "challenge_ts": 1751600000} +``` + +## API + +| Endpoint | Caller | Purpose | +|---|---|---| +| `POST /api/session` | widget | issue challenge: header, disjoint nonce range, share target | +| `POST /api/share` | widget | verify solved nonce, mint single-use token | +| `POST /siteverify` | site backend | redeem token (secret + response) | +| `GET /api/stats` | anyone | pool counters | + +## Threat model, honestly + +- This is a **rate limiter, not sybil resistance**: it prices requests in + compute, it does not identify humans. A GPU farm pays less per share than + a phone; keep the share difficulty in "annoying to bots, invisible to + humans" territory and layer account/balance gates for high-value actions. +- Sessions are single-use, expire in 120 s, and shares are only valid inside + the session's assigned nonce range over the session's header snapshot — + no precomputation, no replay, no share theft. +- Tokens are single-use and expire in 300 s. +- Work runs only on explicit user action (Coinhive's fatal mistake was + ambient page-load mining without consent — see the plan doc in + `../debate-tree/PLAN.md`). diff --git a/captcha/demo/index.html b/captcha/demo/index.html new file mode 100644 index 00000000..d1115f6c --- /dev/null +++ b/captcha/demo/index.html @@ -0,0 +1,90 @@ + + + + + + Quantus Captcha — demo + + + +

Quantus Captcha demo

+

+ The checkbox below does ~1 second of real Poseidon2 proof-of-work over the + current Quantus block header. The work is a genuine mining share: if it + ever meets full network difficulty, the pool submits it as a block. + No tracking, no image puzzles, no data labeling. +

+ +
+
+ + + +
+
+
+ + +
+
+
+ +

+ After solving, the widget puts a single-use token in the form. A real + backend would redeem it server-side via POST /siteverify + with its secret. This demo calls it from the page for illustration only — + never expose your secret in production. +

+ + + + + diff --git a/captcha/scripts/build-solver.sh b/captcha/scripts/build-solver.sh new file mode 100755 index 00000000..c8d96ede --- /dev/null +++ b/captcha/scripts/build-solver.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Builds the WASM solver from quantus-miner and copies it into dist/. +set -euo pipefail + +CAPTCHA_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MINER_DIR="${QUANTUS_MINER_DIR:-$CAPTCHA_DIR/../../quantus-miner}" + +echo "Building solver-wasm from $MINER_DIR" +(cd "$MINER_DIR" && CARGO_TARGET_DIR=target cargo build -p solver-wasm --target wasm32-unknown-unknown --release) + +mkdir -p "$CAPTCHA_DIR/dist" +cp "$MINER_DIR/target/wasm32-unknown-unknown/release/solver_wasm.wasm" "$CAPTCHA_DIR/dist/" +echo "Wrote $CAPTCHA_DIR/dist/solver_wasm.wasm ($(wc -c < "$CAPTCHA_DIR/dist/solver_wasm.wasm") bytes)" diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js new file mode 100644 index 00000000..3273a93a --- /dev/null +++ b/captcha/widget/quan-captcha.js @@ -0,0 +1,179 @@ +// Quantus Captcha widget. +// +// Usage: +//
+// +// +// On solve, a hidden input named "quan-captcha-token" is added to the widget's +// enclosing form (or the widget element itself), and a "quan-captcha-solved" +// CustomEvent (detail: { token }) is dispatched on the widget element. +// The site backend then redeems the token: POST {endpoint}/siteverify +// with JSON {"secret": "...", "response": ""}. + +(function () { + "use strict"; + + const WIDGET_CLASS = "quan-captcha"; + + const STYLE = ` + .quan-captcha-box { + display: inline-flex; align-items: center; gap: 10px; + border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 14px; + font: 14px/1.4 system-ui, -apple-system, sans-serif; color: #1f2937; + background: #fff; min-width: 260px; user-select: none; + } + .quan-captcha-check { + width: 22px; height: 22px; border: 2px solid #98a2b3; border-radius: 5px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; flex: none; background: #fff; transition: border-color .15s; + } + .quan-captcha-box[data-state="idle"] .quan-captcha-check:hover { border-color: #2563eb; } + .quan-captcha-box[data-state="solving"] .quan-captcha-check { + border-color: #2563eb; border-top-color: transparent; border-radius: 50%; + animation: quan-captcha-spin .8s linear infinite; + } + .quan-captcha-box[data-state="solved"] .quan-captcha-check { + border-color: #16a34a; background: #16a34a; color: #fff; cursor: default; + } + .quan-captcha-box[data-state="error"] .quan-captcha-check { border-color: #dc2626; } + .quan-captcha-label { flex: 1; } + .quan-captcha-sub { display: block; font-size: 11px; color: #667085; margin-top: 1px; } + @keyframes quan-captcha-spin { to { transform: rotate(360deg); } } + `; + + function injectStyle() { + if (document.getElementById("quan-captcha-style")) return; + const style = document.createElement("style"); + style.id = "quan-captcha-style"; + style.textContent = STYLE; + document.head.appendChild(style); + } + + function formatHashrate(h) { + if (h >= 1e6) return (h / 1e6).toFixed(1) + " MH/s"; + if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s"; + return h.toFixed(0) + " H/s"; + } + + function initWidget(el) { + if (el.dataset.quanCaptchaInit) return; + el.dataset.quanCaptchaInit = "1"; + + const endpoint = (el.dataset.endpoint || "").replace(/\/$/, ""); + const workerUrl = el.dataset.workerUrl || endpoint + "/widget/solver-worker.js"; + const wasmUrl = el.dataset.wasmUrl || endpoint + "/dist/solver_wasm.wasm"; + + const box = document.createElement("div"); + box.className = "quan-captcha-box"; + box.dataset.state = "idle"; + box.innerHTML = + '' + + 'I\'m not a spammer' + + 'Quantus proof-of-work · no tracking, no puzzles' + + ""; + el.appendChild(box); + + const check = box.querySelector(".quan-captcha-check"); + const sub = box.querySelector(".quan-captcha-sub"); + let worker = null; + const startedAt = { t: 0 }; + + function setState(state, subText) { + box.dataset.state = state; + check.setAttribute("aria-checked", state === "solved" ? "true" : "false"); + // Own the mark in both directions so error/retry states never keep a + // stale checkmark from a previous solved transition. + check.textContent = state === "solved" ? "\u2713" : ""; + if (subText) sub.textContent = subText; + } + + function fail(message) { + if (worker) { worker.terminate(); worker = null; } + setState("error", message + " — click to retry"); + } + + async function start() { + if (box.dataset.state === "solving" || box.dataset.state === "solved") return; + setState("solving", "requesting challenge…"); + try { + const res = await fetch(endpoint + "/api/session", { method: "POST" }); + if (!res.ok) throw new Error("challenge unavailable (" + res.status + ")"); + const session = await res.json(); + + setState("solving", "computing (~" + session.expected_hashes + " hashes)…"); + startedAt.t = performance.now(); + + worker = new Worker(workerUrl); + worker.onerror = () => fail("solver failed to load"); + worker.onmessage = async (e) => { + const msg = e.data; + if (msg.type === "progress") { + const secs = (performance.now() - startedAt.t) / 1000; + sub.textContent = "computing… " + formatHashrate(msg.hashes / Math.max(secs, 0.001)); + } else if (msg.type === "error") { + fail(msg.message); + } else if (msg.type === "found") { + worker.terminate(); + worker = null; + try { + const shareRes = await fetch(endpoint + "/api/share", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: session.session_id, nonce: msg.nonce }), + }); + const share = await shareRes.json(); + if (!share.success) throw new Error(share.error || "share rejected"); + + // Do all fallible tail work BEFORE showing the solved state, so + // a throw here lands in fail() without leaving solved visuals. + const form = el.closest("form") || el; + let input = form.querySelector('input[name="quan-captcha-token"]'); + if (!input) { + input = document.createElement("input"); + input.type = "hidden"; + input.name = "quan-captcha-token"; + form.appendChild(input); + } + input.value = share.token; + + const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); + setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + + (share.block_found ? " — BLOCK FOUND!" : "")); + + el.dispatchEvent(new CustomEvent("quan-captcha-solved", { + bubbles: true, + detail: { token: share.token, blockFound: !!share.block_found }, + })); + } catch (err) { + fail(String(err.message || err)); + } + } + }; + worker.postMessage({ + wasmUrl: wasmUrl, + headerHash: session.header_hash, + nonceStart: session.nonce_start, + shareTarget: session.share_target, + }); + } catch (err) { + fail(String(err.message || err)); + } + } + + check.addEventListener("click", start); + check.addEventListener("keydown", (e) => { + if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(); } + }); + } + + function initAll() { + injectStyle(); + document.querySelectorAll("." + WIDGET_CLASS).forEach(initWidget); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAll); + } else { + initAll(); + } +})(); diff --git a/captcha/widget/solver-worker.js b/captcha/widget/solver-worker.js new file mode 100644 index 00000000..ebe9c1a2 --- /dev/null +++ b/captcha/widget/solver-worker.js @@ -0,0 +1,55 @@ +// Web Worker: loads the WASM solver and grinds nonces in chunks, reporting +// progress so the widget can animate. Terminated by the main thread when done. + +const CHUNK_ITERS = 2048; + +// I/O buffer layout inside the WASM module (see solver-wasm/src/lib.rs) +const HEADER_OFF = 0; +const NONCE_OFF = 32; +const TARGET_OFF = 96; +const IO_SIZE = 224; + +function hexToBytes(hex, length) { + const clean = hex.replace(/^0x/, "").padStart(length * 2, "0"); + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes) { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +self.onmessage = async (e) => { + const { wasmUrl, headerHash, nonceStart, shareTarget } = e.data; + try { + const response = await fetch(wasmUrl); + if (!response.ok) throw new Error(`failed to fetch solver wasm: ${response.status}`); + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + const { io_ptr, io_len, solve, memory } = instance.exports; + + if (io_len() !== IO_SIZE) throw new Error("solver wasm I/O layout mismatch"); + const ioBase = io_ptr(); + const io = () => new Uint8Array(memory.buffer, ioBase, IO_SIZE); + + io().set(hexToBytes(headerHash, 32), HEADER_OFF); + io().set(hexToBytes(nonceStart, 64), NONCE_OFF); + io().set(hexToBytes(shareTarget, 64), TARGET_OFF); + + let hashes = 0; + for (;;) { + const found = solve(CHUNK_ITERS); + hashes += CHUNK_ITERS; + if (found === 1) { + const nonce = bytesToHex(io().slice(NONCE_OFF, NONCE_OFF + 64)); + self.postMessage({ type: "found", nonce, hashes }); + return; + } + self.postMessage({ type: "progress", hashes }); + } + } catch (err) { + self.postMessage({ type: "error", message: String(err && err.message ? err.message : err) }); + } +}; diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md new file mode 100644 index 00000000..1414d10a --- /dev/null +++ b/debate-tree/PLAN.md @@ -0,0 +1,264 @@ +# Debate Tree — Project Plan + +AI-moderated structured debate for the Quantus ecosystem, spam-protected by +Quantus-native primitives (mining-share proof-of-work and QUAN balance gates). + +**Runnable prototype:** `spike/` — see [`spike/README.md`](spike/README.md). + +This plan covers three deliverables across two repos: + +| # | Component | Location | What it is | +|---|-----------|----------|------------| +| 1 | **Share Pool** (pool middleman) | `quantus-miner/pool-service` | A pool-like service that turns captcha solves into real mining shares and pays hosts | +| 2 | **Captcha Gadget** | `quantus-apps/captcha` | Embeddable widget + verify API — a drop-in Turnstile/reCAPTCHA replacement | +| 3 | **Debate Tree** | `quantus-apps/debate-tree` | The debate webapp: tree UI, AI steelman moderator, chain-gated writes | + +Dependency direction: `debate-tree` → `captcha` → `pool-service` → `quantus-node`. +Each layer is independently useful; the captcha is a product in its own right. + +--- + +## 1. Product vision + +**Debate Tree** is a structured-debate platform: a question at the root, +candidate answers below it, pros/cons under each answer, and responses to +those, recursively. (Prior art: Kialo — the tree format is proven. Our +differentiators are the AI moderator and the chain-native spam economics.) + +**The AI is a moderator on the write path, not a participant:** + +- **Deduplication** — before a new node is accepted, check whether the point + already exists in the tree; near-duplicates are redirected to the existing + node ("upvote / extend this instead?"). The spike does this in the steelman + prompt (sibling list + LLM judgment); production will add embedding similarity + for borderline cases. +- **Relevance** — each claim must engage its reply target (parent claim or the + debate question). Off-target drafts are steelmanned but blocked from publish; + the author can negotiate to re-aim. +- **Disentangle + steelman loop** — a contributor drafts a post; the AI first + splits bundled points into separate claims (one tree node = one claim), then + offers a succinct steelman of each. The contributor picks a claim, revises or + accepts (capped at ~3 rounds per claim). Only AI-generated versions are + publishable — there is no "publish my raw original" path; the contributor's + control is exercised through accept/revise, not through bypassing the + moderator. The original text stays attached (visible on click) so the + author's own words are never lost, but the tree node itself is always a + steelman the author approved. +- **The AI never rewrites imported/seeded content.** Seeded arguments cite + their sources verbatim. + +**Spam / cost protection** (also bounds our LLM spend): + +- **Reading**: free, no account, indexable. The tree is the growth asset. +- **Creating a question**: requires a signed challenge from a wallet holding + ≥ N QUAN (threshold configurable per space). Capital-at-stake, nothing + locked or slashed. +- **Posting answers/pros/cons + starting a steelman session**: requires a + proof-of-work share via the captcha gadget. Rate limiter, not identity. +- App-layer rate limits and per-user/global LLM spend caps on top. + +**Governance tie-in**: Quantus currently has no community governance lane +(the runtime's referenda are tech-collective-only) and QIPs have no +discussion venue. Debate Tree is the deliberation layer; on-chain referenda +remain the decision layer. Tree conclusions link to referenda; content +itself stays off-chain (optionally hash-anchored per published node). + +--- + +## 2. Component 1 — Share Pool (`quantus-miner/pool-service`) + +A new crate alongside `miner-service`, reusing `pow-core` hashing and the +`quantus-miner-api` types. + +**Concept**: the node's external-miner protocol already broadcasts +`NewJob { header_hash, difficulty }` and accepts `JobResult`. The pool +service sits between a node and thousands of weak browser solvers: + +``` +quantus-node ──NewJob──► pool-service ──job + nonce-range + share-target──► browser solvers +quantus-node ◄──block── pool-service ◄──────────share (nonce)──────────── browser solvers +``` + +- Holds the current job from an upstream node (QUIC, existing protocol). +- Issues **captcha sessions**: `{ header_hash, disjoint nonce range, share + target (≪ network difficulty), expiry }`. The nonce range is the session + binding — a returned nonce identifies which session earned it. Freshness + is free: shares are only valid against the current block template. +- Verifies submitted shares (one hash) and issues a single-use + **share token** consumed by the captcha verify API. +- If a share also meets full network difficulty → submit as a real block; + reward accrues to the pool operator's account. +- Tracks per-host share counts for **pro-rata (PPLNS-style) payouts** to + registered captcha hosts. Self-hosters can point their share stream at a + community pool or run solo. + +**Economics honesty** (goes in the README, not just here): expected revenue +per captcha is (client work ÷ network hashrate) × emission rate — dust once +the chain has real hashrate. Early-chain revenue is real; long-term the +honest pitch is *non-wasteful* PoW (work secures the network instead of +being burned, unlike Friendly Captcha / Anubis) plus dust. **Pay the host, +never the solver** — paying solvers pays people to spam. + +**Deliverables**: +- [x] `pool-service` crate: upstream QUIC client, session issuance API, + share verification, share-token store, block submission +- [x] `siteverify` endpoint (see Component 2 — same service, site-facing) +- [ ] Host registration + payout ledger (payouts can be manual at first) +- [ ] Metrics (reuse `metrics` crate patterns), Docker image +- [x] Integration test against `quantus-node --dev` (manual e2e: browser + solved real shares against a dev node's block headers, 2026-07-04) + +## 3. Component 2 — Captcha Gadget (`quantus-apps/captcha`) + +A drop-in, privacy-first captcha. Positioning: Turnstile's UX without +Cloudflare, Friendly Captcha / Anubis mechanics but the work is real mining. +No puzzles, no tracking, no data labeling. Coinhive's captcha proved the UX; +Coinhive's death defines our guardrails: + +- Work only on explicit user action (form submit), never ambient page-load. +- Bounded and disclosed: "~1s of computation supports this site." +- Open source, first-party-servable loader (no single CDN domain to blocklist). + +**Pieces**: +- `solver/` — Rust → WASM build of `pow-core` hashing (lives here or under + `quantus-miner/web-miner`, which already has a Vite+WebGPU scaffold; + decide when wiring the build). WebGPU fast path, WASM fallback. +- `widget/` — TS embed: `
` + + ~3 kB loader. Renders checkbox → fetches session from pool-service → + solves → posts share → emits share token into the form. +- Server-side verify: site backend calls `POST /siteverify {token, secret}` + on pool-service (mirrors reCAPTCHA/Turnstile API shape for trivial migration). +- `demo/` — demo page + abuse-cost calculator. + +**Deliverables**: +- [x] WASM solver package (`quantus-miner/crates/solver-wasm`, raw C ABI, + ~40 kB; measured ≈120 kH/s in-browser on an M-series laptop) + — WebGPU fast path still open +- [x] Embed widget + loader, Turnstile-compatible verify API +- [x] Docs: integration guide, threat model (rate-limiter not sybil-proof; + native-GPU attacker pays less per share than a phone — tune share + target accordingly), Coinhive-lessons disclosure +- [x] Demo site (`demo/`, served by pool-service `--serve-dir`) + +## 4. Component 3 — Debate Tree webapp (`quantus-apps/debate-tree`) + +**Stack** (spike): vanilla HTML/JS frontend + Node `server.mjs`, PGlite +(in-process Postgres), LLM via Anthropic / OpenAI / OpenRouter (stub offline +fallback). Production framework TBD; Postgres + pgvector for embeddings. +Wallet auth via ML-DSA signature verification — +`quantus_sdk`'s Rust bridge and `rust-transaction-parser` are references; +server-side verification can link the same Rust crates. + +**Data model** (implemented in `spike/schema.sql` — pure adjacency tree, +plain Postgres so it ports from the spike's PGlite to hosted PG verbatim): +- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds `config` + jsonb: question threshold N QUAN, share target, model tier. +- `node` — id, space, `parent_id` (null = direct answer to the question), + kind (`answer | pro | con`), `published_text` (author-approved steelman = + the node), `original_text` (verbatim, always attached), `transcript` jsonb + (negotiation provenance, inlined — no separate session table needed for + durable state), `content_hash` (optional on-chain anchoring), `embedding` + (dedup search). Walked with a recursive CTE / adjacency list; no `ltree`, + no `status`/redirect, no `node_edge` — dupes are simply not inserted. +- `vote` — (node, account, value ±1), one row per account per node. +- **Dropped for now**: `gate_proof` (consumed share-token / balance-attestation + ledger + replay guard) lands with the captcha write-path integration. + Ephemeral steelman-negotiation state lives in server memory, not the DB. + +Spike DB is **PGlite** (Postgres compiled to WASM, in-process, persisted to +`spike/pgdata*/`): zero external server, same SQL as prod. Use +`PGDATA_DIR=pgdata-demo` for the seeded djb tree; default `pgdata/` is for +local experiments. `pgvector` is an optional add-on; when absent, `embedding` +falls back to jsonb and embedding dedup is disabled. Reset by deleting the +directory while the server is stopped. Run instructions: `spike/README.md`. + +**Write path**: +1. Client requests action → backend issues nonce challenge. +2. Question: wallet signs `{nonce, action, timestamp}`; backend verifies + signature + balance ≥ N via node RPC / `quantus_subsquid`. + Answer/pro/con: captcha share token required to open a steelman session. +3. Dedup check (embedding similarity → LLM confirm on borderline). +4. Steelman loop (≤ 3 rounds) → contributor approves → publish. + +**Seed content — the djb hybrid-vs-pure-PQ debate**: +- Question: *"How should TLS 1.3 handle post-quantum key agreement?"* — + framed open (not either/or) so the tree admits more than two positions; + "standardize solo ML-KEM" and "require hybrid ECC+PQ" are seeded as the + first two answer nodes. +- Curated from the public record with per-node citations: IETF TLS WG + mailing list, djb's IESG appeals (Oct/Dec 2025), blog.cr.yp.to, LWN + coverage. **No AI paraphrasing of imported arguments** — verbatim quotes + + neutral summaries with links. +- Map the *technical* debate only; keep the process/consensus-legitimacy + fight (appeals drama) out of the seed tree. +- **Neutrality disclosure, prominent**: Quantus is a pure-PQ chain and + therefore a party to this debate. "We have a stake; here's the map; + correct us." Invite corrections before promoting it anywhere. +- Second space: QIP discussions (own community, real decisions, zero + current venue). + +**Deliverables**: +- [x] Steelman-loop spike (see §5) — disentangle, relevance check, duplicate + check, succinct steelman loop; OpenRouter/Anthropic/OpenAI + stub fallback +- [x] DB-backed tree: PGlite schema, publish/tree/vote endpoints, seeded space +- [x] Tree UI (read + write): nested pro/con/answer nodes, collapsible branches, + per-answer pro/con tallies, inline composer (click node to reply), + votes, original/source text on click — served from the DB +- [x] Seeded djb tree: 36 nodes curated from the debate-structure chart + (blog.cr.yp.to/20260221-structure.html), technical branches only — + process/consensus-legitimacy arguments omitted; verbatim wording + + citation behind each node's "Source" toggle (`spike/seed.mjs`) +- [ ] Node detail: shareable per-node links +- [ ] Wallet auth + balance gate; captcha gate integration +- [ ] Embedding dedup (pgvector) + production write-path spend caps +- [ ] QIP space (second seeded space) +- [ ] Optional: per-node hash anchoring via `system.remark` (defer) + +--- + +## 5. Build order + +**Track A (start now): pool-service + captcha as ONE vertical slice.** +Neither is testable end-to-end without the other — a pool with no solver +client proves nothing, a widget with no verifier is a mock. Milestone: +demo page on a laptop solves a share against `quantus-node --dev`, verify +endpoint accepts the token, dashboard shows accrued shares. + +**Track B (done): steelman-loop spike + DB-backed tree UI.** +Validated the negotiation UX (disentangle, relevance, duplicates, succinct +steelman) and the read/write tree against PGlite. Prompts live in +`spike/prompts.mjs`; run guide in `spike/README.md`. + +**Next: production write path** — captcha share-token gate, wallet balance +gate for new questions, embedding dedup, spend caps. The captcha is also +independently shippable regardless of how Debate Tree evolves. + +**Sequencing summary**: +1. ~~Track A slice (pool + widget + demo)~~ — done +2. ~~Debate Tree spike (steelman + read/write tree + seeded djb content)~~ — done +3. **Now:** gates on the write path (captcha + wallet) + embedding dedup +4. QIP space, shareable node links, payouts polish, on-chain anchoring + +## 6. Risks + +| Risk | Mitigation | +|------|------------| +| Cryptojacking stigma / AV & adblock flagging | Consent + bounded work + first-party loader + open source; never ambient mining | +| Share revenue ≈ dust as hashrate grows | Market as non-wasteful + host-paid, not get-rich; pool aggregation for variance | +| Native-GPU spammers vs. phone users (PoW asymmetry) | Share target tuned low (rate limiter framing); balance gate for high-value actions | +| AI steelman feels condescending / voice laundering | Spike first; contributor approval required (accept/revise, no raw-publish bypass); original text always attached | +| Moderator bias becomes tree bias | Publish steelman prompts; show diff original→published | +| Quantus not neutral on the seed debate | Prominent disclosure; verbatim citations; invite corrections | +| djb reacts badly to AI paraphrase | Never AI-rewrite imported content | +| LLM spend abuse | Share token required per steelman session; round caps; per-user/global spend caps | +| Balance gate = plutocratic speech | Gate only question creation; answers need only PoW; bonds-not-balances revisit later | + +## 7. Open questions + +- Pool payout cadence/mechanism (manual → automated on-chain batch?). +- Where the WASM solver crate lives (`quantus-apps/captcha/solver` vs + `quantus-miner/web-miner`) — decide when wiring the build. +- Webapp framework + hosting; whether backend verifies ML-DSA sigs via + linked Rust crate or a small verifier sidecar. +- Per-space QUAN thresholds — governance-adjustable? fiat-pegged? +- Whether/when to anchor node hashes on-chain. diff --git a/debate-tree/spike/.gitignore b/debate-tree/spike/.gitignore new file mode 100644 index 00000000..cc4a2a54 --- /dev/null +++ b/debate-tree/spike/.gitignore @@ -0,0 +1,15 @@ +# Dependencies +node_modules/ + +# Secrets (OPENROUTER_API_KEY etc. — never commit) +.env + +# Local PGlite databases (pgdata, pgdata-demo, ...) — reseeded on first run +pgdata*/ + +# Stray process logs +nohup.out +*.log + +# The repo root ignores *.lock (Flutter convention); we DO want npm's lockfile +!package-lock.json diff --git a/debate-tree/spike/README.md b/debate-tree/spike/README.md new file mode 100644 index 00000000..646aba2f --- /dev/null +++ b/debate-tree/spike/README.md @@ -0,0 +1,76 @@ +# Debate Tree spike + +Throwaway prototype: steelman negotiation loop + PGlite-backed debate tree. +The prompts (`prompts.mjs`) are the main artifact; everything else is +hackathon scaffolding. + +## Quick start (seeded demo tree) + +```sh +cd quantus-apps/debate-tree/spike +npm install + +OPENROUTER_API_KEY=sk-or-v1-... \ + PORT=8789 \ + PGDATA_DIR=pgdata-demo \ + node server.mjs +``` + +Open http://127.0.0.1:8789/ + +Startup should log `model provider: openrouter (anthropic/claude-sonnet-4)`. +The yellow stub warning in the composer disappears once a real provider is active. + +## Model providers + +Priority order (first key wins): + +| Env var | Provider | +|---------|----------| +| `ANTHROPIC_API_KEY` | Anthropic direct | +| `OPENAI_API_KEY` | OpenAI direct | +| `OPENROUTER_API_KEY` | OpenRouter (any model slug) | +| *(none)* | **stub** — echoes your text back; UI flow only | + +Optional: `OPENROUTER_MODEL` (default `anthropic/claude-sonnet-4`), +`ANTHROPIC_MODEL`, `OPENAI_MODEL`. + +Without an API key the server still runs, but the moderator does not +steelman — it splits sentences and softens insults. Fine for UI testing; +not fine for demos. + +## Database directories + +PGlite stores data under `spike/pgdata*/` (gitignored). Pick one per instance: + +| Directory | Contents | +|-----------|----------| +| `pgdata-demo` | **Seeded** 42-node djb hybrid-vs-solo-PQ tree — use for demos | +| `pgdata` | Default when `PGDATA_DIR` is unset; empty on first run, then your test posts | + +Set `PGDATA_DIR=pgdata-demo` explicitly so you don't accidentally run against +an old test database. Only one `node server.mjs` process can open a given +directory at a time. + +**Reset a database:** stop the server, then `rm -rf pgdata-demo` (or `pgdata`). +The seeded tree is recreated automatically on next start if the space is empty. + +## What works + +- Read: nested answer / pro / con tree, votes, collapsible branches +- Write: steelman loop (disentangle → relevance check → duplicate check → + succinct steelman), publish to tree +- Click any argument to dock the composer and reply; negotiation state clears + when you retarget +- Off-target claims cannot be published; near-duplicates offer upvote-existing +- Seeded content: verbatim quotes from the public record (`seed.mjs`), no AI + rewrite of imports + +## Not wired yet + +- Captcha / share-token gate on writes +- Wallet auth + QUAN balance gate for new questions +- Embedding-based dedup (pgvector optional; currently disabled) +- Shareable per-node links, QIP space, on-chain anchoring + +See `../PLAN.md` for the full roadmap. diff --git a/debate-tree/spike/db.mjs b/debate-tree/spike/db.mjs new file mode 100644 index 00000000..46fad1cc --- /dev/null +++ b/debate-tree/spike/db.mjs @@ -0,0 +1,193 @@ +// Debate Tree spike DB — PGlite (Postgres in-process, persisted to ./pgdata). +// Same SQL runs on a hosted Postgres later; only the connection line changes. + +import { PGlite } from "@electric-sql/pglite"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SEED_TREE, SEED_AUTHOR } from "./seed.mjs"; + +const DIR = dirname(fileURLToPath(import.meta.url)); + +let db; +let hasVector = false; + +function slugify(s) { + return ( + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "space" + ); +} + +// Default seed: djb's hybrid-vs-pure-PQ TLS debate, so the tree isn't empty. +// Framed as an open question — positions are top-level answer nodes, so the +// question must admit more than two of them. +const DEFAULT_SPACE = { + slug: "tls-pq-hybrid", + question: "How should TLS 1.3 handle post-quantum key agreement?", +}; + +export async function initDb() { + // pgvector is an optional add-on for PGlite (not bundled in 0.5.x). If it's + // present we enable it and give `embedding` a real vector type for dedup; + // otherwise we fall back to jsonb so the tree still works. Dedup is deferred, + // so the fallback is expected for now — on hosted Postgres you just + // `create extension vector;` and the same schema gets vector(1024). + let extensions = {}; + try { + const { vector } = await import("@electric-sql/pglite/vector"); + extensions = { vector }; + hasVector = true; + } catch { + hasVector = false; + } + + const dataDir = process.env.PGDATA_DIR + ? join(DIR, process.env.PGDATA_DIR) + : join(DIR, "pgdata"); + db = new PGlite(dataDir, { extensions }); + await db.waitReady; + + if (hasVector) { + await db.exec("create extension if not exists vector;"); + } + + let schema = await readFile(join(DIR, "schema.sql"), "utf8"); + schema = schema.replace( + /EMBEDDING_COL/, + hasVector ? "vector(1024)" : "jsonb" + ); + await db.exec(schema); + + const space = await getOrCreateSpace(DEFAULT_SPACE.question, DEFAULT_SPACE.slug); + // Migrate existing DBs if the seed question's wording changed. + if (space.question !== DEFAULT_SPACE.question) { + await db.query("update space set question = $1 where id = $2", [ + DEFAULT_SPACE.question, + space.id, + ]); + space.question = DEFAULT_SPACE.question; + } + await seedSpace(space); + console.log( + `db ready (pgvector: ${hasVector ? "on" : "off — dedup disabled"})` + ); + return db; +} + +// Plant the curated djb debate chart if the default space is empty. +// published_text = neutral summary; original_text = verbatim quote + citation +// (surfaced by the UI's "source" toggle). Idempotent across restarts. +async function seedSpace(space) { + const count = await db.query( + "select count(*)::int as n from node where space_id = $1", + [space.id] + ); + if (count.rows[0].n > 0) return; + + const insertBranch = async (entry, parentId) => { + const row = await insertNode({ + spaceId: space.id, + parentId, + kind: entry.kind, + authorAccount: SEED_AUTHOR, + publishedText: entry.text, + originalText: entry.source, + }); + for (const child of entry.children || []) { + await insertBranch(child, row.id); + } + }; + for (const top of SEED_TREE) await insertBranch(top, null); + console.log(`seeded '${space.slug}' from the djb debate chart`); +} + +export async function getOrCreateSpace(question, slug) { + const s = slug || slugify(question); + const existing = await db.query( + "select * from space where slug = $1 or question = $2 limit 1", + [s, question] + ); + if (existing.rows[0]) return existing.rows[0]; + const inserted = await db.query( + "insert into space (slug, question) values ($1, $2) returning *", + [s, question] + ); + return inserted.rows[0]; +} + +export async function listSpaces() { + const r = await db.query( + `select s.*, (select count(*) from node n where n.space_id = s.id)::int as node_count + from space s order by s.created_at asc` + ); + return r.rows; +} + +export async function insertNode({ + spaceId, + parentId, + kind, + authorAccount, + publishedText, + originalText, + transcript, +}) { + const r = await db.query( + `insert into node + (space_id, parent_id, kind, author_account, published_text, original_text, transcript) + values ($1, $2, $3, $4, $5, $6, $7) + returning *`, + [ + spaceId, + parentId || null, + kind, + authorAccount || "anon", + publishedText, + originalText, + transcript ? JSON.stringify(transcript) : null, + ] + ); + return r.rows[0]; +} + +// Existing arguments at one position in the tree (children of parentId, or +// top-level answers when parentId is null) — the dedup candidates for a new +// submission aimed at that position. +export async function getChildren(spaceId, parentId) { + const r = await db.query( + parentId + ? "select id, published_text from node where space_id = $1 and parent_id = $2" + : "select id, published_text from node where space_id = $1 and parent_id is null", + parentId ? [spaceId, parentId] : [spaceId] + ); + return r.rows; +} + +// Whole tree for a space as flat rows with vote tallies. The client nests them. +export async function getTree(spaceId) { + const r = await db.query( + `select n.id, n.parent_id, n.kind, n.author_account, + n.published_text, n.original_text, n.created_at, + coalesce(sum(v.value), 0)::int as score, + count(v.*)::int as vote_count + from node n + left join vote v on v.node_id = n.id + where n.space_id = $1 + group by n.id + order by n.created_at asc`, + [spaceId] + ); + return r.rows; +} + +export async function castVote(nodeId, account, value) { + await db.query( + `insert into vote (node_id, account, value) values ($1, $2, $3) + on conflict (node_id, account) do update set value = excluded.value`, + [nodeId, account, value] + ); +} diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html new file mode 100644 index 00000000..3e757f03 --- /dev/null +++ b/debate-tree/spike/index.html @@ -0,0 +1,809 @@ + + + + + + Debate Tree + + + +
+
+
+

Debate Tree

+ AI-moderated argument map +
+

Every node is a steelman its author approved. The AI disentangles, sharpens, and de-duplicates — it never speaks for you.

+
+ +
+
The question
+
Loading…
+
+ Seeded from the public record — + djb, “NSA and IETF, part 6: The structure of the debate”. + Technical arguments only; click Source on any seeded node for the verbatim wording. +
+
+
+ + +
+
+ +
Loading the tree…
+ +
+
+
+ +
+ +
+
+ + + +
+ + +
+ + + + + +
+
+ +
+
+ +
+ Debate Tree spike — powered by the Quantus stack · + seed content curated from blog.cr.yp.to (process-based arguments omitted) +
+
+ + + + diff --git a/debate-tree/spike/package-lock.json b/debate-tree/spike/package-lock.json new file mode 100644 index 00000000..26bc74a6 --- /dev/null +++ b/debate-tree/spike/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "spike", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spike", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "^0.5.4" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", + "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", + "license": "Apache-2.0" + } + } +} diff --git a/debate-tree/spike/package.json b/debate-tree/spike/package.json new file mode 100644 index 00000000..88510ae1 --- /dev/null +++ b/debate-tree/spike/package.json @@ -0,0 +1,16 @@ +{ + "name": "spike", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@electric-sql/pglite": "^0.5.4" + } +} diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs new file mode 100644 index 00000000..a467f9c3 --- /dev/null +++ b/debate-tree/spike/prompts.mjs @@ -0,0 +1,147 @@ +// The keeper artifact of this spike: the steelman prompts. +// The code around them is throwaway; iterate on these. + +export const SYSTEM_PROMPT = `You are the moderator of a structured debate platform. A participant has +written an argument they want to add to a debate tree. Your job is to +DISENTANGLE, CHECK RELEVANCE, and STEELMAN their submission. + +Tree discipline: every node must engage its TARGET. If the participant is +responding to a specific claim, each of their claims must support or rebut +THAT claim — not the debate question in general, and not some other branch +of the tree. If they are answering the debate question directly, each claim +must be a position on that question. + +Step 1 — Disentangle: if the post bundles multiple independent claims +(technical + procedural, several reasons, a list of objections), split +them into separate entries in "claims". One tree node = one claim. Do not +merge distinct points into a single paragraph. A heated rant may still +contain 2–4 separable claims — extract them, in the order they appear. +Maximum 4 claims; fold trivial fragments into the nearest real claim. + +Step 2 — Check relevance: for each claim, ask whether it actually engages +the target. If it does, set "relevance" to null. If it does not — it argues +past the target, addresses a different branch, answers the question when it +should answer the parent claim, or merely restates the target without +adding anything — set "relevance" to ONE short sentence saying why, so the +author can rethink or re-aim it. Still steelman it faithfully; never +silently drop a claim or twist an off-target claim into an on-target one. + +Step 3 — Check duplicates: the prompt may list arguments that already exist +at this exact position in the tree. If one of the author's claims makes +substantially the same point as an existing argument — same claim, even if +worded very differently or framed as a question vs. a statement — set that +claim's "duplicate_of" to the existing argument's id. New evidence, a new +reason, or a meaningfully narrower/broader version is NOT a duplicate. +Still steelman the claim; the author decides whether to merge or +differentiate. + +Step 4 — Steelman each claim: write the strongest, clearest version of +THAT specific point, which the author must recognize as theirs. + +Hard rules: +1. Never change the author's position, weaken it into agreeableness, or + add hedges they didn't imply. +2. Strengthen: sharpen the core claim, make implicit reasoning explicit, + replace insults with force of argument, cut filler. +3. SUCCINCT: each steelman is at most 2 sentences or ~45 words. Debate + trees need scannable nodes, not essays. If the author was verbose, you + compress — you do not add length. +4. Keep the author's voice: first person if they wrote in first person, + plain language. No debate-club jargon, no "one might argue". +5. Do not invent facts, sources, or examples the author didn't reference + or clearly imply. Preserve concrete specifics exactly as written — + numbers, names, dates, quotes, citations. +6. You are the moderator, not a participant: never argue back, never + inject counterpoints, corrections, or your own view into the steelman. +7. "label" is a 3–8 word handle for the claim (for navigation), not a + new argument. +8. If the author's declared stance contradicts what their text actually + argues, flag that in "question" — do not silently flip the argument. +9. If (and only if) a specific claim is genuinely ambiguous, put ONE + short clarifying question in "question" (applies to the whole submission). + +The author will review and may push back on one claim at a time. Their +feedback is authoritative — revise to match their intent, not your taste. + +Respond with ONLY a JSON object, no markdown fences: +{ + "claims": [ + { + "id": "1", + "label": "", + "steelman": "", + "relevance": "", + "duplicate_of": "" + } + ], + "notes": "<1–3 short bullets, each starting with '- ', on splits and edits>", + "question": "" +}`; + +export function buildFirstRoundPrompt({ question, parent, stance, original, siblings }) { + const target = parent + ? `TARGET — they are responding to this claim: "${parent}" +Each of their claims must support or rebut that claim specifically.` + : `TARGET — the debate question itself. Each of their claims must be a +direct position on it.`; + + const existing = (siblings || []).length + ? ` +Arguments that already exist at this exact position in the tree (dedup +candidates — compare each of the author's claims against these): +${siblings.map((s) => `- (id: ${s.id}) ${s.published_text}`).join("\n")} +` + : ""; + + return `Debate question: ${question} +${target} +Their declared stance on the target: ${stance} +${existing} +Their argument, verbatim: +--- +${original} +--- + +Disentangle into separate claims if needed, check each claim's relevance to +the target and whether it duplicates an existing argument, then steelman +each succinctly.`; +} + +export function buildRevisionPrompt({ original, claim, draft, feedback, round }) { + return `This is revision round ${round} for claim "${claim.label}" (id ${claim.id}). + +Author's full original submission (context only): +--- +${original} +--- + +The claim you are revising: +--- +${claim.steelman} +--- + +Your previous draft for this claim: +--- +${draft} +--- + +The author's feedback: +--- +${feedback} +--- + +Revise ONLY this claim's steelman. Stay succinct (max 2 sentences / ~45 words). +Their feedback wins over your judgment — but re-check relevance against the +same target as before and update "relevance" honestly (null if it engages +the target). + +Respond with ONLY the same JSON object shape as before, containing exactly this +one revised claim, no markdown fences: +{ + "claims": [ + { "id": "${claim.id}", "label": "", "steelman": "", "relevance": "", "duplicate_of": null } + ], + "notes": "<1-3 short bullets on what you changed>", + "question": null +}`; +} diff --git a/debate-tree/spike/schema.sql b/debate-tree/spike/schema.sql new file mode 100644 index 00000000..b7a49db4 --- /dev/null +++ b/debate-tree/spike/schema.sql @@ -0,0 +1,36 @@ +-- Debate Tree — spike schema. +-- Plain Postgres (runs on PGlite in the spike, ports verbatim to hosted PG). +-- Deliberately minimal: no gate_proof (spam gate lands with the captcha), +-- no ltree (PGlite has no ltree ext; we walk the adjacency list with a +-- recursive CTE, which is plenty at spike scale). + +create table if not exists space ( + id uuid primary key default gen_random_uuid(), + slug text unique not null, + question text not null, + config jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists node ( + id uuid primary key default gen_random_uuid(), + space_id uuid not null references space(id) on delete cascade, + parent_id uuid references node(id) on delete cascade, -- null = direct answer to the space question + kind text not null check (kind in ('answer','pro','con')), + author_account text not null default 'anon', + published_text text not null, -- author-approved steelman = the node + original_text text not null, -- verbatim submission, always attached + transcript jsonb, -- negotiation provenance + content_hash bytea, -- for optional on-chain anchoring later + embedding EMBEDDING_COL, -- dedup search (populated later); see db.mjs + created_at timestamptz not null default now() +); + +create index if not exists node_space_parent_idx on node (space_id, parent_id); + +create table if not exists vote ( + node_id uuid not null references node(id) on delete cascade, + account text not null, + value smallint not null check (value in (-1, 1)), + primary key (node_id, account) +); diff --git a/debate-tree/spike/seed.mjs b/debate-tree/spike/seed.mjs new file mode 100644 index 00000000..de67fdb2 --- /dev/null +++ b/debate-tree/spike/seed.mjs @@ -0,0 +1,307 @@ +// Seed content: the djb hybrid-vs-solo-PQ debate, curated from the public +// argument chart in "NSA and IETF, part 6: The structure of the debate" +// (blog.cr.yp.to, 2026.02.21, version 2026.06.25). +// +// Technical branches only — the process/consensus-legitimacy branches of the +// chart ("the spec is procedurally improper", "objecting is procedurally +// improper") are deliberately omitted per PLAN.md. +// +// No AI paraphrasing: `text` is a neutral summary; `source` carries the +// verbatim wording (quotes as they appear in the chart) plus the link. + +const SRC = + "D. J. Bernstein, \u201cNSA and IETF, part 6: The structure of the debate\u201d \u2014 https://blog.cr.yp.to/20260221-structure.html"; + +function q(quote) { + return `${quote}\n\nSource: ${SRC}`; +} + +export const SEED_AUTHOR = "seed:djb-debate-chart"; + +// kind is relative to the parent: a `pro` supports its parent, a `con` +// attacks it (djb's chart semantics). Top-level entries are `answer`s to +// the space question. +export const SEED_TREE = [ + { + kind: "answer", + text: "Standardize solo ML-KEM: publish the RFC specifying pure ML-KEM key agreement in TLS.", + source: q( + "The proposal charted in the post: \u201cthe NSA-driven proposal for IETF to publish an RFC specifying usage of solo ML-KEM in TLS.\u201d" + ), + children: [ + { + kind: "pro", + text: "Regulatory compliance requires standalone PQ key establishment (NIST profiles, CNSA 2.0); many vendors will only ship ML-KEM-only TLS if a specific RFC exists.", + source: q( + "\u201cregulatory frameworks that require standalone post-quantum key establishment\u201d; \u201chybrid doesn't necessarily work for everyone\u201d; \u201cMany vendors will only support ML-KEM-only TLS if there is, specifically, an RFC from IETF specifying such\u201d" + ), + children: [ + { + kind: "con", + text: "NSA's own official CNSA 2.0 documents say hybrids \u201cmay be allowed or required due to protocol standards\u201d \u2014 the written requirement does not mandate solo PQ.", + source: q( + "\u201cthe official NSA documents on CNSA 2.0 say 'hybrid solutions may be allowed or required due to protocol standards'; so far NSA's push for this spec consists of unofficial actions by NSA employees\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "Solo PQ is smaller and faster than ECC+PQ; hybrids require ad-hoc encodings.", + source: q( + "\u201cthe spec decreases cost; solo PQ is smaller and faster than ECC+PQ; hybrids require 'ad hoc encodings'; 'pure-mlkem is the obviously correct solution if you want high-performance solutions'\u201d" + ), + children: [ + { + kind: "con", + text: "The cost difference is minor: X25519 keys are 32 bytes next to ML-KEM's 800\u20131568-byte keys, so the ECC share of communication and computation is negligible.", + source: q( + "\u201cML-KEM keys are 800/1184/1568 bytes; X25519 keys are only 32 bytes; communication and computation costs of X25519 are negligible compared to communicating ML-KEM keys and ciphertexts\u201d" + ), + }, + { + kind: "pro", + text: "Some constrained environments can afford solo PQ but not ECC+PQ.", + source: q( + "\u201csome constrained environments can afford solo PQ but not ECC+PQ; 'constrained environments where smaller key sizes or less computation are needed'\u201d" + ), + children: [ + { + kind: "con", + text: "The cited sources don't claim 32 extra bytes are an issue, and reported legacy-middlebox problems with PQ key shares were fixed years ago.", + source: q( + "\u201cthe second source says that legacy-middlebox problems with PQ were fixed years ago; the first and third sources don't claim legacy-middlebox problems; also, none of the sources claim that 32 extra bytes are an issue\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "High-frequency trading needs the smallest possible latency.", + source: q("\u201chigh-frequency trading needs the smallest possible latency\u201d"), + children: [ + { + kind: "con", + text: "This is a key-exchange spec; key-exchange costs don't affect per-trade latency.", + source: q( + "\u201cthat's irrelevant; this is a key-exchange spec; key-exchange costs do not affect trading latency\u201d" + ), + }, + ], + }, + ], + }, + { + kind: "pro", + text: "Hybrids are transitional by design; going straight to solo PQ avoids a second large-scale migration later.", + source: q( + "\u201c'hybrid PQ/T is clearly a transitional mechanism'; 'bridging technology'; better to 'make the future transition easier'; deploying hybrids would require a 'second large-scale engineering effort to migrate to pure ML-KEM sometime later'\u201d" + ), + children: [ + { + kind: "con", + text: "Even after a billion-dollar quantum computer starts breaking thousands of ECC keys per year, ECC+PQ will still rescue many more broken PQ keys for the next decade.", + source: q( + "\u201ceven after a billion-dollar quantum computer starts breaking thousands of ECC keys per year, there will still be many more broken PQ keys rescued by ECC+PQ for the next decade\u201d" + ), + }, + { + kind: "con", + text: "There are many ways ML-KEM can end up not being the eventual choice \u2014 the \u201csecond migration\u201d may happen regardless.", + source: q( + "\u201cthere are many ways that ML-KEM can end up not being the eventual choice\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "ECC is useless once a cryptographically relevant quantum computer exists; the ECC part of ECC+PQ then adds no real benefit.", + source: q( + "\u201cECC 'isn't doing much now and won't do anything at all, in just a few years'; 'hybrids are pointless' once there is a 'CRQC'; ECC+PQ 'will offer no real benefit over PQ once a CRQC exists'\u201d" + ), + children: [ + { + kind: "con", + text: "Eventual uselessness can't justify dropping ECC now: removing ECC from ECC+SIKE would have expanded the SIKE break into an immediate non-quantum compromise of every user.", + source: q( + "\u201ceven if ECC is eventually useless, this cannot justify avoiding ECC now; removing ECC from ECC+SIKE would have expanded the damage of the SIKE break by exposing ECC+SIKE users to immediate non-quantum attack\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "Lattices were fully vetted through a decade-long open NIST PQC process; no new cryptanalysis of ML-KEM has been offered.", + source: q( + "\u201clattices are very well studied and were 'fully vetted' through 'a full decade of entirely open, international analysis and debate'; 'ML-KEM was fully vetted through this process'; 'No new cryptanalyses have been offered'; 'The lattice candidates survived'\u201d" + ), + children: [ + { + kind: "con", + text: "Lattice candidates in that same process were broken (Compact LWE, HILA5's IND-CCA2 claim, Round2), and the surviving candidates have kept losing bits of security.", + source: q( + "\u201cactually, some of the lattice candidates in the NIST PQC process have already been broken (including Compact LWE, HILA5's IND-CCA2 claim, and Round2); furthermore, all of the remaining lattice candidates have lost many bits of security and are continuing to lose security\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "ML-KEM is easier to implement securely than its classical alternatives and will have exceedingly few bugs.", + source: q( + "\u201c'ML-KEM and ML-DSA are a lot easier to implement securely than their classical alternatives' and will have 'exceedingly few bugs'\u201d" + ), + children: [ + { + kind: "con", + text: "ML-KEM software has needed emergency security patches since December 2023: KyberSlash 1, then KyberSlash 2, then Clangover.", + source: q( + "\u201csince December 2023, Kyber/ML-KEM software has had emergency security patches for KyberSlash 1, then KyberSlash 2, then Clangover\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "ECC+PQ creates combinatorial software-engineering and testing complexity: every ECC option combined with every PQ option.", + source: q( + "\u201cthe problematic software engineering/testing complexity of combining each ECC option with each PQ option (e.g., ECC1+PQ1, ECC1+PQ2, \u2026); 'combinatorical explosion'; 'Support all of the schemes? Probably not feasible'\u201d" + ), + children: [ + { + kind: "con", + text: "The complexity is almost entirely inside the individual options; the combinations themselves are simple and easy to automate.", + source: q( + "\u201cno, the software engineering/testing complexity is almost entirely inside the individual options; the combinations are simple and easy to automate\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "Hybrid is cognitive dissonance: the quantum threat can't simultaneously be urgent and the PQ algorithms possibly broken.", + source: q( + "\u201cit is 'cognitive dissonance to simultaneously argue that the quantum threat requires immediate work, and yet we are also somehow uncertain of if the algorithms are totally broken. Both cannot be true at the same time'\u201d" + ), + children: [ + { + kind: "con", + text: "Demanding certainty is not competent risk management. ECC+PQ reduces damage from PQ breaks and from quantum attacks \u2014 it's wearing a seatbelt while trying to keep the car from crashing.", + source: q( + "\u201casking for certainty is not competent risk management; ECC+PQ sensibly tries to reduce damage from breaks of the PQ part and from quantum attacks; it's wearing your seatbelt and trying to make sure the car doesn't crash\u201d" + ), + }, + ], + }, + { + kind: "pro", + text: "NSA includes ML-KEM-1024 in CNSA 2.0 and will use it itself \u2014 a serious endorsement; NSA can't plausibly hold an attack against it.", + source: q( + "\u201cNSA can't possibly have an attack against ML-KEM; NSA says they'll use ML-KEM; 'I regard the inclusion of ML-KEM-1024 in CNSA2 as a serious endorsement'\u201d" + ), + children: [ + { + kind: "con", + text: "NSA publicly said it would use DES while secretly rating it weak enough to break.", + source: q( + "\u201cNSA secretly said DES was weak enough to break, but publicly said they would use it\u201d" + ), + }, + { + kind: "con", + text: "If NSA holds an ML-KEM break, it will quietly use something else for its own data, whatever it claims publicly.", + source: q( + "\u201cif NSA has an ML-KEM break then for their own data they'll use something else, no matter what they claim publicly\u201d" + ), + }, + ], + }, + ], + }, + { + kind: "answer", + text: "Require hybrid (ECC+PQ): don't standardize solo-PQ key agreement in TLS.", + source: q( + "The opposing position throughout the chart: keep \u201cnormal ECC+PQ\u201d rather than \u201cweakening \u2026 to solo PQ\u201d." + ), + children: [ + { + kind: "pro", + text: "Half of proposed PQ cryptosystems have been mathematically broken. SIKE was publicly broken after SIKEp434 had been applied to tens of millions of user connections.", + source: q( + "\u201chalf of proposed PQ cryptosystems have been mathematically broken; for example, SIKE was publicly broken after SIKEp434 was applied to tens of millions of user connections\u201d \u2014 supporting \u201cweakening normal ECC+PQ to solo PQ creates security risks\u201d" + ), + children: [ + { + kind: "con", + text: "\u201cWe will end up with secure implementations\u201d of PQ.", + source: q("\u201cwe will end up with secure implementations\u201d of PQ"), + children: [ + { + kind: "con", + text: "The same confidence was expressed for RSA-512, SIKE, and many other since-broken cryptosystems.", + source: q( + "\u201cthe same argument applies to RSA-512, SIKE, and many other broken cryptosystems\u201d" + ), + }, + ], + }, + ], + }, + { + kind: "pro", + text: "ECC+PQ forces an attacker to break both ECC and the PQ scheme.", + source: q("\u201cno, ECC+PQ forces attackers to break ECC and PQ\u201d"), + }, + { + kind: "pro", + text: "Multiple national information-security authorities have set PQ/T hybrids as the standard.", + source: q( + "\u201cmultiple national information security authorities have set the use of PQ/T hybrids as the standard\u201d" + ), + }, + { + kind: "pro", + text: "Since ECC+PQ is deployed anyway, adding a solo-PQ option makes TLS more complicated by forcing more options.", + source: q( + "\u201csince we have ECC+PQ anyway, adding a PQ option makes TLS more complicated by forcing more options\u201d" + ), + children: [ + { + kind: "con", + text: "A particular implementation can avoid implementing ECC entirely and thus become simpler.", + source: q( + "\u201cyes, but a particular implementation can avoid implementing ECC and can thus become simpler\u201d" + ), + children: [ + { + kind: "con", + text: "Such implementations fail to interoperate with already-deployed ECC+PQ and with the mandated ECC baseline for TLS.", + source: q( + "\u201cno, those implementations will fail to interoperate with already-deployed ECC+PQ and with the mandated ECC baseline for TLS\u201d" + ), + }, + ], + }, + ], + }, + { + kind: "con", + text: "Nobody outside NSA will use solo PQ, so any ML-KEM breaks would impact no one else.", + source: q( + "\u201cnobody outside NSA will use this, so any breaks of ML-KEM will be 'not impacting anyone else'\u201d" + ), + children: [ + { + kind: "con", + text: "An RFC leads to usage far beyond NSA: applications pick whatever sounds most efficient, and several pro-solo arguments actively encourage broader solo-PQ usage.", + source: q( + "\u201cno, an RFC will lead to usage outside NSA; applications often choose whatever sounds like the most efficient solution; some of the pro arguments are actively encouraging broader usage of solo PQ\u201d" + ), + }, + ], + }, + ], + }, +]; diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs new file mode 100644 index 00000000..ddb58b44 --- /dev/null +++ b/debate-tree/spike/server.mjs @@ -0,0 +1,371 @@ +// Steelman-loop spike server. Throwaway code; the prompts are the artifact. +// +// node server.mjs # stub model (offline, tests the flow) +// ANTHROPIC_API_KEY=... node server.mjs +// OPENAI_API_KEY=... node server.mjs +// OPENROUTER_API_KEY=... OPENROUTER_MODEL=anthropic/claude-sonnet-4 node server.mjs +// +// Zero dependencies; serves index.html and POST /api/steelman. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SYSTEM_PROMPT, buildFirstRoundPrompt, buildRevisionPrompt } from "./prompts.mjs"; +import { + initDb, + getOrCreateSpace, + listSpaces, + insertNode, + getTree, + getChildren, + castVote, +} from "./db.mjs"; + +const PORT = process.env.PORT || 8788; +const DIR = dirname(fileURLToPath(import.meta.url)); + +const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; +const OPENAI_KEY = process.env.OPENAI_API_KEY; +const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY; +const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5"; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2"; +const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4"; +const OPENROUTER_REFERER = process.env.OPENROUTER_REFERER || "http://localhost:8788"; +const OPENROUTER_TITLE = process.env.OPENROUTER_TITLE || "Debate Tree steelman spike"; + +const provider = ANTHROPIC_KEY + ? "anthropic" + : OPENAI_KEY + ? "openai" + : OPENROUTER_KEY + ? "openrouter" + : "stub"; +console.log(`model provider: ${provider}${provider === "openrouter" ? ` (${OPENROUTER_MODEL})` : ""}`); + +async function callAnthropic(messages) { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": ANTHROPIC_KEY, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: ANTHROPIC_MODEL, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages, + }), + }); + if (!res.ok) throw new Error(`anthropic ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.content.map((c) => c.text || "").join(""); +} + +async function callOpenAI(messages) { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${OPENAI_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_MODEL, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages], + }), + }); + if (!res.ok) throw new Error(`openai ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.choices[0].message.content; +} + +// OpenRouter exposes an OpenAI-compatible chat API; handy for swapping models +// without changing provider code. +async function callOpenRouter(messages) { + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${OPENROUTER_KEY}`, + "HTTP-Referer": OPENROUTER_REFERER, + "X-Title": OPENROUTER_TITLE, + }, + body: JSON.stringify({ + model: OPENROUTER_MODEL, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages], + }), + }); + if (!res.ok) throw new Error(`openrouter ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.choices[0].message.content; +} + +// Offline stand-in: crude split on sentence boundaries + short prefix. +function callStub(messages) { + const last = messages[messages.length - 1].content; + const isRevision = /revision round/.test(last); + + if (isRevision) { + const draft = (last.match(/Your previous draft[^]*?---\n([\s\S]*?)\n---/) || [, ""])[1].trim(); + return Promise.resolve( + JSON.stringify({ + claims: [{ id: "1", label: "Revised", steelman: draft + " (revised per your feedback)", relevance: null }], + notes: "- [stub model] echoed feedback", + question: null, + }) + ); + } + + const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim(); + const sentences = original + .split(/(?<=[.!?])\s+/) + .map((s) => + s + .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed") + .replace(/!+/g, ".") + .trim() + ) + .filter((s) => s.length > 20); + + // Crude relevance check: flag a claim that shares no substantive words with + // the reply target (real models do this semantically; this tests the UI). + const parentQuote = (last.match(/responding to this claim: "([\s\S]*?)"/) || [, ""])[1]; + // Stemmed word set: lowercase, letters only, first 6 chars — so that + // "standardized" and "standardize" collide. + const words = (t) => + new Set((t.toLowerCase().match(/[a-z][a-z-]{3,}/g) || []).map((w) => w.slice(0, 6))); + const parentWords = words(parentQuote); + const overlaps = (text) => + [...words(text)].some((w) => parentWords.has(w)); + + // Crude duplicate check against the sibling list embedded in the prompt: + // Jaccard similarity on stemmed words (real models judge this semantically). + const siblings = [...last.matchAll(/^- \(id: ([0-9a-f-]+)\) (.+)$/gm)].map((m) => ({ + id: m[1], + words: words(m[2]), + })); + const duplicateOf = (text) => { + const w = words(text); + for (const s of siblings) { + const inter = [...w].filter((x) => s.words.has(x)).length; + const union = new Set([...w, ...s.words]).size; + if (union && inter / union > 0.4) return s.id; + } + return null; + }; + + const chunks = sentences.length >= 2 ? sentences.slice(0, 4) : [original]; + const claims = chunks.map((chunk, i) => ({ + id: String(i + 1), + label: `Point ${i + 1}`, + steelman: chunk.length > 120 ? chunk.slice(0, 117) + "…" : chunk, + relevance: + parentQuote && !overlaps(chunk) + ? "This claim doesn't appear to engage the claim you're replying to — it may belong elsewhere in the tree." + : null, + duplicate_of: duplicateOf(chunk), + })); + + return Promise.resolve( + JSON.stringify({ + claims, + notes: + claims.length > 1 + ? `- [stub model] split into ${claims.length} claims\n- set an API key for real disentangling` + : "- [stub model] single claim\n- set an API key for real steelmanning", + question: null, + }) + ); +} + +const callModel = + provider === "anthropic" + ? callAnthropic + : provider === "openai" + ? callOpenAI + : provider === "openrouter" + ? callOpenRouter + : callStub; + +function parseModelJson(text) { + const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(cleaned); + + let claims = parsed.claims; + // Tolerate shapes a model tends to drift into, especially on revision: + // { steelman: "..." } (old single-claim shape) + // { claim: { steelman } } (singular key) + // { revised: "..." } | { text: "..." } + if (!Array.isArray(claims)) { + const single = + (parsed.claim && typeof parsed.claim === "object" && parsed.claim) || + (typeof parsed.steelman === "string" && { steelman: parsed.steelman }) || + (typeof parsed.revised === "string" && { steelman: parsed.revised }) || + (typeof parsed.text === "string" && { steelman: parsed.text }); + if (single) { + claims = [{ id: single.id ?? "1", label: single.label ?? "Main point", steelman: single.steelman }]; + } + } + if (!Array.isArray(claims) || claims.length === 0) { + throw new Error("model reply missing claims"); + } + claims = claims.map((c, i) => { + if (typeof c.steelman !== "string" || !c.steelman.trim()) { + throw new Error(`claim ${i + 1} missing steelman`); + } + const optionalStr = (v) => + typeof v === "string" && v.trim() && v.trim().toLowerCase() !== "null" + ? v.trim() + : null; + return { + id: String(c.id ?? i + 1), + label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : `Point ${i + 1}`, + steelman: c.steelman.trim(), + relevance: optionalStr(c.relevance), + duplicate_of: optionalStr(c.duplicate_of), + }; + }); + + return { + claims, + notes: typeof parsed.notes === "string" ? parsed.notes : "", + question: typeof parsed.question === "string" ? parsed.question : null, + }; +} + +async function handleSteelman(req, res) { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + + // Existing arguments at the target position, so the model can flag + // duplicates before anything is published. + let siblings = []; + if (body.question) { + const space = await getOrCreateSpace(body.question, body.spaceSlug); + siblings = await getChildren(space.id, body.parentId || null); + } + + // Rebuild the conversation from the client-held transcript. rounds is + // [{draft, feedback}, ...] for completed rounds. + const messages = [ + { role: "user", content: buildFirstRoundPrompt({ ...body, siblings }) }, + ]; + (body.rounds || []).forEach((r, i) => { + messages.push({ + role: "assistant", + content: JSON.stringify({ + claims: [{ id: r.claimId, label: r.claimLabel, steelman: r.draft }], + }), + }); + messages.push({ + role: "user", + content: buildRevisionPrompt({ + original: body.original, + claim: { id: r.claimId, label: r.claimLabel, steelman: r.draft }, + draft: r.draft, + feedback: r.feedback, + round: i + 1, + }), + }); + }); + + const reply = await callModel(messages); + const parsed = parseModelJson(reply); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(parsed)); +} + +async function readBody(req) { + let raw = ""; + for await (const chunk of req) raw += chunk; + return raw ? JSON.parse(raw) : {}; +} + +function sendJson(res, status, obj) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(obj)); +} + +// GET /api/tree?space= — space row + flat node list. +async function handleTree(req, res, url) { + const key = url.searchParams.get("space") || ""; + const question = url.searchParams.get("question") || key; + const space = await getOrCreateSpace(question, /\s/.test(key) ? undefined : key); + const nodes = await getTree(space.id); + sendJson(res, 200, { space, nodes, provider }); +} + +async function handleSpaces(_req, res) { + sendJson(res, 200, { spaces: await listSpaces() }); +} + +// POST /api/publish — persist an author-approved steelman as a tree node. +// parent_id null => 'answer' (direct to the question); otherwise pro/con by stance. +async function handlePublish(req, res) { + const b = await readBody(req); + if (!b.publishedText || !b.originalText || !b.question) { + return sendJson(res, 400, { error: "question, publishedText, originalText required" }); + } + const space = await getOrCreateSpace(b.question, b.spaceSlug); + const kind = !b.parentId + ? "answer" + : /oppos|con/i.test(b.stance || "") + ? "con" + : "pro"; + const node = await insertNode({ + spaceId: space.id, + parentId: b.parentId, + kind, + authorAccount: b.authorAccount, + publishedText: b.publishedText, + originalText: b.originalText, + transcript: b.transcript, + }); + sendJson(res, 200, { node, space }); +} + +async function handleVote(req, res) { + const b = await readBody(req); + if (!b.nodeId || ![1, -1].includes(b.value)) { + return sendJson(res, 400, { error: "nodeId and value (1|-1) required" }); + } + await castVote(b.nodeId, b.account || "anon", b.value); + sendJson(res, 200, { ok: true }); +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + if (req.method === "POST" && url.pathname === "/api/steelman") { + return await handleSteelman(req, res); + } + if (req.method === "POST" && url.pathname === "/api/publish") { + return await handlePublish(req, res); + } + if (req.method === "POST" && url.pathname === "/api/vote") { + return await handleVote(req, res); + } + if (req.method === "GET" && url.pathname === "/api/tree") { + return await handleTree(req, res, url); + } + if (req.method === "GET" && url.pathname === "/api/spaces") { + return await handleSpaces(req, res); + } + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const html = await readFile(join(DIR, "index.html")); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return res.end(html); + } + res.writeHead(404); + res.end("not found"); + } catch (err) { + console.error(err); + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: String(err.message || err) })); + } +}); + +await initDb(); +server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`));