From cd6fccde0ea6f6b23fca9839edd3e8fa2da8bef3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 10:31:10 +0800 Subject: [PATCH 1/5] Add Quantus Captcha widget/demo and Debate Tree plan captcha/ is the embeddable proof-of-work captcha: drop-in widget, Web Worker WASM solver glue, demo form page, and integration docs; it consumes the pool-service API from quantus-miner. debate-tree/PLAN.md is the full plan for the AI-moderated debate platform the captcha gates. Co-authored-by: Cursor --- captcha/.gitignore | 1 + captcha/README.md | 87 ++++++++++++ captcha/demo/index.html | 90 ++++++++++++ captcha/scripts/build-solver.sh | 13 ++ captcha/widget/quan-captcha.js | 174 ++++++++++++++++++++++++ captcha/widget/solver-worker.js | 55 ++++++++ debate-tree/PLAN.md | 233 ++++++++++++++++++++++++++++++++ 7 files changed, 653 insertions(+) create mode 100644 captcha/.gitignore create mode 100644 captcha/README.md create mode 100644 captcha/demo/index.html create mode 100755 captcha/scripts/build-solver.sh create mode 100644 captcha/widget/quan-captcha.js create mode 100644 captcha/widget/solver-worker.js create mode 100644 debate-tree/PLAN.md 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..1ad0d72b --- /dev/null +++ b/captcha/widget/quan-captcha.js @@ -0,0 +1,174 @@ +// 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"); + if (state === "solved") check.textContent = "\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"); + + const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); + setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + + (share.block_found ? " — BLOCK FOUND!" : "")); + + 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; + 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..19352c44 --- /dev/null +++ b/debate-tree/PLAN.md @@ -0,0 +1,233 @@ +# 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). + +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, embeddings + a cheap LLM + pass check whether the point already exists in the tree; near-duplicates are + redirected to the existing node ("upvote / extend this instead?"). +- **Steelmanning loop** — a contributor drafts a point; the AI reflects back + the strongest version of it; the contributor revises or accepts; capped at + ~3 rounds, with a "publish as-is (flagged unrefined)" escape hatch. Only + the version the *contributor* approves is published. The original text + stays attached (visible on click) — nobody's voice is erased. +- **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** (proposed): TypeScript web frontend + backend (framework TBD at +kickoff), Postgres + pgvector (tree + embeddings), LLM API for +steelman/dedup (cheap model for loop turns, stronger model for final +published version). 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 (sketch)**: +- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds config: + question threshold N QUAN, share target, model tier. +- `node` — id, space, parent, kind (`question | answer | pro | con`), + published text, original text, author account, source citations (for + seeded nodes), content hash (for optional on-chain anchoring), status. +- `steelman_session` — node draft, transcript, round count, state. +- `share_token` / `balance_attestation` — consumed gate proofs. + +**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: *"Should TLS 1.3 standardize pure ML-KEM key agreement, or + require hybrid (ECC+PQ)?"* +- 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**: +- [ ] Steelman-loop spike (see §5 — build first, throwaway UI) +- [ ] Tree UI (read): collapsible tree, node detail w/ original text + + citations, shareable node links +- [ ] Wallet auth + balance gate; captcha gate integration +- [ ] Dedup + steelman write path with round caps and spend caps +- [ ] Seeded djb tree + QIP 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 (parallel, cheap): steelman-loop spike.** +The single highest product risk is whether the steelman negotiation feels +respectful rather than condescending — no chain deps, just an LLM chat +loop + prompt iteration on real contentious arguments. A weekend spike; +throwaway code, keep the prompts. + +**Then: Debate Tree webapp** consuming both tracks, launching with the +seeded djb tree + QIP space. Rationale for not building the webapp first: +its write path *is* the gates + the steelman loop; building it first means +building it twice. The captcha is also independently shippable/marketable +regardless of how Debate Tree evolves. + +**Sequencing summary**: +1. Track A slice (pool + widget + demo) — Track B spike in parallel +2. Debate Tree read UI + seeded djb/QIP content (valuable even before + writes open — "the map" is the marketing artifact) +3. Debate Tree write path (gates + moderator) +4. Payouts polish, on-chain anchoring, additional spaces + +## 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; 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. From 17ba15e7d84af6b85f531f25019115bd9de86872 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 16:56:22 +0800 Subject: [PATCH 2/5] Clear stale checkmark and set solved state only after fallible tail work setState now owns the checkmark in both directions, and the solved state is applied after the hidden-token injection so a throw in the tail lands in fail() without leaving solved visuals behind. Co-authored-by: Cursor --- captcha/widget/quan-captcha.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js index 1ad0d72b..3273a93a 100644 --- a/captcha/widget/quan-captcha.js +++ b/captcha/widget/quan-captcha.js @@ -81,7 +81,9 @@ function setState(state, subText) { box.dataset.state = state; check.setAttribute("aria-checked", state === "solved" ? "true" : "false"); - if (state === "solved") check.textContent = "\u2713"; + // 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; } @@ -122,10 +124,8 @@ const share = await shareRes.json(); if (!share.success) throw new Error(share.error || "share rejected"); - const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); - setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + - (share.block_found ? " — BLOCK FOUND!" : "")); - + // 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) { @@ -135,6 +135,11 @@ 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 }, From 7389dfb1ed07458cdb983fcc3ee23478b88e23b9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 17:56:33 +0800 Subject: [PATCH 3/5] add steelman logic --- debate-tree/spike/index.html | 221 ++++++++++++++++++++++++++++++++++ debate-tree/spike/prompts.mjs | 67 +++++++++++ debate-tree/spike/server.mjs | 147 ++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 debate-tree/spike/index.html create mode 100644 debate-tree/spike/prompts.mjs create mode 100644 debate-tree/spike/server.mjs diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html new file mode 100644 index 00000000..5e9d5145 --- /dev/null +++ b/debate-tree/spike/index.html @@ -0,0 +1,221 @@ + + + + + + Steelman spike + + + +

