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
14 changes: 14 additions & 0 deletions blockchain/simple_proof_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ def proof_of_work(
Finds a nonce such that the SHA-256 hash of the block starts with
a specific number of zeros (difficulty).

Complexity: every nonce gives an (effectively) random hash, and a hex digit is
"0" with probability 1/16, so a hash starts with ``difficulty`` zeros with
probability 16**-difficulty. The number of attempts is geometrically
distributed with an expected value of 16**difficulty, so the expected time is
O(16**difficulty) = O(2**(4 * difficulty)) (each attempt takes constant time)
and the space is O(1). Each extra leading zero makes mining about 16 times
slower. Individual runs vary a lot (the standard deviation is about equal to
the mean), so only averages follow the formula.

Measured mean attempts (blocks 0, 1, 2, ... with a fixed transaction string):
difficulty 1: 16.1 (20000 runs), 2: 258.7 (5000 runs), 3: 4254 (1000 runs),
4: 64150 (400 runs), 5: 1077340 (60 runs); expected 16, 256, 4096, 65536 and
1048576. Mean time per run grew by x14 to x17 per level from difficulty 2 to 5.

>>> proof_of_work(1, "test", "abc", 1)[1].startswith("0")
True
>>> # Consistency check: same input must produce same output
Expand Down
51 changes: 51 additions & 0 deletions work/R23-T059/COMPLEXITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# R23-T059: expected vs measured cost of `blockchain/simple_proof_of_work.py`

## 1. Expected complexity (derivation)
`proof_of_work` tries nonce = 0, 1, 2, ... until `sha256(block + transactions + previous_hash + nonce)`
starts with `difficulty` zeros.
- A hex digit of a SHA-256 hash is "0" with probability 1/16, and the digits behave like independent random digits, so one attempt succeeds with probability **p = 16^-difficulty**.
- The number of attempts until the first success is **geometric**: expected value `1/p = 16^difficulty`, standard deviation about the same (`sqrt(1-p)/p`).
- Each attempt does a fixed amount of work (format a short string, one SHA-256), so the expected time is **O(16^difficulty) = O(2^(4*difficulty))**, space **O(1)**. Each extra leading zero multiplies the expected work by **16**.

## 2. Method (`work/R23-T059/bench.py`)
- Difficulty 0 to 5 (6 levels). `attempts = nonce + 1`; time measured per call with `timeit.timeit(..., number=1)`.
- The function is deterministic (same input -> same nonce), so each run uses a different block number (0, 1, 2, ...). Attempts are therefore **exactly reproducible on any machine**; times are not.
- Runs per level: 100 / 20000 / 5000 / 1000 / 400 / 60 (cheap levels get many runs because individual runs vary a lot).

## 3. Results
| difficulty | runs | expected 16^d | mean attempts | 95% CI | mean/expected | mean time (s) | us/attempt | attempts x vs previous | time x vs previous |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 100 | 1 | 1.0 | 1.0 - 1.0 | 1.0 | 0.000001 | 1.42 | - | - |
| 1 | 20000 | 16 | 16.1 | 15.8 - 16.3 | 1.003 | 0.000010 | 0.60 | 16.1 | 6.8 |
| 2 | 5000 | 256 | 258.7 | 251.5 - 265.8 | 1.01 | 0.000139 | 0.54 | 16.1 | 14.4 |
| 3 | 1000 | 4,096 | 4,254.4 | 3,990.8 - 4,517.9 | 1.039 | 0.002284 | 0.54 | 16.4 | 16.4 |
| 4 | 400 | 65,536 | 64,150.2 | 57,940.0 - 70,360.5 | 0.979 | 0.036135 | 0.56 | 15.1 | 15.8 |
| 5 | 60 | 1,048,576 | 1,077,340.1 | 824,178.3 - 1,330,501.8 | 1.027 | 0.626333 | 0.58 | 16.8 | 17.3 |

(Measured on a cloud Linux machine, Python 3.13. Raw data: `results.csv`; plot: `pow_benchmark.png`.)

## 4. Reading the results
- **Attempts:** the measured mean is within 4% of `16^d` at every level and every 95% interval contains `16^d`. The growth per level is x16.1, x16.1, x16.4, x15.1, x16.8, i.e. about x16.
- **Time:** time per attempt is constant (about 0.54-0.60 microseconds), so time follows attempts: x14.4, x16.4, x15.8, x17.3 per level from difficulty 2 up. The d0 -> d1 time ratio (x6.8) is lower only because one-hash runs are dominated by the fixed cost of the call and the timer, not by hashing.
- **Wide variance:** the standard deviation is about as large as the mean, so a single run can be far from `16^d`, and a small sample can mislead. Difficulty 5 used only 60 runs, so its interval is about +-23%; its x16.8 is consistent with 16 but is not a precise estimate.

## 5. A false alarm worth recording
An earlier pass with 100 runs at difficulty 4 gave a mean of 49,272 (0.75x expected, outside its 95% interval), and 1,000 runs at difficulty 1 gave 14.6 (0.91x). I re-checked before trusting either:
- difficulty 4 with 800 runs: mean 62,610 (0.955x; batches of 100 ranged from 49,272 to 70,790);
- difficulty 1 with 20,000 runs on three different input sets: 16.05, 15.84 and 16.12.

So those were low samples, not a bias in the algorithm. The final table uses larger samples.

## 6. Where it appears in the code
The derivation and the measured attempt counts were added to the docstring of `proof_of_work`; the 7 `>>>` lines of doctests and the `ValueError` for a negative difficulty are unchanged.

## 7. Re-run on my MacBook (same attempts, faster machine)

| difficulty | runs | expected 16^d | mean attempts | 95% CI | mean/expected | mean time (s) | us/attempt | attempts x vs previous | time x vs previous |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 100 | 1 | 1.0 | 1.0 - 1.0 | 1.0 | 0.000001 | 0.66 | nan | nan |
| 1 | 20000 | 16 | 16.1 | 15.8 - 16.3 | 1.003 | 0.000004 | 0.28 | 16.1 | 6.7 |
| 2 | 5000 | 256 | 258.7 | 251.5 - 265.8 | 1.01 | 0.000066 | 0.26 | 16.1 | 14.9 |
| 3 | 1000 | 4,096 | 4,254.4 | 3,990.8 - 4,517.9 | 1.039 | 0.001105 | 0.26 | 16.4 | 16.7 |
| 4 | 400 | 65,536 | 64,150.2 | 57,940.0 - 70,360.5 | 0.979 | 0.016957 | 0.26 | 15.1 | 15.3 |
| 5 | 60 | 1,048,576 | 1,077,340.1 | 824,178.3 - 1,330,501.8 | 1.027 | 0.276141 | 0.26 | 16.8 | 16.3 |
109 changes: 109 additions & 0 deletions work/R23-T059/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Measure attempts and time of blockchain/simple_proof_of_work.proof_of_work.

Expected: the hash must start with `difficulty` hex zeros, each nonce succeeds with
probability 16**-difficulty, so the expected number of attempts is 16**difficulty
and the time grows by about x16 per extra level.

Run from the repo root: python work/R23-T059/bench.py
Writes work/R23-T059/results.csv and (if matplotlib is installed) pow_benchmark.png.
Attempts are exactly reproducible (SHA-256 is deterministic); times depend on the machine.
"""

import csv
import math
import statistics
import sys
import timeit
from pathlib import Path

sys.path.insert(0, ".")
from blockchain.simple_proof_of_work import proof_of_work

OUT = Path(__file__).parent
# (difficulty, number of runs). Each run uses a different block number, because the
# function is deterministic: the same input always gives the same nonce.
LEVELS = [(0, 100), (1, 20000), (2, 5000), (3, 1000), (4, 400), (5, 60)]
TRANSACTIONS = "Alice sends 1 BTC to Bob"
PREVIOUS_HASH = "00000abcdef1234567890"

rows = []
for difficulty, runs in LEVELS:
attempts, seconds = [], []
for block_number in range(runs):
result = []
t = timeit.timeit(
lambda b=block_number, d=difficulty: result.append(
proof_of_work(b, TRANSACTIONS, PREVIOUS_HASH, d)
),
number=1,
)
attempts.append(result[0][0] + 1) # nonce 0 is the first attempt
seconds.append(t)
expected = 16**difficulty
mean_att = statistics.fmean(attempts)
# attempts are geometric: standard deviation is about the mean, so SE = mean/sqrt(n)
se = statistics.stdev(attempts) / math.sqrt(runs) if runs > 1 else 0.0
rows.append(
{
"difficulty": difficulty,
"runs": runs,
"expected_attempts": expected,
"mean_attempts": round(mean_att, 1),
"ci95_low": round(mean_att - 1.96 * se, 1),
"ci95_high": round(mean_att + 1.96 * se, 1),
"median_attempts": statistics.median(attempts),
"mean_over_expected": round(mean_att / expected, 3),
"mean_time_s": statistics.fmean(seconds),
"us_per_attempt": 1e6 * sum(seconds) / sum(attempts),
}
)

print("| difficulty | runs | expected 16^d | mean attempts | 95% CI | mean/expected | mean time (s) | us/attempt | attempts x vs previous | time x vs previous |")
print("|---|---|---|---|---|---|---|---|---|---|")
for i, r in enumerate(rows):
att_x = r["mean_attempts"] / rows[i - 1]["mean_attempts"] if i else float("nan")
time_x = r["mean_time_s"] / rows[i - 1]["mean_time_s"] if i else float("nan")
r["attempts_x_prev"], r["time_x_prev"] = round(att_x, 2), round(time_x, 2)
print(
f"| {r['difficulty']} | {r['runs']} | {r['expected_attempts']:,} | {r['mean_attempts']:,} "
f"| {r['ci95_low']:,} - {r['ci95_high']:,} | {r['mean_over_expected']} "
f"| {r['mean_time_s']:.6f} | {r['us_per_attempt']:.2f} | {att_x:.1f} | {time_x:.1f} |"
)

with open(OUT / "results.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)

try:
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
print("matplotlib not installed: skipping the plot")
else:
d = [r["difficulty"] for r in rows]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.2))
err = [
[r["mean_attempts"] - r["ci95_low"] for r in rows],
[r["ci95_high"] - r["mean_attempts"] for r in rows],
]
ax1.errorbar(d, [r["mean_attempts"] for r in rows], yerr=err, fmt="o", capsize=4, label="measured mean (95% CI)")
ax1.plot(d, [16**x for x in d], "--", label="expected 16^difficulty")
ax1.set_yscale("log")
ax1.set_xlabel("difficulty (leading hex zeros)")
ax1.set_ylabel("attempts (log scale)")
ax1.set_title("Attempts vs difficulty")
ax1.legend()
ax2.plot(d, [r["mean_time_s"] for r in rows], "o-", label="measured mean time")
per_attempt = sum(r["mean_time_s"] for r in rows) / sum(r["mean_attempts"] for r in rows)
ax2.plot(d, [per_attempt * 16**x for x in d], "--", label="expected: const x 16^difficulty")
ax2.set_yscale("log")
ax2.set_xlabel("difficulty (leading hex zeros)")
ax2.set_ylabel("time per run, seconds (log scale)")
ax2.set_title("Time vs difficulty")
ax2.legend()
fig.tight_layout()
fig.savefig(OUT / "pow_benchmark.png", dpi=150)
print("saved pow_benchmark.png")
9 changes: 9 additions & 0 deletions work/R23-T059/bench_output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
| difficulty | runs | expected 16^d | mean attempts | 95% CI | mean/expected | mean time (s) | us/attempt | attempts x vs previous | time x vs previous |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 100 | 1 | 1.0 | 1.0 - 1.0 | 1.0 | 0.000001 | 0.66 | nan | nan |
| 1 | 20000 | 16 | 16.1 | 15.8 - 16.3 | 1.003 | 0.000004 | 0.28 | 16.1 | 6.7 |
| 2 | 5000 | 256 | 258.7 | 251.5 - 265.8 | 1.01 | 0.000066 | 0.26 | 16.1 | 14.9 |
| 3 | 1000 | 4,096 | 4,254.4 | 3,990.8 - 4,517.9 | 1.039 | 0.001105 | 0.26 | 16.4 | 16.7 |
| 4 | 400 | 65,536 | 64,150.2 | 57,940.0 - 70,360.5 | 0.979 | 0.016957 | 0.26 | 15.1 | 15.3 |
| 5 | 60 | 1,048,576 | 1,077,340.1 | 824,178.3 - 1,330,501.8 | 1.027 | 0.276141 | 0.26 | 16.8 | 16.3 |
saved pow_benchmark.png
Binary file added work/R23-T059/pow_benchmark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions work/R23-T059/results.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
difficulty,runs,expected_attempts,mean_attempts,ci95_low,ci95_high,median_attempts,mean_over_expected,mean_time_s,us_per_attempt,attempts_x_prev,time_x_prev
0,100,1,1.0,1.0,1.0,1.0,1.0,6.649699980698642e-07,0.6649699980698642,nan,nan
1,20000,16,16.1,15.8,16.3,11.0,1.003,4.430835699997715e-06,0.27603013331657833,16.1,6.66
2,5000,256,258.7,251.5,265.8,183.0,1.01,6.620477739975285e-05,0.25592519714773343,16.07,14.94
3,1000,4096,4254.4,3990.8,4517.9,2875.5,1.039,0.0011049753689978843,0.25972775435033335,16.45,16.69
4,400,65536,64150.2,57940.0,70360.5,43607.0,0.979,0.016956510305003576,0.2643249294430431,15.08,15.35
5,60,1048576,1077340.1,824178.3,1330501.8,810937.0,1.027,0.27614072856662614,0.2563171435933099,16.79,16.29