feat(cli): NIP-13 for amy — post --pow and the pow verb group

- `amy notes post TEXT --pow BITS [--pow-timeout SECS]` mines the note
  pre-signature via quartz's PoWMiner (blocking — the CLI process is the
  job), exits 124 on timeout with nothing published, and adds additive
  --json keys: pow, pow_target, pow_millis.
- `amy pow check EVENT-JSON|-` reports actual_bits, committed_target,
  has_commitment and effective_pow (capped at the commitment per
  NIP-13's anti-lucky-spam rule) plus id+sig validity.
- `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE|-`
  mines an unsigned template for any pubkey — NIP-13's delegated PoW:
  ids don't commit to signatures, so a headless box can mine for a
  phone and hand the template back for signing.
- `amy pow bench` prints the machine's hash rate and expected seconds
  at 16/20/24/28 bits (commons PoWEstimator).
- cli/tests/pow/pow-headless.sh: relay-free harness covering bench,
  mine (commitment shape + 124 timeout), and the delegated round trip
  (mine → sign via `amy event` → pow check ≥ target). 6/6 passing.

No logic added to cli/ — thin assembly over quartz nip13Pow + commons
PoWEstimator per the CLI architecture rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
This commit is contained in:
Claude
2026-07-10 22:47:05 +00:00
parent 97fc801294
commit 81dc4449bd
7 changed files with 386 additions and 5 deletions
+4 -1
View File
@@ -215,6 +215,9 @@ Army-knife verbs that operate purely on their arguments. They never touch
| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. |
| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. |
| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. |
| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). |
| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. |
| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. |
| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. |
| `amy key public NSEC\|HEX` | Derive the public key from a secret key. |
| `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. |
@@ -381,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| Command | What it does |
|---|---|
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). |
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
+1
View File
@@ -51,6 +51,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Marmot message send / list | ✅ | `commons/marmot/` |
| `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` |
| NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. |
| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. |
| NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. |
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.cli.commands.OfferCommands
import com.vitorpamplona.amethyst.cli.commands.OutboxCommand
import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands
import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
import com.vitorpamplona.amethyst.cli.commands.PowCommands
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
@@ -262,6 +263,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"dm" -> DmCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
"notes" -> NotesCommands.dispatch(dataDir, tail)
"pow" -> PowCommands.dispatch(dataDir, tail)
"nsite" -> NsiteCommands.dispatch(dataDir, tail)
"napplet" -> NappletCommands.dispatch(dataDir, tail)
"store" -> StoreCommands.dispatch(dataDir, tail)
@@ -428,6 +430,13 @@ private fun printUsage() {
| encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]
| verify [EVENT-JSON] check an event's id hash + signature
| (reads stdin when the arg is omitted or `-`)
| pow check EVENT-JSON|- NIP-13: leading-zero bits, committed target,
| effective PoW (capped at the commitment)
| pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON|-
| mine an UNSIGNED template (delegated PoW:
| ids don't commit to sigs, so amy can mine
| for any pubkey); exit 124 on timeout
| pow bench hash rate + expected seconds at 16/20/24/28 bits
| key generate mint a fresh keypair (nsec + npub + hex)
| key public NSEC|HEX derive the public key from a secret key
| key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1…
@@ -493,6 +502,8 @@ private fun printUsage() {
|
|Notes (NIP-10 kind:1):
| notes post TEXT [--relay URL] publish a kind:1 short text note
| [--pow BITS [--pow-timeout SECS]] mine a NIP-13 proof of work first
| (exit 124 on timeout, nothing published)
| (--relay accepts comma-separated extras)
| notes feed [--author USER] fetch kind:1 notes
| [--following] (default: own; --author: one user;
@@ -25,21 +25,31 @@ import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.pow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* `amy post <text> [--relay URL …]` — publish a NIP-10 kind:1 short text note
* to the user's outbox relays.
* `amy post <text> [--relay URL …] [--pow BITS [--pow-timeout SECS]]` —
* publish a NIP-10 kind:1 short text note to the user's outbox relays,
* optionally mining a NIP-13 proof of work into it first. Mining blocks the
* invocation (the CLI process IS the job); `--pow-timeout` aborts with exit
* 124 and publishes nothing.
*
* Threading is intentionally out of scope here — `amy post` only handles new
* top-level notes. Replies/quotes need richer event-hint plumbing and will get
* their own verb when needed.
*/
object PostCommand {
private const val MAX_DIFFICULTY = 64
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …]")
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …] [--pow BITS [--pow-timeout SECS]]")
val text = rest[0]
if (text.isBlank()) return Output.error("bad_args", "post text must not be blank")
@@ -50,6 +60,12 @@ object PostCommand {
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
val powTarget = args.flags["pow"]?.toIntOrNull()
if (args.flags.containsKey("pow") && (powTarget == null || powTarget < 1 || powTarget > MAX_DIFFICULTY)) {
return Output.error("bad_args", "--pow must be between 1 and $MAX_DIFFICULTY leading zero bits")
}
val powTimeoutSec = args.flags["pow-timeout"]?.toLongOrNull()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val outbox = ctx.outboxRelays()
@@ -63,7 +79,33 @@ object PostCommand {
return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
}
val signed = ctx.signer.sign(TextNoteEvent.build(text))
val template = TextNoteEvent.build(text)
var powMillis: Long? = null
val readyToSign =
if (powTarget != null) {
System.err.println("mining $powTarget bits…")
val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, ctx.signer.pubKey, powTarget) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
} catch (e: CancellationException) {
Output.error("pow_timeout", "did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published")
return 124
}
powMillis = (System.nanoTime() - startedAt) / 1_000_000
System.err.println("mined in ${powMillis}ms")
mined
} else {
template
}
val signed = ctx.signer.sign(readyToSign)
val ack = ctx.publish(signed, targets)
Output.emit(
@@ -72,6 +114,9 @@ object PostCommand {
"kind" to signed.kind,
"created_at" to signed.createdAt,
"content" to signed.content,
"pow" to if (powTarget != null) signed.pow() else null,
"pow_target" to powTarget,
"pow_millis" to powMillis,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.commitedPoW
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.nip13Pow.pow
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToLong
/**
* `amy pow <check|mine|bench>` — NIP-13 proof-of-work primitives.
*
* `check` and `bench` are stateless (no account, no network). `mine` works on
* an UNSIGNED template: because the NIP-01 id does not commit to the
* signature, amy can mine on behalf of any pubkey (NIP-13's delegated PoW) —
* pass `--pubkey`, or omit it to mine for the active account.
*/
object PowCommands {
private const val MAX_DIFFICULTY = 64
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"pow",
tail,
"pow <check|mine|bench> …",
mapOf(
"check" to { rest -> check(rest) },
"mine" to { rest -> mine(dataDir, rest) },
"bench" to { rest -> bench() },
),
)
/**
* `amy pow check <event-json | ->` — difficulty of a SIGNED event, with the
* NIP-13 commitment rule applied: `effective_pow` is capped at the committed
* target and `valid` covers id+signature (a forged id can claim any PoW).
*/
private fun check(rest: Array<String>): Int {
val json = readPayload(rest) ?: return Output.error("bad_args", "pow check <event-json | -> (- reads stdin)")
val event =
try {
Event.fromJson(json)
} catch (e: Exception) {
return Output.error("bad_event", e.message)
}
val commitment = event.tags.commitedPoW()
Output.emit(
mapOf(
"event_id" to event.id,
"valid" to event.verify(),
"actual_bits" to PoWRankEvaluator.calculatePowRankOf(event.id),
"committed_target" to commitment,
"has_commitment" to (commitment != null),
"effective_pow" to event.pow(),
),
)
return 0
}
/**
* `amy pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->`
* — mines an unsigned template and prints it back with the nonce tag, ready
* to be signed by whoever owns the pubkey.
*/
private suspend fun mine(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->"
val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage)
if (target < 1 || target > MAX_DIFFICULTY) {
return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY")
}
val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage)
val template =
try {
EventTemplate.fromJson(json)
} catch (e: Exception) {
return Output.error("bad_template", e.message)
}
val pubKey =
args.flags["pubkey"]
?: try {
Context.open(dataDir).use { it.signer.pubKey }
} catch (e: Exception) {
return Output.error("bad_args", "no account available; pass --pubkey (${e.message})")
}
if (pubKey.length != 64 || pubKey.any { it !in "0123456789abcdefABCDEF" }) {
return Output.error("bad_args", "--pubkey must be 64 hex characters")
}
val timeoutSec = args.flags["timeout"]?.toLongOrNull()
val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
System.err.println("mining $target bits for ${pubKey.take(8)}…")
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, pubKey, target) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
} catch (e: CancellationException) {
Output.error("pow_timeout", "did not reach $target bits within ${timeoutSec}s")
return 124
}
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
val id =
sha256(
EventHasherSerializer.fastMakeJsonForId(
pubKey = pubKey,
createdAt = mined.createdAt,
kind = mined.kind,
tags = mined.tags,
content = mined.content,
),
).toHexKey()
Output.emit(
mapOf(
"id" to id,
"pubkey" to pubKey,
"pow" to PoWRankEvaluator.calculatePowRankOf(id),
"pow_target" to target,
"pow_millis" to elapsedMs,
"template_json" to mined.toJson(),
),
)
return 0
}
/** `amy pow bench` — hash rate + expected mining time per common target. */
private suspend fun bench(): Int {
val rate = PoWEstimator.hashesPerSecond()
Output.emit(
mapOf(
"hashes_per_second" to rate.roundToLong(),
"expected_seconds" to
listOf(16, 20, 24, 28).associate { bits ->
bits.toString() to PoWEstimator.estimateSeconds(bits, rate)
},
),
)
return 0
}
private fun readPayload(rest: Array<String>): String? {
val arg = rest.firstOrNull() ?: return null
val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg
return payload.trim().ifEmpty { null }
}
}
+1
View File
@@ -0,0 +1 @@
state-pow-headless/
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# pow-headless.sh — verifies amy's NIP-13 primitives without a relay.
#
# One throwaway amy identity in an isolated $HOME. We assert that:
#
# 1. `amy pow bench` reports a positive hash rate and estimates.
# 2. `amy pow mine --target 10 --pubkey HEX <template>` returns a
# template whose recomputed id has >= 10 leading zero bits and
# whose nonce tag commits to "10".
# 3. `amy pow mine` with a 0-second timeout on an impossible target
# exits 124 (the await-timeout contract).
# 4. `amy event --kind 1` piped through `amy pow check -` reports
# valid=true and has_commitment=false for an unmined event.
# 5. Mining via `amy event --tags <mined nonce tag>` … a signed event
# carrying the mined nonce round-trips through `pow check` with
# effective_pow >= 10 and has_commitment=true.
#
# Usage: ./pow-headless.sh [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-pow-headless"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,19p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$SCRIPT_DIR/../lib.sh"
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
if [[ $NO_BUILD -eq 0 ]]; then
step "building amy (installDist)"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
export HOME="$STATE_DIR"
amy() { "$AMY_BIN" --account t --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
banner "amy NIP-13 primitives"
step "init throwaway identity"
amy init >>"$LOG_FILE" || { record_result "init" fail; exit 1; }
PUBKEY="$(amy whoami | jq -r .hex)"
[[ "$PUBKEY" =~ ^[0-9a-f]{64}$ ]] || { record_result "init" fail "no pubkey"; exit 1; }
record_result "init" pass
step "pow bench"
BENCH="$(amy pow bench)"
RATE="$(jq -r .hashes_per_second <<<"$BENCH")"
EST20="$(jq -r '.expected_seconds["20"]' <<<"$BENCH")"
if [[ "$RATE" -gt 0 ]] && jq -e '.expected_seconds["16"] < .expected_seconds["28"]' <<<"$BENCH" >/dev/null; then
record_result "pow-bench" pass "rate=$RATE h/s, 20 bits ≈ ${EST20}s"
else
record_result "pow-bench" fail "$BENCH"
fi
step "pow mine at 10 bits"
TEMPLATE='{"created_at":1683596206,"kind":1,"tags":[],"content":"pow harness"}'
MINE="$(amy pow mine --target 10 --pubkey "$PUBKEY" "$TEMPLATE")"
POW="$(jq -r .pow <<<"$MINE")"
NONCE_TARGET="$(jq -r '.template_json | fromjson | .tags[] | select(.[0]=="nonce") | .[2]' <<<"$MINE")"
if [[ "$POW" -ge 10 && "$NONCE_TARGET" == "10" ]]; then
record_result "pow-mine" pass "pow=$POW committed=$NONCE_TARGET"
else
record_result "pow-mine" fail "$MINE"
fi
step "pow mine timeout exits 124"
amy pow mine --target 60 --timeout 1 --pubkey "$PUBKEY" "$TEMPLATE" >>"$LOG_FILE"
RC=$?
if [[ $RC -eq 124 ]]; then
record_result "pow-mine-timeout" pass
else
record_result "pow-mine-timeout" fail "exit=$RC"
fi
step "pow check on an unmined signed event"
UNMINED="$(amy event --kind 1 --content "no pow here" | jq -c .event)"
CHECK1="$(amy pow check "$UNMINED")"
if jq -e '.valid == true and .has_commitment == false' <<<"$CHECK1" >/dev/null; then
record_result "pow-check-unmined" pass
else
record_result "pow-check-unmined" fail "$CHECK1"
fi
step "mined template signs into a valid PoW event"
NONCE_TAGS="$(jq -c '.template_json | fromjson | .tags' <<<"$MINE")"
CREATED_AT="$(jq -r '.template_json | fromjson | .created_at' <<<"$MINE")"
SIGNED="$(amy event --kind 1 --content "pow harness" --tags "$NONCE_TAGS" --created-at "$CREATED_AT" | jq -c .event)"
CHECK2="$(amy pow check "$SIGNED")"
if jq -e '.valid == true and .has_commitment == true and .effective_pow >= 10' <<<"$CHECK2" >/dev/null; then
record_result "pow-check-mined" pass "effective_pow=$(jq -r .effective_pow <<<"$CHECK2")"
else
record_result "pow-check-mined" fail "$CHECK2"
fi
print_summary