Steelman negotiation spike — the AI moderator write-path, isolated

+ +
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+ + + + diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs new file mode 100644 index 00000000..598c75f5 --- /dev/null +++ b/debate-tree/spike/prompts.mjs @@ -0,0 +1,67 @@ +// 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 +STEELMAN it: produce the strongest, clearest version of THEIR point, +which they must recognize as their own. + +Hard rules: +1. Never change the author's position, weaken it into agreeableness, or + add hedges they didn't imply. If they are against something, the + steelman is firmly against it. +2. Strengthen: sharpen the core claim, make implicit reasoning explicit, + replace insults and sneers with force of argument, cut filler. +3. Keep the author's voice: first person if they wrote in first person, + similar length (never more than ~1.5x), plain language. No debate-club + jargon, no "one might argue". +4. Do not invent facts, sources, or examples the author didn't reference + or clearly imply. If their claim depends on a fact you're unsure of, + keep their phrasing rather than "correcting" it. +5. If the argument bundles several points, keep the strongest one central + rather than flattening it into a list. +6. If (and only if) the author's position is genuinely ambiguous, ask ONE + short clarifying question. + +The author will review your draft and may push back. When they do, their +feedback is authoritative about what they meant — revise to match their +intent, not to defend your previous draft. + +Respond with ONLY a JSON object, no markdown fences: +{ + "steelman": "", + "notes": "<1-3 short bullets, each starting with '- ', saying what you changed and why>", + "question": "" +}`; + +export function buildFirstRoundPrompt({ question, parent, stance, original }) { + return `Debate question: ${question} +${parent ? `The participant is responding to this claim: ${parent}\n` : ""}Their stance on it: ${stance} + +Their argument, verbatim: +--- +${original} +--- + +Steelman it.`; +} + +export function buildRevisionPrompt({ original, draft, feedback, round }) { + return `This is revision round ${round}. Reminder of the author's original, verbatim: +--- +${original} +--- + +Your previous draft: +--- +${draft} +--- + +The author's feedback on your draft: +--- +${feedback} +--- + +Revise the steelman to match the author's intent. Their feedback wins over +your judgment about what makes the argument "better".`; +} diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs new file mode 100644 index 00000000..b46c76f5 --- /dev/null +++ b/debate-tree/spike/server.mjs @@ -0,0 +1,147 @@ +// 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 +// +// 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"; + +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 ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5"; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2"; + +const provider = ANTHROPIC_KEY ? "anthropic" : OPENAI_KEY ? "openai" : "stub"; +console.log(`model provider: ${provider}`); + +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; +} + +// Offline stand-in so the interaction flow is testable without a key: it +// performs a crude mechanical "steelman" and echoes feedback acknowledgment. +function callStub(messages) { + const last = messages[messages.length - 1].content; + const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim(); + const isRevision = /revision round/.test(last); + const cleaned = original + .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed") + .replace(/!+/g, ".") + .trim(); + const steelman = isRevision + ? cleaned + " (revised per your feedback)" + : "The core of my position: " + cleaned; + return Promise.resolve( + JSON.stringify({ + steelman, + notes: "- [stub model] softened insults, kept your position\n- set an API key for real steelmanning", + question: null, + }) + ); +} + +const callModel = provider === "anthropic" ? callAnthropic : provider === "openai" ? callOpenAI : callStub; + +function parseModelJson(text) { + // Models occasionally wrap JSON in fences despite instructions. + const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(cleaned); + if (typeof parsed.steelman !== "string") throw new Error("model reply missing steelman"); + return { + steelman: parsed.steelman, + 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); + + // Rebuild the conversation from the client-held transcript. rounds is + // [{draft, feedback}, ...] for completed rounds. + const messages = [ + { role: "user", content: buildFirstRoundPrompt(body) }, + ]; + (body.rounds || []).forEach((r, i) => { + messages.push({ role: "assistant", content: JSON.stringify({ steelman: r.draft }) }); + messages.push({ + role: "user", + content: buildRevisionPrompt({ + original: body.original, + 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)); +} + +const server = createServer(async (req, res) => { + try { + if (req.method === "POST" && req.url === "/api/steelman") { + return await handleSteelman(req, res); + } + if (req.method === "GET" && (req.url === "/" || req.url === "/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) })); + } +}); + +server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`)); From b9fbfddac01608ad676a20d47eea22b0ed99915e Mon Sep 17 00:00:00 2001 From: illuzen Date: Sat, 11 Jul 2026 18:10:37 +0800 Subject: [PATCH 4/5] first draft of UI and database --- debate-tree/PLAN.md | 52 ++++-- debate-tree/spike/.gitignore | 3 + debate-tree/spike/db.mjs | 142 ++++++++++++++ debate-tree/spike/index.html | 274 +++++++++++++++++++++++++--- debate-tree/spike/package-lock.json | 22 +++ debate-tree/spike/package.json | 16 ++ debate-tree/spike/prompts.mjs | 86 ++++++--- debate-tree/spike/schema.sql | 36 ++++ debate-tree/spike/server.mjs | 221 +++++++++++++++++++--- 9 files changed, 763 insertions(+), 89 deletions(-) create mode 100644 debate-tree/spike/.gitignore create mode 100644 debate-tree/spike/db.mjs create mode 100644 debate-tree/spike/package-lock.json create mode 100644 debate-tree/spike/package.json create mode 100644 debate-tree/spike/schema.sql diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md index 19352c44..63b29723 100644 --- a/debate-tree/PLAN.md +++ b/debate-tree/PLAN.md @@ -28,11 +28,15 @@ differentiators are the AI moderator and the chain-native spam economics.) - **Deduplication** — before a new node is accepted, embeddings + a cheap LLM pass check whether the point already exists in the tree; near-duplicates are redirected to the existing node ("upvote / extend this instead?"). -- **Steelmanning loop** — a contributor drafts a point; the AI reflects back - the strongest version of it; the contributor revises or accepts; capped at - ~3 rounds, with a "publish as-is (flagged unrefined)" escape hatch. Only - the version the *contributor* approves is published. The original text - stays attached (visible on click) — nobody's voice is erased. +- **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. @@ -138,14 +142,26 @@ published version). 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 (sketch)**: -- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds config: - question threshold N QUAN, share target, model tier. -- `node` — id, space, parent, kind (`question | answer | pro | con`), - published text, original text, author account, source citations (for - seeded nodes), content hash (for optional on-chain anchoring), status. -- `steelman_session` — node draft, transcript, round count, state. -- `share_token` / `balance_attestation` — consumed gate proofs. +**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. `pgvector` is an +optional add-on there; when absent, `embedding` falls back to jsonb and dedup +is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`. **Write path**: 1. Client requests action → backend issues nonce challenge. @@ -171,9 +187,11 @@ server-side verification can link the same Rust crates. current venue). **Deliverables**: -- [ ] Steelman-loop spike (see §5 — build first, throwaway UI) -- [ ] Tree UI (read): collapsible tree, node detail w/ original text + - citations, shareable node links +- [x] Steelman-loop spike (see §5) — disentangle + succinct steelman loop +- [x] DB-backed tree: PGlite schema, publish/tree/vote endpoints, seeded space +- [x] Tree UI (read + write): nested pro/con/answer nodes, votes, reply-to-node, + original text on click — served from the DB +- [ ] Node detail: source citations, shareable per-node links - [ ] Wallet auth + balance gate; captcha gate integration - [ ] Dedup + steelman write path with round caps and spend caps - [ ] Seeded djb tree + QIP space @@ -215,7 +233,7 @@ regardless of how Debate Tree evolves. | 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; original text always attached | +| 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 | diff --git a/debate-tree/spike/.gitignore b/debate-tree/spike/.gitignore new file mode 100644 index 00000000..c1f1e5be --- /dev/null +++ b/debate-tree/spike/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +pgdata/ diff --git a/debate-tree/spike/db.mjs b/debate-tree/spike/db.mjs new file mode 100644 index 00000000..1af6c325 --- /dev/null +++ b/debate-tree/spike/db.mjs @@ -0,0 +1,142 @@ +// 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"; + +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 question, so the tree isn't empty. +const DEFAULT_SPACE = { + slug: "tls-pq-hybrid", + question: + "Should TLS 1.3 standardize pure ML-KEM key agreement, or require hybrid (ECC+PQ)?", +}; + +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); + + await getOrCreateSpace(DEFAULT_SPACE.question, DEFAULT_SPACE.slug); + console.log( + `db ready (pgvector: ${hasVector ? "on" : "off — dedup disabled"})` + ); + return db; +} + +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]; +} + +// 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 index 5e9d5145..f3e79764 100644 --- a/debate-tree/spike/index.html +++ b/debate-tree/spike/index.html @@ -39,12 +39,64 @@ .error { color: #dc2626; font-size: 14px; margin-top: 10px; white-space: pre-wrap; } .spinner { color: var(--muted); font-size: 14px; margin-top: 12px; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + .claim-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } + .claim-card { + border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; + background: #fafafa; + } + .claim-card h4 { margin: 0 0 6px; font-size: 13px; color: #344054; } + .claim-card .steelman { font-size: 14px; white-space: pre-wrap; } + .claim-card .actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; } + .claim-card .actions button { margin-top: 0; font-size: 13px; padding: 6px 12px; } + .hint { font-size: 13px; color: var(--muted); margin-top: 8px; } + + /* tree view */ + #tree-panel h2 { font-size: 15px; margin: 0 0 4px; } + #tree-panel .q { color: var(--muted); font-size: 13px; margin-bottom: 12px; } + .tree, .tree ul { list-style: none; margin: 0; padding: 0; } + .tree ul { margin-left: 18px; border-left: 2px solid var(--border); padding-left: 14px; } + .node { margin-top: 10px; } + .node-body { + border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; background: #fff; + } + .node-body.pro { border-left: 4px solid var(--green); } + .node-body.con { border-left: 4px solid #dc2626; } + .node-body.answer { border-left: 4px solid var(--accent); background: #f8faff; } + .node .meta { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } + .kind-badge { + font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 700; + padding: 1px 8px; border-radius: 99px; + } + .kind-badge.pro { color: var(--green); background: #e7f8ee; } + .kind-badge.con { color: #dc2626; background: #fdeaea; } + .kind-badge.answer { color: var(--accent); background: #e8effe; } + .node .who { font-size: 12px; color: var(--muted); } + .node .ptext { white-space: pre-wrap; } + .node .node-actions { display: flex; gap: 6px; align-items: center; margin-top: 8px; } + .node .node-actions button { margin-top: 0; font-size: 12px; padding: 4px 10px; } + .vote { display: inline-flex; align-items: center; gap: 4px; } + .vote button { background: #fff; color: #344054; border: 1px solid #d0d5dd; padding: 2px 9px; } + .vote .score { font-weight: 700; min-width: 18px; text-align: center; } + .empty { color: var(--muted); font-size: 14px; } + .replying-to { + margin-top: 8px; padding: 8px 12px; background: #eff6ff; border: 1px solid #bfdbfe; + border-radius: 8px; font-size: 13px; display: none; + } + .replying-to.show { display: flex; gap: 10px; align-items: center; justify-content: space-between; } + .replying-to button { margin-top: 0; font-size: 12px; padding: 3px 9px; } -

