Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions INJECT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 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-<opaque-hash>`. 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

- **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?"

## 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
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.
136 changes: 136 additions & 0 deletions inject_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/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, verify_witness
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:
import hashlib

tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close()
store = Store(tmp.name)

family = "gentest"
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, 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)

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("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)
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("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)}")
return 1
print("INJECT VERIFY PASS")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading