From d7a70406f4108eaaf2f23efc6303715c15b751dd Mon Sep 17 00:00:00 2001 From: Ancient Runner <7602667+wallscaler@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:04:34 -0400 Subject: [PATCH 1/3] feat(publisher): additive inject lane for live solve-time/score experiments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a continuous, env-gated challenge-injection lane that runs ALONGSIDE the native refill loop (default OFF). Native refill is untouched, so the board never empties even if this lane is off or stalls — best of both worlds. Injected challenges: * isolated under a distinct family_id (default 'gentest') so native refill never counts/retires them and they never eat native slots; * served/gated/verified/scored identically (cnf_source='local'); * identifiable — challenge_id embeds the family label and still parses to the correct tier (sat-t{N}-random-3sat-gentest-); * seeded from secrets.randbits(63) (OS entropy) instead of the predictable sha256(utc_hour:tier:seq), with per-tier method/shape overrides — so the experiment can isolate the effect of unpredictable / harder instances on live solve time and scores. Note: there is no zero-value mode — solved injected challenges pay real tier weight, so this moves live income. Keep targets small, announce, run bounded. New: * scaffold/publisher/inject.py — the lane (mirrors refill's env-gated pattern) * measure_inject.py — read-only solve-time/solver-spread report, inject vs native * inject_verify.py — gate proving isolation/identifiability/serve-parity (PASS) * INJECT.md — what it does, env knobs, the income caveat, how to run/measure/stop Changed: * scaffold/publisher/app.py — seed_challenge gains optional family_id; lane wired into lifespan startup/shutdown (env-gated, default off) Co-Authored-By: Claude Opus 4.8 (1M context) --- INJECT.md | 100 ++++++++++++++ inject_verify.py | 104 +++++++++++++++ measure_inject.py | 147 +++++++++++++++++++++ scaffold/publisher/app.py | 28 +++- scaffold/publisher/inject.py | 249 +++++++++++++++++++++++++++++++++++ 5 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 INJECT.md create mode 100644 inject_verify.py create mode 100644 measure_inject.py create mode 100644 scaffold/publisher/inject.py diff --git a/INJECT.md b/INJECT.md new file mode 100644 index 00000000..1fa03b75 --- /dev/null +++ b/INJECT.md @@ -0,0 +1,100 @@ +# Inject lane — continuous additive test-puzzle injection + +A second challenge stream that runs **alongside** the native refill loop so we can +measure, on the **live board**, how unpredictable / harder SAT instances affect +solve time and scores — **without risking board availability**. + +Native refill is untouched. If this lane is off or stalls, the board still fills +normally. Best of both worlds: native keeps the board full, this lane mixes in +tagged test puzzles. + +**Default OFF.** With `CATHEDRAL_INJECT_ENABLED` unset, the publisher is +byte-identical to before — the lane never starts. + +## What it does + +Each pass (default every 60s), for each configured tier, it retires its own ready +challenges and tops up to a small target of **extra** active challenges that are: + +- **Isolated** — minted under a distinct `family_id` (default `gentest`). Native + refill counts/retires only `synthetic_boolean_v1`, so injected challenges never + eat native slots and native never retires them. The lane manages its own family. +- **Served identically** — `cnf_source='local'`, so they are served, HMAC-fetch- + gated, witness-verified on submit, and scored exactly like native challenges: + one signed solve per `(challenge, hotkey)`, same dedup, same proportional + scoring. +- **Identifiable** — the `challenge_id` embeds the family label and still parses + to the right tier, e.g. `sat-t2-random-3sat-gentest-`. Every solve + row carries the `challenge_id`, so measurement is a substring match on + `-gentest-`. + +### What makes an injected puzzle different from a native one + +- **Seed (headline variable).** Native derives its seed from + `sha256(utc_hour:tier:seq)` — recomputable offline, so the planted answer is + predictable and pre-solvable. This lane seeds from `secrets.randbits(63)` (OS + entropy) — unpredictable, like the standalone generator. +- **Method / shape.** Per-tier configurable; defaults to the **native** method + and shape for an apples-to-apples *seed-only* comparison. Override to test + harder instances (e.g. force `ajm`, or raise `n_vars`). + +## ⚠️ It moves real income + +There is **no zero-value mode**. A solved injected challenge pays real tier weight +(weight derives purely from the tier parsed out of the id). So injecting adds +extra earning opportunities and **will shift live scores** — which is the point of +the experiment, but means you should: + +- keep the per-tier target **small**, +- announce it to miners, and +- run it for a bounded window, then turn it off and let the lane's challenges + retire. + +This is a **live-prod change** — enabling it sets env on the live publisher. It is +purely additive and reversible (unset the flag; injected challenges age out), but +treat it as a deliberate, announced experiment. + +## Enable (on the publisher service) + +```bash +CATHEDRAL_INJECT_ENABLED=1 # master switch (default off) +CATHEDRAL_INJECT_FAMILY=gentest # isolation family_id +CATHEDRAL_INJECT_TIERS=2 # which tiers to inject (default 1,2) +CATHEDRAL_INJECT_TARGET_T2=5 # active injected challenges to hold per tier +# optional — make injected puzzles differ from native beyond the seed: +# CATHEDRAL_INJECT_METHOD_T2=ajm +# CATHEDRAL_INJECT_NVARS_T2=600 CATHEDRAL_INJECT_NCLAUSES_T2=2556 +# CATHEDRAL_INJECT_INTERVAL_SECONDS=60 +``` + +Retirement reuses the native age / distinct-solver thresholds +(`CATHEDRAL_OPEN_WINDOW_RETIRE_AFTER_SECONDS`, +`CATHEDRAL_OPEN_WINDOW_RETIRE_AFTER_DISTINCT_SOLVERS`), scoped to the injected +family. + +## Measure + +```bash +python measure_inject.py --family gentest --window-hours 24 +``` + +Reports, per tier, for injected vs native over the same window: challenge count, +solved %, time-to-first-solve (min / median / mean), and mean distinct solvers — +the side-by-side answer to "do the unpredictable/harder injected instances solve +slower, and who solves them?" + +## Verify (no live state) + +```bash +python inject_verify.py # expect: INJECT VERIFY PASS +``` + +Proves isolation (native counting/retirement never touches injected and +vice-versa), identifiability (family label + correct tier parse), and serve parity +(`cnf_source='local'`, active) on a throwaway store. + +## Turn off + +Unset `CATHEDRAL_INJECT_ENABLED` and restart. The lane stops minting; existing +injected challenges retire on the normal age / solver-cap schedule. Nothing else +is affected. diff --git a/inject_verify.py b/inject_verify.py new file mode 100644 index 00000000..06d620fe --- /dev/null +++ b/inject_verify.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""inject_verify.py — gate for the additive inject lane (scaffold/publisher/ +inject.py). Proves the load-bearing invariants WITHOUT touching live state: + + 1. ISOLATION: injected challenges (distinct family_id) are NOT counted by the + native refill loop, and native challenges are NOT counted by the inject lane. + 2. NON-INTERFERENCE: native retirement never touches injected challenges and + vice-versa. + 3. IDENTIFIABILITY: injected challenge_ids carry the family label AND still + parse to the correct tier (so scoring weights them correctly). + 4. SERVE PARITY: injected challenges are cnf_source='local' and active, so the + board serve query returns them exactly like native ones. + +Run: python inject_verify.py → expect "INJECT VERIFY PASS" +""" +from __future__ import annotations + +import os +import sys +import tempfile + +from scaffold.dimacs import gen_planted_3sat +from scaffold.publisher import inject, refill +from scaffold.publisher.app import seed_challenge +from scaffold.publisher.store import Store +from scaffold.publisher.weights import tier_from_challenge_id + +FAILS: list[str] = [] + + +def check(name: str, cond: bool) -> None: + print(f" [{'PASS' if cond else 'FAIL'}] {name}") + if not cond: + FAILS.append(name) + + +def main() -> int: + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + tmp.close() + store = Store(tmp.path) if hasattr(tmp, "path") else Store(tmp.name) + + family = "gentest" + tier = 1 + nat_cnf, _ = gen_planted_3sat(1, 5, 21, method="biased") + inj_seed = 0x1234_5678_9abc_def0 & ((1 << 63) - 1) + inj_cnf, _ = gen_planted_3sat(inj_seed, 5, 21, method="biased") + + nat_cid = refill.mint_challenge_id("testblk", tier, 0) + inj_cid = inject.inject_cid(tier, family, inj_seed) + + seed_challenge(store, challenge_id=nat_cid, tier=tier, cnf_text=nat_cnf, status="active") + seed_challenge(store, challenge_id=inj_cid, tier=tier, cnf_text=inj_cnf, + status="active", family_id=family) + + print("1. ISOLATION") + check("native count sees only the native challenge", + refill.active_local_count(store, tier) == 1) + check("inject count sees only the injected challenge", + inject.active_inject_count(store, tier, family) == 1) + + print("2. IDENTIFIABILITY") + check("injected cid carries the family label", f"-{family}-" in inj_cid) + check("injected cid parses to the correct tier", + tier_from_challenge_id(inj_cid) == tier) + check("native cid does NOT carry the family label", f"-{family}-" not in nat_cid) + + print("3. SERVE PARITY") + served = store.query( + "SELECT challenge_id, family_id, cnf_source FROM lane_challenges " + "WHERE status='active' AND cnf_source='local'") + served_ids = {r["challenge_id"] for r in served} + check("both challenges are in the active local serve set", + nat_cid in served_ids and inj_cid in served_ids) + check("injected challenge is cnf_source='local'", + all(r["cnf_source"] == "local" for r in served if r["challenge_id"] == inj_cid)) + + print("4. NON-INTERFERENCE (retirement)") + # record one distinct solve on the injected challenge, set the solver-cap to 1 + refill.record_solve(store, inj_cid, "5HotkeyAAA") + os.environ["CATHEDRAL_OPEN_WINDOW_RETIRE_AFTER_DISTINCT_SOLVERS"] = "1" + inj_retired = inject.retire_inject_ready(store, tier, family) + check("inject retirement retired the saturated injected challenge", + inj_retired == 1) + check("injected challenge is now retired", + store.query("SELECT status FROM lane_challenges WHERE challenge_id=?", + (inj_cid,))[0]["status"] == "retired") + check("native challenge is untouched by inject retirement", + store.query("SELECT status FROM lane_challenges WHERE challenge_id=?", + (nat_cid,))[0]["status"] == "active") + # native retirement must likewise never touch the injected family + nat_retired = refill.retire_ready(store, tier) + check("native retirement does not retire any injected challenge (none left active)", + nat_retired == 0) + + print() + if FAILS: + print(f"INJECT VERIFY FAIL — {len(FAILS)} check(s): {', '.join(FAILS)}") + return 1 + print("INJECT VERIFY PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/measure_inject.py b/measure_inject.py new file mode 100644 index 00000000..f6826af2 --- /dev/null +++ b/measure_inject.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""measure_inject.py — read live solve-time + solver-spread for the additive +inject lane (see scaffold/publisher/inject.py / INJECT.md) and compare it to the +native board over the same window. + +Pure read. Touches no live state. Run it on the box (where DATABASE_URL points at +the prod Postgres) or against a SQLite copy: + + python measure_inject.py # PG via DATABASE_URL / CATHEDRAL_DB_PATH + python measure_inject.py --db publisher.db # explicit SQLite path + python measure_inject.py --family gentest --window-hours 24 + +For each family it reports, per challenge: time-to-first-solve (first solve minus +mint) and distinct-solver count, then an aggregate (count, solved %, min/median/ +mean time-to-first-solve, mean solvers). Injected vs native side by side answers: +do the unpredictable / harder injected instances solve slower, and who solves +them? +""" +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime, timezone + +from scaffold.publisher.store import Store + +NATIVE_FAMILY = "synthetic_boolean_v1" + + +def _parse_iso(s: str | None) -> datetime | None: + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + return None + + +def _rows_for_family(store: Store, family: str, since_iso: str) -> list[dict]: + """Per-challenge: mint time, first solve, distinct solver count, in window.""" + rows = store.query( + "SELECT c.challenge_id AS cid, c.tier AS tier, c.status AS status, " + " c.created_at_iso AS created, " + " MIN(s.solved_at_iso) AS first_solved, " + " COUNT(DISTINCT s.miner_hotkey) AS n_solvers " + "FROM lane_challenges c " + "LEFT JOIN lane_challenge_solves s ON s.challenge_id = c.challenge_id " + "WHERE c.family_id = ? AND c.created_at_iso > ? " + "GROUP BY c.challenge_id, c.tier, c.status, c.created_at_iso", + (family, since_iso)) + out = [] + for r in rows: + created = _parse_iso(r["created"]) + first = _parse_iso(r["first_solved"]) + ttfs = (first - created).total_seconds() if (created and first) else None + out.append({ + "cid": r["cid"], "tier": int(r["tier"]), "status": r["status"], + "created": created, "ttfs": ttfs, "n_solvers": int(r["n_solvers"] or 0), + }) + return out + + +def _median(xs: list[float]) -> float | None: + if not xs: + return None + xs = sorted(xs) + n = len(xs) + mid = n // 2 + return xs[mid] if n % 2 else (xs[mid - 1] + xs[mid]) / 2 + + +def _summary(rows: list[dict]) -> dict: + solved = [r for r in rows if r["ttfs"] is not None] + ttfs = [r["ttfs"] for r in solved] + solvers = [r["n_solvers"] for r in rows] + return { + "challenges": len(rows), + "solved": len(solved), + "solved_pct": (100.0 * len(solved) / len(rows)) if rows else 0.0, + "ttfs_min": min(ttfs) if ttfs else None, + "ttfs_median": _median(ttfs), + "ttfs_mean": (sum(ttfs) / len(ttfs)) if ttfs else None, + "solvers_mean": (sum(solvers) / len(solvers)) if solvers else 0.0, + } + + +def _fmt_secs(x: float | None) -> str: + return "—" if x is None else (f"{x:.1f}s" if x < 120 else f"{x / 60:.1f}m") + + +def _print_family(label: str, rows: list[dict], examples: int) -> None: + print(f"\n=== {label} ===") + if not rows: + print(" (no challenges in window)") + return + by_tier: dict[int, list[dict]] = {} + for r in rows: + by_tier.setdefault(r["tier"], []).append(r) + for tier in sorted(by_tier): + s = _summary(by_tier[tier]) + print(f" tier {tier}: {s['challenges']} challenges, " + f"{s['solved']} solved ({s['solved_pct']:.0f}%) | " + f"time-to-first-solve min={_fmt_secs(s['ttfs_min'])} " + f"median={_fmt_secs(s['ttfs_median'])} mean={_fmt_secs(s['ttfs_mean'])} | " + f"mean distinct solvers={s['solvers_mean']:.1f}") + if examples: + print(f" -- {min(examples, len(rows))} example challenges --") + shown = sorted((r for r in rows if r["ttfs"] is not None), + key=lambda r: r["ttfs"])[:examples] + for r in shown: + print(f" t{r['tier']} {r['cid'][:54]:54} " + f"ttfs={_fmt_secs(r['ttfs']):>7} solvers={r['n_solvers']}") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--db", default=os.environ.get("CATHEDRAL_DB_PATH", "publisher.db"), + help="SQLite path, or a postgres DSN; DATABASE_URL overrides for PG") + p.add_argument("--family", default="gentest", help="injected family_id to measure") + p.add_argument("--native-family", default=NATIVE_FAMILY, + help="native family to compare against") + p.add_argument("--window-hours", type=int, default=24, + help="only challenges minted within this window") + p.add_argument("--examples", type=int, default=5, + help="example injected challenges to list (0 to suppress)") + args = p.parse_args(argv) + + since = datetime.now(timezone.utc).timestamp() - args.window_hours * 3600 + since_iso = datetime.fromtimestamp(since, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z") + + store = Store(args.db) + print(f"DB backend: {store.backend} | window: last {args.window_hours}h " + f"(since {since_iso})") + + inj = _rows_for_family(store, args.family, since_iso) + nat = _rows_for_family(store, args.native_family, since_iso) + _print_family(f"INJECTED family='{args.family}'", inj, args.examples) + _print_family(f"NATIVE family='{args.native_family}'", nat, args.examples) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scaffold/publisher/app.py b/scaffold/publisher/app.py index 26993fc3..61aa8fbf 100644 --- a/scaffold/publisher/app.py +++ b/scaffold/publisher/app.py @@ -155,6 +155,7 @@ async def __call__(self, scope, receive, send): app.state.public_key_hex = pub_hex app.state.signing_key_hex = key_hex app.state.refill_task = None + app.state.inject_task = None app.state.seed_task = None app.state.arena_eval_task = None app.state.arena_payout_task = None @@ -173,6 +174,22 @@ async def _start_refill(): app.state.refill_task = asyncio.create_task( refill.refill_loop(store, log=loop_log)) + # ---- Inject lane: continuous ADDITIVE test-puzzle injection (env-gated) - + # Runs ALONGSIDE refill under a distinct family_id (default 'gentest') so it + # can never starve the board — native refill is untouched and keeps the + # board full even if this lane is off or stalls. Default OFF. The injected + # challenges are served/gated/verified/scored identically to native ones and + # are tagged in their challenge_id for live solve-time + score measurement. + # See INJECT.md and measure_inject.py. + @app.on_event("startup") + async def _start_inject(): + from . import inject + if inject.inject_enabled(): + import asyncio + inj_log = lambda evt, **kw: print(f"[inject] {evt} {kw}") # noqa: E731 + app.state.inject_task = asyncio.create_task( + inject.inject_loop(store, log=inj_log)) + # ---- Lane S: arena eval loop (env-gated, TASK 1) ---------------------- # Periodically scores registered pending solvers and, on a record-fall, # emits a signed v6 row crediting the new champion's owner. Default OFF. @@ -259,7 +276,7 @@ async def _seed_runner(): @app.on_event("shutdown") async def _stop_refill(): - for attr in ("refill_task", "seed_task", "arena_eval_task", "arena_payout_task"): + for attr in ("refill_task", "inject_task", "seed_task", "arena_eval_task", "arena_payout_task"): task = getattr(app.state, attr, None) if task is not None: task.cancel() @@ -989,7 +1006,12 @@ def _cnf_put_on_mint(store: Store, challenge_id: str, cnf_text: str) -> None: def seed_challenge(store: Store, *, challenge_id: str, tier: int, cnf_text: str, status: str = "active", difficulty_label: str | None = None, score_multiplier: float = 1.0, - designated_solver_digest: str | None = None) -> None: + designated_solver_digest: str | None = None, + family_id: str | None = None) -> None: + # family_id defaults to the native family. The additive inject lane (see + # inject.py / INJECT.md) passes a distinct family so its challenges are + # isolated from native refill counting/retirement yet still served, gated, + # verified, and scored identically (cnf_source stays 'local'). from ..dimacs import parse_cnf n_vars, clauses = parse_cnf(cnf_text) cnf_bytes = len(cnf_text.encode("utf-8")) @@ -1000,7 +1022,7 @@ def _do(conn): "cnf_text, cnf_sha256, cnf_bytes, num_vars, num_clauses, status, " "score_multiplier, difficulty_label, designated_solver_digest, created_at_iso) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (challenge_id, _FAMILY, tier, cnf_text, sha256_hex(cnf_text), cnf_bytes, + (challenge_id, family_id or _FAMILY, tier, cnf_text, sha256_hex(cnf_text), cnf_bytes, n_vars, len(clauses), status, score_multiplier, difficulty_label, designated_solver_digest, _now_iso_ms())) store.write(_do) diff --git a/scaffold/publisher/inject.py b/scaffold/publisher/inject.py new file mode 100644 index 00000000..7bb464a9 --- /dev/null +++ b/scaffold/publisher/inject.py @@ -0,0 +1,249 @@ +"""Continuous, ADDITIVE challenge-injection lane (env-gated, default OFF). + +Purpose +------- +Run a SECOND stream of challenges alongside the native refill loop so we can +measure, on the LIVE board, how unpredictable / harder instances affect solve +time and scores — WITHOUT risking board availability. The native refill loop is +untouched: if this lane is off or stalls, the board still fills normally. Best of +both worlds — native keeps the board full, this lane mixes in test puzzles. + +How it stays additive + isolated +--------------------------------- +* Distinct family_id (default 'gentest'). Native refill counts/retires only its + own family (``synthetic_boolean_v1``), so injected challenges never eat native + slots and native never retires them. This lane manages its own family. +* ``cnf_source='local'`` — injected challenges are served, HMAC-fetch-gated, + witness-verified on submit, and scored EXACTLY like native ones: one signed + solve per (challenge, hotkey), same dedup, same proportional scoring. + NOTE: a solved injected challenge DOES pay real tier weight — there is no + zero-value mode — so keep the per-tier target small. +* Identifiable: the challenge_id embeds the family label, e.g. + ``sat-t2-random-3sat-gentest-``. The tier still parses + (``tier_from_challenge_id`` reads ``t{N}`` right after ``sat-t``), and every + solve row carries the challenge_id, so measurement is a substring filter on + ``--`` (see measure_inject.py). + +What makes an injected puzzle different from a native one +--------------------------------------------------------- +* SEED: native derives its seed from ``sha256(utc_hour:tier:seq)`` — recomputable + offline, so the planted answer is predictable. This lane seeds from + ``secrets.randbits(63)`` (OS entropy) — unpredictable, like the standalone + generator. This is the headline variable under test. +* METHOD / SHAPE: configurable per tier (default = the native method/shape for an + apples-to-apples *seed-only* comparison; override to test harder instances). + +Default OFF (``CATHEDRAL_INJECT_ENABLED`` unset/false). When off this module does +nothing and the publisher is byte-identical to before. + +Env knobs +--------- +* ``CATHEDRAL_INJECT_ENABLED`` — master switch (default off) +* ``CATHEDRAL_INJECT_FAMILY`` — isolation family_id (default ``gentest``) +* ``CATHEDRAL_INJECT_TIERS`` — comma list of tiers (default ``1,2``) +* ``CATHEDRAL_INJECT_TARGET_T{N}`` — active injected challenges to hold (default 5) +* ``CATHEDRAL_INJECT_METHOD_T{N}`` — planting method (default = native method_for tier) +* ``CATHEDRAL_INJECT_NVARS_T{N}`` / + ``CATHEDRAL_INJECT_NCLAUSES_T{N}`` — instance shape (default = native shape_for tier) +* ``CATHEDRAL_INJECT_INTERVAL_SECONDS``— loop period (default 60) + +Retirement reuses the native age / distinct-solver thresholds +(``CATHEDRAL_OPEN_WINDOW_RETIRE_AFTER_*``) but scoped to the injected family. +""" +from __future__ import annotations + +import asyncio +import os +import secrets + +from . import refill +from .store import Store + +# --- config defaults -------------------------------------------------------- +_DEFAULT_FAMILY = "gentest" +_DEFAULT_TARGET = 5 # small: bounds extra income + CPU +_DEFAULT_TIERS = (1, 2) +_DEFAULT_INTERVAL_SECONDS = 60 + + +def inject_enabled() -> bool: + return os.environ.get("CATHEDRAL_INJECT_ENABLED", "").strip().lower() in { + "1", "true", "yes", "on"} + + +def inject_family() -> str: + return os.environ.get("CATHEDRAL_INJECT_FAMILY", "").strip() or _DEFAULT_FAMILY + + +def inject_tiers() -> list[int]: + raw = os.environ.get("CATHEDRAL_INJECT_TIERS", "").strip() + if not raw: + return list(_DEFAULT_TIERS) + out = [int(tok) for tok in (t.strip() for t in raw.split(",")) if tok.isdigit()] + return out or list(_DEFAULT_TIERS) + + +def inject_target(tier: int) -> int: + return refill._env_int(f"CATHEDRAL_INJECT_TARGET_T{tier}", _DEFAULT_TARGET) + + +def inject_method(tier: int) -> str: + """Planting method for an injected tier. Defaults to the native method for the + tier (apples-to-apples; the only variable is the seed). Override per tier with + ``CATHEDRAL_INJECT_METHOD_T{N}`` (e.g. force ``ajm`` on tier1).""" + override = os.environ.get(f"CATHEDRAL_INJECT_METHOD_T{tier}", "").strip().lower() + return override or refill.method_for(tier) + + +def inject_shape(tier: int) -> tuple[int, int]: + """(n_vars, n_clauses) for an injected tier. Defaults to the native shape; + override with ``CATHEDRAL_INJECT_NVARS_T{N}`` / ``_NCLAUSES_T{N}`` to make + injected instances harder/easier than native for the experiment.""" + base_n, base_m = refill.shape_for(tier) + n = refill._env_int(f"CATHEDRAL_INJECT_NVARS_T{tier}", base_n) + m = refill._env_int(f"CATHEDRAL_INJECT_NCLAUSES_T{tier}", base_m) + return n, m + + +def inject_interval_seconds() -> int: + return refill._env_int("CATHEDRAL_INJECT_INTERVAL_SECONDS", _DEFAULT_INTERVAL_SECONDS) + + +# --- id + counting ---------------------------------------------------------- +def _inject_seed() -> int: + """Unpredictable 63-bit seed (OS entropy) — the whole point of the lane. + Contrast refill.mint_seed, which is sha256(utc_hour:tier:seq) and therefore + recomputable offline.""" + return secrets.randbits(63) + + +def inject_cid(tier: int, family: str, seed: int) -> str: + """``sat-t{tier}-random-3sat-{family}-{seed:016x}``. Keeps the ``sat-t{N}-`` + prefix so tier_from_challenge_id parses the tier; embeds the family label for + filtering and the random seed hex for uniqueness.""" + return f"sat-t{tier}-random-3sat-{family}-{seed:016x}" + + +def active_inject_count(store: Store, tier: int, family: str) -> int: + rows = store.query( + "SELECT COUNT(*) AS n FROM lane_challenges " + "WHERE family_id=? AND tier=? AND status='active' AND cnf_source='local'", + (family, tier)) + return rows[0]["n"] + + +def retire_inject_ready(store: Store, tier: int, family: str) -> int: + """Retire injected challenges that are old enough or saturated. Mirrors + refill.retire_ready but scoped to the injected family — it never touches the + native family. Same age / distinct-solver thresholds as native.""" + now = refill._now_iso() + age_cutoff = refill._iso_before(refill.retire_after_seconds()) + retired = 0 + + def _age(conn): + cur = conn.execute( + "UPDATE lane_challenges SET status='retired', cnf_text='', updated_at_iso=? " + "WHERE family_id=? AND tier=? AND status='active' AND cnf_source='local' " + "AND created_at_iso <= ?", + (now, family, tier, age_cutoff)) + return int(cur.rowcount or 0) + retired += store.write(_age) + + threshold = refill.retire_after_distinct_solvers() + + def _solved(conn): + cur = conn.execute( + "UPDATE lane_challenges SET status='retired', cnf_text='', updated_at_iso=? " + "WHERE family_id=? AND tier=? AND status='active' AND cnf_source='local' " + "AND challenge_id IN (" + " SELECT challenge_id FROM lane_challenge_solves " + " GROUP BY challenge_id HAVING COUNT(DISTINCT miner_hotkey) >= ?)", + (now, family, tier, threshold)) + return int(cur.rowcount or 0) + retired += store.write(_solved) + + if retired: + from . import board_cache as _bc + _bc.invalidate_all() + return retired + + +def _commit_injected(store: Store, cid: str, tier: int, family: str, cnf_text: str) -> None: + """Write one injected challenge via the shared seed_challenge path, tagged + with the injected family. Stamps updated_at_iso=created_at_iso to match the + native mint shape.""" + from .app import seed_challenge + seed_challenge(store, challenge_id=cid, tier=tier, cnf_text=cnf_text, + status="active", family_id=family, + difficulty_label=f"inject:{family}") + + def _stamp(conn, cid=cid): + conn.execute( + "UPDATE lane_challenges SET updated_at_iso=created_at_iso WHERE challenge_id=?", + (cid,)) + store.write(_stamp) + + +# --- the loop --------------------------------------------------------------- +async def inject_tier_async(store: Store, tier: int, family: str, + log=lambda *a, **k: None) -> dict: + """One inject pass for a tier: retire ready, then top up to target with + OS-entropy-seeded mints. Gen runs off the event loop (refill._gen_cnf forks a + nice(19) child that releases the GIL via Pipe.poll), one mint at a time.""" + retired = await asyncio.to_thread(retire_inject_ready, store, tier, family) + target = inject_target(tier) + n_vars, n_clauses = inject_shape(tier) + method = inject_method(tier) + + minted = 0 + guard = 0 + while (await asyncio.to_thread(active_inject_count, store, tier, family) < target + and guard < target * 4 + 8): + guard += 1 + seed = _inject_seed() + cid = inject_cid(tier, family, seed) + if store.query("SELECT status FROM lane_challenges WHERE challenge_id=?", (cid,)): + continue # astronomically unlikely seed collision — skip + cnf_text = await asyncio.to_thread(refill._gen_cnf, seed, n_vars, n_clauses, method) + await asyncio.to_thread(_commit_injected, store, cid, tier, family, cnf_text) + minted += 1 + log("inject_mint", tier=tier, cid=cid[:40], method=method, + seed_hex=f"{seed:016x}", shape=(n_vars, n_clauses)) + await asyncio.sleep(0) # yield between mints + + active = await asyncio.to_thread(active_inject_count, store, tier, family) + return {"tier": tier, "family": family, "retired": retired, "minted": minted, + "active": active, "target": target, "method": method, + "shape": (n_vars, n_clauses)} + + +async def inject_once_async(store: Store, *, log=lambda *a, **k: None) -> list[dict]: + """One full inject+retire pass across configured tiers.""" + family = inject_family() + return [await inject_tier_async(store, tier, family, log) for tier in inject_tiers()] + + +async def inject_loop(store: Store, *, interval_seconds: int | None = None, + log=lambda *a, **k: None, stop_event: asyncio.Event | None = None) -> None: + """Asyncio task: periodic additive inject+retire. Mirrors refill.refill_loop. + Never blocks the event loop (gen forks; DB calls run via to_thread).""" + interval = interval_seconds or inject_interval_seconds() + family = inject_family() + log("inject_loop_start", interval=interval, family=family, tiers=inject_tiers(), + targets={t: inject_target(t) for t in inject_tiers()}) + try: + while not (stop_event and stop_event.is_set()): + try: + summary = await inject_once_async(store, log=log) + log("inject_pass", summary=summary) + except Exception as e: # never let a transient error kill the loop + log("inject_error", error=str(e)) + try: + await asyncio.wait_for( + stop_event.wait() if stop_event else asyncio.sleep(interval), + timeout=interval) + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + log("inject_loop_cancelled") + raise From c5ca9d004212bb83b49af84ca68abd7fd7fb6fcd Mon Sep 17 00:00:00 2001 From: Ancient Runner <7602667+wallscaler@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:10:40 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(inject):=20add=20difficulty-ladder=20p?= =?UTF-8?q?resets=20(seed=20=E2=86=92=20ajm@400=20=E2=86=92=20scale=20n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste-a-rung-and-measure guide: rung 0 isolates the seed (same shape as native), rung 1 forces ajm at m/n≈4.26 to isolate hardness, rung 2+ scales n at a fixed ratio. Notes the stop condition (solved% → 0 = overshoot) and the non-monotonic threshold-3SAT caveat (P0 spike). Co-Authored-By: Claude Opus 4.8 (1M context) --- INJECT.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/INJECT.md b/INJECT.md index 1fa03b75..5f3f0973 100644 --- a/INJECT.md +++ b/INJECT.md @@ -83,6 +83,48 @@ solved %, time-to-first-solve (min / median / mean), and mean distinct solvers the side-by-side answer to "do the unpredictable/harder injected instances solve slower, and who solves them?" +## Difficulty ladder — paste a rung, measure, climb + +The goal is **variance in solve time**. Climb one rung at a time and re-run +`measure_inject.py` after each. Two independent variables, in order: + +**Rung 0 — same shape as native (isolate the SEED).** Injected instances match +native exactly except the seed is OS-entropy instead of `sha256(utc_hour…)`. If +native solves suspiciously faster/tighter than `gentest` here, someone is +pre-solving the predictable seed — that's the exploit, surfaced. + +```bash +CATHEDRAL_INJECT_ENABLED=1 CATHEDRAL_INJECT_TIERS=2 CATHEDRAL_INJECT_TARGET_T2=5 +# method + shape unset → inherit native tier-2 (ajm, 400 vars / 1704 clauses) +``` + +**Rung 1 — AJM at the phase transition (isolate HARDNESS).** Force the unbiased +`ajm` method at m/n ≈ 4.26. `biased` is easy at any size, so this is the first +rung that can actually move solve time. + +```bash +CATHEDRAL_INJECT_METHOD_T2=ajm CATHEDRAL_INJECT_NVARS_T2=400 CATHEDRAL_INJECT_NCLAUSES_T2=1704 +``` + +**Rung 2+ — scale n, hold the ratio.** Raise `NVARS` keeping `NCLAUSES ≈ 4.26 × +NVARS`. Hardness climbs steeply with n for `ajm` near threshold. + +```bash +CATHEDRAL_INJECT_METHOD_T2=ajm CATHEDRAL_INJECT_NVARS_T2=800 CATHEDRAL_INJECT_NCLAUSES_T2=3408 +# next: 1500 / 6390 … keep going until solve-time spreads +``` + +**Stop climbing when challenges start getting _zero_ solves** — that means you +overshot the field: variance turns into "too hard for everyone," those slots stop +differentiating, and they still cost real weight on the ones that do solve. Watch +`solved %` in the report; if it falls toward 0 on a rung, step back one. + +Caveat: threshold random-3SAT hardness is **not perfectly monotonic** (the +difficulty-ladder open question — "falsified by the P0 spike"). `ajm`-at-larger-n +is the right cheap read on variance, but expect a noisy curve, not a clean line; +the solver-robust ladder (reduced-round preimage / cube-of-real-instance) is a +later piece. + ## Verify (no live state) ```bash From 3426ecbb57a79922cc7b333bf868a87530fed2d2 Mon Sep 17 00:00:00 2001 From: Ancient Runner <7602667+wallscaler@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:32:46 -0400 Subject: [PATCH 3/3] fix(inject): opaque seed-secret challenge ids + fail-closed family guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a blocker: inject_cid embedded the raw seed (sat-...-), and the planted model derives from random.Random(seed) — so anyone could read the seed off the public board and reconstruct the answer with zero solving, defeating the whole experiment. Fixes: * inject_cid suffix is now sha256(tier:family:seed)[:16] — a one-way hash. The seed is never published, stored, or logged (dropped seed_hex from the mint log). The served CNF is the only artifact and is unreproducible from public fields. * family_is_safe(): the lane refuses the native family (synthetic_boolean_v1) and any non-slug family; inject_once_async fails closed (returns []) on a bad family so it can never collide with native counting/retirement on real emissions. * collision pre-check moved off the event loop (asyncio.to_thread) — postgres getconn() blocks; matches every other DB call in the loop. * inject_verify.py: new §0 SEED SECRECY proves the public id can't regenerate the CNF or the planted answer; §5 FAMILY GUARD; tier switched to 2 so the tier-parse check is meaningful (tier_from_challenge_id defaults to 1 on failure). * INJECT.md: documents the opaque id, seed-secrecy, and the family guard. Note (verified, not a live bug): the native live board does NOT have this leak — its challenge_id seed and served CNF are decoupled by the background pre-gen queue (served CNF is seeded by time.monotonic(), unrelated to the id's mint_seed). Confirmed by regenerating live CNFs from the public id suffix → sha256 mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- INJECT.md | 11 ++++++- inject_verify.py | 42 ++++++++++++++++++++++--- scaffold/publisher/inject.py | 59 +++++++++++++++++++++++++++++++----- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/INJECT.md b/INJECT.md index 5f3f0973..1771d224 100644 --- a/INJECT.md +++ b/INJECT.md @@ -24,9 +24,18 @@ challenges and tops up to a small target of **extra** active challenges that are one signed solve per `(challenge, hotkey)`, same dedup, same proportional scoring. - **Identifiable** — the `challenge_id` embeds the family label and still parses - to the right tier, e.g. `sat-t2-random-3sat-gentest-`. Every solve + to the right tier, e.g. `sat-t2-random-3sat-gentest-`. Every solve row carries the `challenge_id`, so measurement is a substring match on `-gentest-`. +- **Seed-secret** — the suffix is a one-way hash of the seed, **not the seed**. + The seed is never published, never stored, never logged; the served CNF is the + only artifact and cannot be reproduced from any public board field. (An earlier + draft put the raw seed in the id, which let anyone reconstruct the planted + answer with no solving — caught in review; `inject_verify.py §0` now proves it + can't.) +- **Fail-closed family** — the lane refuses to run on the native family + (`synthetic_boolean_v1`) or any non-slug family, so a bad `CATHEDRAL_INJECT_FAMILY` + can never collide with native counting/retirement on real emissions. ### What makes an injected puzzle different from a native one diff --git a/inject_verify.py b/inject_verify.py index 06d620fe..3492daa8 100644 --- a/inject_verify.py +++ b/inject_verify.py @@ -19,7 +19,7 @@ import sys import tempfile -from scaffold.dimacs import gen_planted_3sat +from scaffold.dimacs import gen_planted_3sat, verify_witness from scaffold.publisher import inject, refill from scaffold.publisher.app import seed_challenge from scaffold.publisher.store import Store @@ -35,15 +35,19 @@ def check(name: str, cond: bool) -> None: def main() -> int: + import hashlib + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) tmp.close() - store = Store(tmp.path) if hasattr(tmp, "path") else Store(tmp.name) + store = Store(tmp.name) family = "gentest" - tier = 1 - nat_cnf, _ = gen_planted_3sat(1, 5, 21, method="biased") + tier = 2 # not 1: tier_from_challenge_id defaults to 1 on parse failure, so a + # non-1 tier makes the identifiability check actually meaningful. + n_vars, n_clauses = 60, 255 + nat_cnf, _ = gen_planted_3sat(1, n_vars, n_clauses, method="biased") inj_seed = 0x1234_5678_9abc_def0 & ((1 << 63) - 1) - inj_cnf, _ = gen_planted_3sat(inj_seed, 5, 21, method="biased") + inj_cnf, inj_planted = gen_planted_3sat(inj_seed, n_vars, n_clauses, method="biased") nat_cid = refill.mint_challenge_id("testblk", tier, 0) inj_cid = inject.inject_cid(tier, family, inj_seed) @@ -52,6 +56,26 @@ def main() -> int: seed_challenge(store, challenge_id=inj_cid, tier=tier, cnf_text=inj_cnf, status="active", family_id=family) + print("0. SEED SECRECY (the public id must NOT reveal the planted answer)") + # The injected challenge's public id and the public board fields (tier, family, + # num_vars, num_clauses, cnf_sha256). An attacker reads the id suffix and tries + # to use it as the seed — the way the OLD {seed:016x} id leaked. + suffix = inj_cid.rsplit("-", 1)[-1] + check("seed hex does not appear anywhere in the public id", + f"{inj_seed:016x}" not in inj_cid) + guessed_seed = int(suffix, 16) + check("id suffix does not decode to the real seed", + guessed_seed != inj_seed) + guessed_cnf, guessed_planted = gen_planted_3sat(guessed_seed, n_vars, n_clauses, method="biased") + check("CNF regenerated from the public id does NOT match the served CNF", + hashlib.sha256(guessed_cnf.encode()).hexdigest() + != hashlib.sha256(inj_cnf.encode()).hexdigest()) + check("planted answer derived from the public id does NOT solve the served CNF", + not verify_witness(inj_cnf, guessed_planted)) + # sanity: the REAL planted answer does solve it (so the challenge is genuine) + check("the real planted answer (secret) does solve the served CNF", + verify_witness(inj_cnf, inj_planted)) + print("1. ISOLATION") check("native count sees only the native challenge", refill.active_local_count(store, tier) == 1) @@ -92,6 +116,14 @@ def main() -> int: check("native retirement does not retire any injected challenge (none left active)", nat_retired == 0) + print("5. FAMILY GUARD (fail closed on a dangerous family)") + ok_native, _ = inject.family_is_safe("synthetic_boolean_v1") + check("refuses the native family", ok_native is False) + ok_blank, _ = inject.family_is_safe("") + check("refuses an empty/invalid family", ok_blank is False) + ok_good, _ = inject.family_is_safe("gentest") + check("accepts a normal injected family", ok_good is True) + print() if FAILS: print(f"INJECT VERIFY FAIL — {len(FAILS)} check(s): {', '.join(FAILS)}") diff --git a/scaffold/publisher/inject.py b/scaffold/publisher/inject.py index 7bb464a9..9cb4a57a 100644 --- a/scaffold/publisher/inject.py +++ b/scaffold/publisher/inject.py @@ -56,6 +56,9 @@ import os import secrets +import hashlib +import re + from . import refill from .store import Store @@ -65,6 +68,12 @@ _DEFAULT_TIERS = (1, 2) _DEFAULT_INTERVAL_SECONDS = 60 +# The native lane's family — injected challenges must NEVER use it, or this lane's +# counting/retirement would collide with native refill on real emissions. +_NATIVE_FAMILY = refill._FAMILY +# Family ids become part of public challenge_ids; keep them to a safe slug. +_FAMILY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,30}$") + def inject_enabled() -> bool: return os.environ.get("CATHEDRAL_INJECT_ENABLED", "").strip().lower() in { @@ -75,6 +84,19 @@ def inject_family() -> str: return os.environ.get("CATHEDRAL_INJECT_FAMILY", "").strip() or _DEFAULT_FAMILY +def family_is_safe(family: str) -> tuple[bool, str]: + """A family is usable only if it (a) is NOT the native family — else inject + counting/retirement would collide with native refill on real emissions — and + (b) is a safe slug, since it lands in public challenge_ids. Returns + (ok, reason).""" + if family == _NATIVE_FAMILY: + return False, (f"refuses native family '{_NATIVE_FAMILY}' — would collide " + f"with native refill counting/retirement") + if not _FAMILY_RE.match(family): + return False, f"family '{family}' must match {_FAMILY_RE.pattern}" + return True, "" + + def inject_tiers() -> list[int]: raw = os.environ.get("CATHEDRAL_INJECT_TIERS", "").strip() if not raw: @@ -118,10 +140,21 @@ def _inject_seed() -> int: def inject_cid(tier: int, family: str, seed: int) -> str: - """``sat-t{tier}-random-3sat-{family}-{seed:016x}``. Keeps the ``sat-t{N}-`` - prefix so tier_from_challenge_id parses the tier; embeds the family label for - filtering and the random seed hex for uniqueness.""" - return f"sat-t{tier}-random-3sat-{family}-{seed:016x}" + """Opaque, unique challenge id that does NOT reveal the seed. + + The suffix is a one-way hash of (tier, family, seed), so a participant + CANNOT invert the public id back to the seed and reconstruct the planted + assignment — they must actually solve. Keeps the ``sat-t{N}-`` prefix so + tier_from_challenge_id parses the tier, and the ``{family}`` label so solves + stay filterable for measurement. + + SECURITY: an earlier version used ``{seed:016x}`` directly, which let anyone + read the seed off the public board, regenerate ``random.Random(seed)``, and + recover the planted model with no solving. The seed is never published and + never stored — the served CNF body is the only artifact, and it cannot be + reproduced from any public field. See inject_verify.py §SEED-SECRECY.""" + suffix = hashlib.sha256(f"{tier}:{family}:{seed}".encode()).hexdigest()[:16] + return f"sat-t{tier}-random-3sat-{family}-{suffix}" def active_inject_count(store: Store, tier: int, family: str) -> int: @@ -202,13 +235,18 @@ async def inject_tier_async(store: Store, tier: int, family: str, guard += 1 seed = _inject_seed() cid = inject_cid(tier, family, seed) - if store.query("SELECT status FROM lane_challenges WHERE challenge_id=?", (cid,)): - continue # astronomically unlikely seed collision — skip + # collision check off the event loop (postgres getconn() blocks) + exists = await asyncio.to_thread( + store.query, "SELECT status FROM lane_challenges WHERE challenge_id=?", (cid,)) + if exists: + continue # astronomically unlikely id collision — skip cnf_text = await asyncio.to_thread(refill._gen_cnf, seed, n_vars, n_clauses, method) await asyncio.to_thread(_commit_injected, store, cid, tier, family, cnf_text) minted += 1 + # NOTE: never log the seed — it is the secret that keeps the planted + # answer unrecoverable. The opaque cid is enough to identify the mint. log("inject_mint", tier=tier, cid=cid[:40], method=method, - seed_hex=f"{seed:016x}", shape=(n_vars, n_clauses)) + shape=(n_vars, n_clauses)) await asyncio.sleep(0) # yield between mints active = await asyncio.to_thread(active_inject_count, store, tier, family) @@ -218,8 +256,13 @@ async def inject_tier_async(store: Store, tier: int, family: str, async def inject_once_async(store: Store, *, log=lambda *a, **k: None) -> list[dict]: - """One full inject+retire pass across configured tiers.""" + """One full inject+retire pass across configured tiers. No-op (returns []) if + the configured family is unsafe — fail closed rather than touch native.""" family = inject_family() + ok, why = family_is_safe(family) + if not ok: + log("inject_disabled", reason=why) + return [] return [await inject_tier_async(store, tier, family, log) for tier in inject_tiers()]