Steelman negotiation spike — the AI moderator write-path, isolated

+

Debate Tree — AI-moderated argument map (spike)

+ +
+

The tree

+
+
Loading…
+
+
@@ -103,9 +155,104 @@

Steelman negotiation spike — the AI moderator write-path, isolated< seeds.appendChild(b); }); - const state = { question: "", parent: "", stance: "", original: "", rounds: [], draft: null }; + const state = { + question: "", parent: "", stance: "", original: "", + rounds: [], draft: null, selectedClaim: null, + parentId: null, parentLabel: "", + }; const loop = document.getElementById("loop"); + // ---- tree view ------------------------------------------------------- + async function loadTree() { + const q = document.getElementById("question").value.trim(); + const res = await fetch("/api/tree?question=" + encodeURIComponent(q)); + const { space, nodes } = await res.json(); + document.getElementById("tree-question").textContent = space.question; + renderTree(nodes); + } + + function renderTree(nodes) { + const body = document.getElementById("tree-body"); + if (!nodes.length) { + body.innerHTML = '
No arguments yet — steelman one below to plant the first node.
'; + return; + } + const byParent = new Map(); + nodes.forEach((n) => { + const k = n.parent_id || "root"; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k).push(n); + }); + + const build = (parentKey) => { + const kids = byParent.get(parentKey) || []; + if (!kids.length) return ""; + return "
    " + kids.map((n) => ` +
  • +
    +
    + ${n.kind} + ${escapeHtml(n.author_account)} +
    +
    ${escapeHtml(n.published_text)}
    +
    + + + ${n.score} + + + + +
    + +
    + ${build(n.id)} +
  • `).join("") + "
"; + }; + + body.innerHTML = '
    ' + build("root").slice(4); + body.querySelectorAll("[data-vote]").forEach((btn) => { + btn.onclick = async () => { + await fetch("/api/vote", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ nodeId: btn.dataset.node, value: Number(btn.dataset.vote) }), + }); + loadTree(); + }; + }); + body.querySelectorAll("[data-orig]").forEach((btn) => { + btn.onclick = () => { + const el = document.getElementById("orig-" + btn.dataset.orig); + el.style.display = el.style.display === "none" ? "block" : "none"; + }; + }); + body.querySelectorAll("[data-reply]").forEach((btn) => { + btn.onclick = () => setReplyTarget(btn.dataset.reply, btn.dataset.kind); + }); + } + + function setReplyTarget(nodeId, kind) { + const node = document.querySelector(`[data-orig="${nodeId}"]`) + ?.closest(".node-body")?.querySelector(".ptext")?.textContent || ""; + state.parentId = nodeId; + state.parentLabel = node.slice(0, 80); + const banner = document.getElementById("replying-to"); + banner.className = "replying-to show"; + banner.innerHTML = `Replying to ${escapeHtml(state.parentLabel)} + `; + document.getElementById("clear-reply").onclick = clearReplyTarget; + document.getElementById("original").focus(); + document.getElementById("setup").scrollIntoView({ behavior: "smooth" }); + } + + function clearReplyTarget() { + state.parentId = null; + state.parentLabel = ""; + document.getElementById("replying-to").className = "replying-to"; + } + + loadTree(); + document.getElementById("start").onclick = async () => { state.question = document.getElementById("question").value.trim(); state.parent = document.getElementById("parent").value.trim(); @@ -117,6 +264,7 @@

    Steelman negotiation spike — the AI moderator write-path, isolated< return; } state.rounds = []; + state.selectedClaim = null; await requestSteelman(); }; @@ -135,81 +283,163 @@

    Steelman negotiation spike — the AI moderator write-path, isolated< const body = await res.json(); if (!res.ok) throw new Error(body.error || res.status); state.draft = body; - renderRound(); + if (state.selectedClaim) { + const updated = body.claims.find((c) => c.id === state.selectedClaim.id) || body.claims[0]; + state.selectedClaim = updated; + } else if (body.claims.length === 1) { + state.selectedClaim = body.claims[0]; + renderRound(); + } else { + renderClaimPicker(); + } } catch (err) { renderError(String(err.message || err)); } } function renderLoading() { - loop.innerHTML = '
    Moderator is drafting the strongest version of your point…
    '; + loop.innerHTML = '
    Moderator is disentangling and drafting…
    '; } - function renderError(message) { - loop.innerHTML = '
    ' + escapeHtml(message) + - '
    '; + function renderClaimPicker() { + const d = state.draft; + const claimsHtml = d.claims.map((c) => ` +
    +

    ${escapeHtml(c.label)}

    +
    ${escapeHtml(c.steelman)}
    +
    + + +
    +
    `).join(""); + + loop.innerHTML = ` +
    + ${d.claims.length} separate claims found +

    Each becomes its own tree node. Accept one as-is, or negotiate wording on one claim at a time.

    +
    ${claimsHtml}
    + ${d.notes ? `
    ${escapeHtml(d.notes)}
    ` : ""} + ${d.question ? `
    Moderator asks: ${escapeHtml(d.question)}
    ` : ""} +
    `; + + d.claims.forEach((c) => { + loop.querySelector(`[data-accept="${CSS.escape(c.id)}"]`).onclick = () => renderPublished(c.steelman, c); + loop.querySelector(`[data-negotiate="${CSS.escape(c.id)}"]`).onclick = () => { + state.selectedClaim = c; + state.rounds = []; + renderRound(); + }; + }); } function renderRound() { const roundNo = state.rounds.length + 1; const isLast = roundNo >= MAX_ROUNDS; const d = state.draft; + const claim = state.selectedClaim; loop.innerHTML = `
    Does this say what you mean? - round ${roundNo} of ${MAX_ROUNDS} + ${escapeHtml(claim.label)} · round ${roundNo} of ${MAX_ROUNDS}
    -

    You wrote

    ${escapeHtml(state.original)}
    -

    Moderator's draft

    ${escapeHtml(d.steelman)}
    +

    You wrote (full post)

    ${escapeHtml(state.original)}
    +

    This claim

    ${escapeHtml(claim.steelman)}
    ${d.notes ? `
    ${escapeHtml(d.notes)}
    ` : ""} ${d.question ? `
    Moderator asks: ${escapeHtml(d.question)}
    ` : ""} - - + +
    - + - +
    `; - document.getElementById("accept").onclick = () => renderPublished(d.steelman, false); - document.getElementById("as-is").onclick = () => renderPublished(state.original, true); + document.getElementById("accept").onclick = () => renderPublished(claim.steelman, claim); + document.getElementById("back").onclick = () => { + state.selectedClaim = null; + state.rounds = []; + renderClaimPicker(); + }; document.getElementById("pushback").onclick = async () => { const feedback = document.getElementById("feedback").value.trim(); if (!feedback) { document.getElementById("feedback").focus(); return; } - state.rounds.push({ draft: d.steelman, feedback }); + state.rounds.push({ + claimId: claim.id, + claimLabel: claim.label, + draft: claim.steelman, + feedback, + }); await requestSteelman(); }; } - function renderPublished(text, asIs) { + function renderError(message) { + loop.innerHTML = '
    ' + escapeHtml(message) + + '
    '; + } + + async function renderPublished(text, claim) { + const claimLine = claim ? ` ${escapeHtml(claim.label)}` : ""; + const transcript = { + allClaims: state.draft?.claims, selectedClaim: claim || null, rounds: state.rounds, + }; + loop.innerHTML = '
    Saving to the tree…
    '; + + let saveError = ""; + try { + const res = await fetch("/api/publish", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + question: state.question, stance: state.stance, + parentId: state.parentId, publishedText: text, + originalText: state.original, transcript, + }), + }); + const b = await res.json(); + if (!res.ok) throw new Error(b.error || res.status); + } catch (err) { + saveError = String(err.message || err); + } + + clearReplyTarget(); + await loadTree(); + loop.innerHTML = `
    - Published to the tree ${asIs ? "(as-is, flagged unrefined)" : "(steelmanned, author-approved)"} + ${saveError ? "Steelmanned — but save failed" : "Published to the tree (steelmanned, author-approved)"}${claimLine} + ${saveError ? `
    ${escapeHtml(saveError)}
    ` : ""}
    ${escapeHtml(text)}
    Original text (always attached)
    ${escapeHtml(state.original)}
    - +
    `; document.getElementById("export").onclick = () => { navigator.clipboard.writeText(JSON.stringify({ question: state.question, parent: state.parent, stance: state.stance, - original: state.original, rounds: state.rounds, - published: text, publishedAsIs: asIs, + original: state.original, allClaims: state.draft?.claims, + selectedClaim: claim || null, rounds: state.rounds, + published: text, }, null, 2)); }; + document.getElementById("another").onclick = () => { + state.rounds = []; state.selectedClaim = null; state.draft = null; + document.getElementById("original").value = ""; + loop.innerHTML = ""; + document.getElementById("tree-panel").scrollIntoView({ behavior: "smooth" }); + }; } function escapeHtml(s) { 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 index 598c75f5..f8e04f19 100644 --- a/debate-tree/spike/prompts.mjs +++ b/debate-tree/spike/prompts.mjs @@ -3,34 +3,47 @@ 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 -STEELMAN it: produce the strongest, clearest version of THEIR point, -which they must recognize as their own. +DISENTANGLE and STEELMAN their submission. + +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. + +Step 2 — Steelman each claim: for every entry, 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. If they are against something, the - steelman is firmly against it. + add hedges they didn't imply. 2. Strengthen: sharpen the core claim, make implicit reasoning explicit, - replace insults and sneers with force of argument, cut filler. -3. Keep the author's voice: first person if they wrote in first person, - similar length (never more than ~1.5x), plain language. No debate-club - jargon, no "one might argue". -4. Do not invent facts, sources, or examples the author didn't reference - or clearly imply. If their claim depends on a fact you're unsure of, - keep their phrasing rather than "correcting" it. -5. If the argument bundles several points, keep the strongest one central - rather than flattening it into a list. -6. If (and only if) the author's position is genuinely ambiguous, ask ONE - short clarifying question. - -The author will review your draft and may push back. When they do, their -feedback is authoritative about what they meant — revise to match their -intent, not to defend your previous draft. + 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. Keep their phrasing for disputed facts. +6. "label" is a 3–8 word handle for the claim (for navigation), not a + new argument. +7. 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: { - "steelman": "", - "notes": "<1-3 short bullets, each starting with '- ', saying what you changed and why>", + "claims": [ + { + "id": "1", + "label": "", + "steelman": "" + } + ], + "notes": "<1–3 short bullets, each starting with '- ', on splits and edits>", "question": "" }`; @@ -43,25 +56,42 @@ Their argument, verbatim: ${original} --- -Steelman it.`; +Disentangle into separate claims if needed, then steelman each succinctly.`; } -export function buildRevisionPrompt({ original, draft, feedback, round }) { - return `This is revision round ${round}. Reminder of the author's original, verbatim: +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} --- -Your previous draft: +The claim you are revising: +--- +${claim.steelman} +--- + +Your previous draft for this claim: --- ${draft} --- -The author's feedback on your draft: +The author's feedback: --- ${feedback} --- -Revise the steelman to match the author's intent. Their feedback wins over -your judgment about what makes the argument "better".`; +Revise ONLY this claim's steelman. Stay succinct (max 2 sentences / ~45 words). +Their feedback wins over your judgment. + +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": "" } + ], + "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/server.mjs b/debate-tree/spike/server.mjs index b46c76f5..8b25cde9 100644 --- a/debate-tree/spike/server.mjs +++ b/debate-tree/spike/server.mjs @@ -1,8 +1,9 @@ // Steelman-loop spike server. Throwaway code; the prompts are the artifact. // -// node server.mjs # stub model (offline, tests the flow) +// 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. @@ -11,17 +12,35 @@ 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, + 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" : "stub"; -console.log(`model provider: ${provider}`); +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", { @@ -60,37 +79,117 @@ async function callOpenAI(messages) { return body.choices[0].message.content; } -// Offline stand-in so the interaction flow is testable without a key: it -// performs a crude mechanical "steelman" and echoes feedback acknowledgment. +// 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 original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim(); const isRevision = /revision round/.test(last); - const cleaned = original - .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed") - .replace(/!+/g, ".") - .trim(); - const steelman = isRevision - ? cleaned + " (revised per your feedback)" - : "The core of my position: " + cleaned; + + 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)" }], + 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); + + 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, + })); + return Promise.resolve( JSON.stringify({ - steelman, - notes: "- [stub model] softened insults, kept your position\n- set an API key for real steelmanning", + 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 : callStub; +const callModel = + provider === "anthropic" + ? callAnthropic + : provider === "openai" + ? callOpenAI + : provider === "openrouter" + ? callOpenRouter + : callStub; function parseModelJson(text) { - // Models occasionally wrap JSON in fences despite instructions. const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); const parsed = JSON.parse(cleaned); - if (typeof parsed.steelman !== "string") throw new Error("model reply missing steelman"); + + 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`); + } + 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(), + }; + }); + return { - steelman: parsed.steelman, + claims, notes: typeof parsed.notes === "string" ? parsed.notes : "", question: typeof parsed.question === "string" ? parsed.question : null, }; @@ -107,11 +206,17 @@ async function handleSteelman(req, res) { { role: "user", content: buildFirstRoundPrompt(body) }, ]; (body.rounds || []).forEach((r, i) => { - messages.push({ role: "assistant", content: JSON.stringify({ steelman: r.draft }) }); + 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, @@ -125,12 +230,83 @@ async function handleSteelman(req, res) { 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 }); +} + +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 { - if (req.method === "POST" && req.url === "/api/steelman") { + 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 === "GET" && (req.url === "/" || req.url === "/index.html")) { + 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); @@ -144,4 +320,5 @@ const server = createServer(async (req, res) => { } }); +await initDb(); server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`)); From 39271159ae5da494f25eb5cfc373c6e6af0008b7 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Jul 2026 15:01:01 +0800 Subject: [PATCH 5/5] add debate tree demo --- debate-tree/PLAN.md | 79 ++-- debate-tree/spike/.gitignore | 14 +- debate-tree/spike/README.md | 76 ++++ debate-tree/spike/db.mjs | 59 ++- debate-tree/spike/index.html | 710 +++++++++++++++++++++++++--------- debate-tree/spike/prompts.mjs | 80 +++- debate-tree/spike/seed.mjs | 307 +++++++++++++++ debate-tree/spike/server.mjs | 53 ++- 8 files changed, 1146 insertions(+), 232 deletions(-) create mode 100644 debate-tree/spike/README.md create mode 100644 debate-tree/spike/seed.mjs diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md index 63b29723..1414d10a 100644 --- a/debate-tree/PLAN.md +++ b/debate-tree/PLAN.md @@ -3,6 +3,8 @@ 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 | @@ -25,9 +27,14 @@ 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, embeddings + a cheap LLM - pass check whether the point already exists in the tree; near-duplicates are - redirected to the existing node ("upvote / extend this instead?"). +- **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 @@ -135,10 +142,10 @@ Coinhive's death defines our guardrails: ## 4. Component 3 — Debate Tree webapp (`quantus-apps/debate-tree`) -**Stack** (proposed): TypeScript web frontend + backend (framework TBD at -kickoff), Postgres + pgvector (tree + embeddings), LLM API for -steelman/dedup (cheap model for loop turns, stronger model for final -published version). Wallet auth via ML-DSA signature verification — +**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. @@ -159,9 +166,11 @@ plain Postgres so it ports from the spike's PGlite to hosted PG verbatim): 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. `pgvector` is an -optional add-on there; when absent, `embedding` falls back to jsonb and dedup -is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`. +`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. @@ -172,8 +181,10 @@ is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`. 4. Steelman loop (≤ 3 rounds) → contributor approves → publish. **Seed content — the djb hybrid-vs-pure-PQ debate**: -- Question: *"Should TLS 1.3 standardize pure ML-KEM key agreement, or - require hybrid (ECC+PQ)?"* +- 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 @@ -187,14 +198,20 @@ is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`. current venue). **Deliverables**: -- [x] Steelman-loop spike (see §5) — disentangle + succinct steelman loop +- [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, votes, reply-to-node, - original text on click — served from the DB -- [ ] Node detail: source citations, shareable per-node links +- [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 -- [ ] Dedup + steelman write path with round caps and spend caps -- [ ] Seeded djb tree + QIP space +- [ ] Embedding dedup (pgvector) + production write-path spend caps +- [ ] QIP space (second seeded space) - [ ] Optional: per-node hash anchoring via `system.remark` (defer) --- @@ -207,24 +224,20 @@ 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 (parallel, cheap): steelman-loop spike.** -The single highest product risk is whether the steelman negotiation feels -respectful rather than condescending — no chain deps, just an LLM chat -loop + prompt iteration on real contentious arguments. A weekend spike; -throwaway code, keep the prompts. +**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`. -**Then: Debate Tree webapp** consuming both tracks, launching with the -seeded djb tree + QIP space. Rationale for not building the webapp first: -its write path *is* the gates + the steelman loop; building it first means -building it twice. The captcha is also independently shippable/marketable -regardless of how Debate Tree evolves. +**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) — Track B spike in parallel -2. Debate Tree read UI + seeded djb/QIP content (valuable even before - writes open — "the map" is the marketing artifact) -3. Debate Tree write path (gates + moderator) -4. Payouts polish, on-chain anchoring, additional spaces +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 diff --git a/debate-tree/spike/.gitignore b/debate-tree/spike/.gitignore index c1f1e5be..cc4a2a54 100644 --- a/debate-tree/spike/.gitignore +++ b/debate-tree/spike/.gitignore @@ -1,3 +1,15 @@ +# Dependencies node_modules/ + +# Secrets (OPENROUTER_API_KEY etc. — never commit) .env -pgdata/ + +# 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 index 1af6c325..46fad1cc 100644 --- a/debate-tree/spike/db.mjs +++ b/debate-tree/spike/db.mjs @@ -5,6 +5,7 @@ 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)); @@ -21,11 +22,12 @@ function slugify(s) { ); } -// Default seed: djb's hybrid-vs-pure-PQ TLS question, so the tree isn't empty. +// 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: - "Should TLS 1.3 standardize pure ML-KEM key agreement, or require hybrid (ECC+PQ)?", + question: "How should TLS 1.3 handle post-quantum key agreement?", }; export async function initDb() { @@ -60,13 +62,49 @@ export async function initDb() { ); await db.exec(schema); - await getOrCreateSpace(DEFAULT_SPACE.question, DEFAULT_SPACE.slug); + 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( @@ -116,6 +154,19 @@ export async function insertNode({ 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( diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html index f3e79764..3e757f03 100644 --- a/debate-tree/spike/index.html +++ b/debate-tree/spike/index.html @@ -3,151 +3,324 @@ - Steelman spike + Debate Tree -

    Debate Tree — AI-moderated argument map (spike)

    +
    +
    +
    +

    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. +
    +
    +
    + + +
    +
    -
    -

    The tree

    -
    -
    Loading…
    -
    +
    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/prompts.mjs b/debate-tree/spike/prompts.mjs index f8e04f19..a467f9c3 100644 --- a/debate-tree/spike/prompts.mjs +++ b/debate-tree/spike/prompts.mjs @@ -3,16 +3,40 @@ 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 and STEELMAN their submission. +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. - -Step 2 — Steelman each claim: for every entry, write the strongest, clearest -version of THAT specific point, which the author must recognize as theirs. +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 @@ -25,10 +49,15 @@ Hard rules: 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. Keep their phrasing for disputed facts. -6. "label" is a 3–8 word handle for the claim (for navigation), not a + 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. -7. If (and only if) a specific claim is genuinely ambiguous, put ONE +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 @@ -40,23 +69,42 @@ Respond with ONLY a JSON object, no markdown fences: { "id": "1", "label": "", - "steelman": "" + "steelman": "", + "relevance": "", + "duplicate_of": "" } ], "notes": "<1–3 short bullets, each starting with '- ', on splits and edits>", "question": "" }`; -export function buildFirstRoundPrompt({ question, parent, stance, original }) { - return `Debate question: ${question} -${parent ? `The participant is responding to this claim: ${parent}\n` : ""}Their stance on it: ${stance} +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, then steelman each succinctly.`; +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 }) { @@ -83,13 +131,15 @@ ${feedback} --- Revise ONLY this claim's steelman. Stay succinct (max 2 sentences / ~45 words). -Their feedback wins over your judgment. +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": "" } + { "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/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 index 8b25cde9..ddb58b44 100644 --- a/debate-tree/spike/server.mjs +++ b/debate-tree/spike/server.mjs @@ -18,6 +18,7 @@ import { listSpaces, insertNode, getTree, + getChildren, castVote, } from "./db.mjs"; @@ -109,7 +110,7 @@ function callStub(messages) { 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)" }], + claims: [{ id: "1", label: "Revised", steelman: draft + " (revised per your feedback)", relevance: null }], notes: "- [stub model] echoed feedback", question: null, }) @@ -127,11 +128,43 @@ function callStub(messages) { ) .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( @@ -181,10 +214,16 @@ function parseModelJson(text) { 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), }; }); @@ -200,10 +239,18 @@ async function handleSteelman(req, res) { 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) }, + { role: "user", content: buildFirstRoundPrompt({ ...body, siblings }) }, ]; (body.rounds || []).forEach((r, i) => { messages.push({ @@ -247,7 +294,7 @@ async function handleTree(req, res, url) { 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 }); + sendJson(res, 200, { space, nodes, provider }); } async function handleSpaces(_req, res) {