Skip to content

Puzzle rework: both puzzles adopt the cheap identity assert - #62

Merged
EvanWinget merged 10 commits into
mainfrom
puzzle-taptree-rework
Aug 24, 2026
Merged

Puzzle rework: both puzzles adopt the cheap identity assert#62
EvanWinget merged 10 commits into
mainfrom
puzzle-taptree-rework

Conversation

@EvanWinget

@EvanWinget EvanWinget commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Both benchmark puzzles adopt ASSERT_MY_TAPTREE (0x38) in place of ASSERT_MY_TAPROOT (0x37), operand for operand, the corpus is re-pinned, the saving is measured in both puzzle docs, and the five review findings PR 60 deferred here are landed. This is tooling, tests, vectors, and docs only: nothing in spec/ or python/bitlisp/ changes.

Spec authority. spec/CONDITIONS.md, the ASSERT_MY_TAPTREE entry (0x38), and spec/COSTS.md section 10. Ratified as decision 28 and divergence C24 in docs/condition-record.md. The execution plan's cheap-identity-assert amendment names this rework as its acceptance step.

Read the commits in this order

  1. puzzles: adopt ASSERT_MY_TAPTREE, the cheap identity assert. The four sources swap the opcode name at six emit sites, comments reworded. The internal key stays the BIP341 NUMS point in both puzzles. The test suites re-pin the three changed mod hashes in the same commit, and the curried-identity closure reads the internal key and merkle root off the emitted assert, which carries no derived script_pubkey field.
  2. vectors: the puzzle corpus re-pins on the taptree assert. All five puzzle vector files regenerate. Method below.
  3. docs: the puzzle docs measure the taptree saving.
  4. to 7. The five PR 60 deferrals: the filler identity built once, the corpus identity audit shared and memoized per root, _optional_hex in the runner, HONEST_SPKS deleted.
  5. and 10. Self-review fold-ins: the vault cost sentences corrected, and the shared helpers moved to a plain support.py with NUMS, load_vector, and the corpus walk joining them.
  6. plan: the cheap identity assert amendment is accepted.

The measured saving

Every spend of either puzzle costs exactly 1,300,000 less, the point multiplication the taptree assert does not run. Totals through the single-spend runner at the provisional constants:

spend 0x37 (before) 0x38 (after)
vault trigger, no revault 5,354,131 4,054,131
vault trigger with a revault claim 8,004,696 6,704,696
vault keyless recovery 2,707,940 1,407,940
matured withdrawal 1,349,468 49,468
consolidation leader 2,718,898 1,418,898
consolidation follower 1,357,496 57,496
singleton first spend 5,467,737 4,167,737
singleton later generation 5,545,698 4,245,698
singleton ending spend 5,562,797 4,262,797

The condition charge itself is 200 against 1,300,200, and a minimal spend emitting one identity assert and nothing else runs at 220 against 1,300,220 through evaluation and parsing together. Evaluation costs are unchanged, the opcode atom having the same width, so the whole saving is the condition charge.

What a reviewer should scrutinize

One corpus expectation changes. program_on_wrong_coin in vectors/validation/vault-core.json now fails as unsatisfied_taptree_assert where it failed as unsatisfied_scriptpubkey_assert. CONDITIONS.md states the two asserts are satisfied together and fail together on an honest input, each with its own error, and this vector now pins the error swap.

What the puzzles no longer read. The derivation assert compared the spent scriptPubKey bytes against a freshly derived tweak. The taptree assert compares against the identity triple the transaction view carries as authenticated and derives nothing, decision 28's stated stance. I probed the boundary directly (constructions in the review commands below):

  • wrong merkle root in the identity: unsatisfied_taptree_assert
  • wrong internal key in the identity: unsatisfied_taptree_assert
  • honest identity but a garbage spent scriptPubKey: VALID at the model level

The third row is the recorded consequence of the authenticated-view stance: base consensus admits no input whose scriptPubKey is not the taproot output of its own control-block identity, so the shape exists only in hand-built model transactions. The stage 2 invariant in VALIDATION.md states the agreement. The vault's follower and leader paths still bind the literal scriptPubKey through ASSERT_MY_SCRIPTPUBKEY where the message ledger needs the bytes.

Vector regeneration method. The corpus was regenerated by a construction-for-construction generator keyed on the test modules' own objects. Before the swap was trusted, the generator had to reproduce all five files byte for byte from the unswapped sources, and did. After the swap it rebuilt every compiled program, condition payload, instance root, scriptPubKey, and singleton lifecycle txid, while every case name, shape, amount, and literal txid stayed. The closure tests then recompute every pinned program and payload from source, in both set directions.

The fate of ASSERT_MY_TAPROOT (0x37), your call

Decision 28 reserves this for you at this PR, with the measured saving in hand. Both sides:

Remove it. ASSERT_MY_TAPTREE subsumes it on every input base consensus admits, the tweak-collision exemption aside (decision 28). Nothing in the tree uses it any more. A vocabulary entry with no user is review burden and attack surface for a spec headed to skeptical readers, and the question "when do your two identity asserts diverge" invites exactly the tweak-collision conversation a reviewer will start. Pre-deployment removal is cheap, later removal is a governance event. The derivation code itself survives untouched in CREATE_OUTPUT_TAPROOT.

Keep it. It is the only self assert that binds the spent scriptPubKey bytes to components without trusting the view triple, the assert-side mirror of CREATE_OUTPUT_TAPROOT (D-CC2 symmetry in the condition comparison). Its spec entry, implementation branch, and vectors already exist and cost nothing to carry. Phase 4 re-prices the tweak constant, and a cheap derivation would leave the choice between the two harmless. Removing it is spec, implementation, and vector churn in a release that has no deployment pressure forcing the cut.

My recommendation: remove, in its own follow-up PR so this one stays at its stated scope. The subsumption is ratified, the last caller is gone as of this PR, and a one-fact-one-assert vocabulary is easier to defend than an exemption footnote. I have not touched 0x37 anywhere here.

Verify independently

python3 -m venv .venv && .venv/bin/pip install -e ".[dev,oracles]"
.venv/bin/pytest python/tests                # 815 passed
.venv/bin/python tools/run_vectors.py        # 38 files, 1132 cases, 0 failures
ci/lint/lint.sh                              # all checks passed
.venv/bin/bitlisp-compile -T puzzles/vault/vault.bl -I puzzles/lib -I puzzles/vault
# 802e8fda9a74a0fdf0170b2e974ae3d9b91805c4f7640997f05b1b0d0268dc13, the pinned literal
.venv/bin/bitlisp-compile -T puzzles/singleton/singleton.bl -I puzzles/lib -I puzzles/singleton
# 15d44b7d58dfa717679dfdeb583bb42a749bf9188dc5d7661171fdf89e8c3714
git diff main -- vectors/ | grep -c '^[+-]'  # the corpus churn is mechanical re-derivation

The closure tests are the strongest check: test_vm_vectors_match_source and test_validation_vectors_match_source in both puzzle suites recompute every pinned payload from a fresh compile and curry, set-equal in both directions, and test_validation_vector_identities_derive_their_scripts re-derives every identity's scriptPubKey. The diff harness was not run: no VM or condition-layer behavior changes in this PR.

Confidence

High, 9 of 10. The two remaining ASSERT_MY_TAPROOT mentions in the puzzle docs are the deliberate comparison sentences stating the saving. The residual risk is concentrated in the regenerated corpus, and it is fenced from three sides: the generator reproduced the old corpus byte for byte before the swap, the closure tests recompute every payload from source, and the corpus runner executes every case. The one point I could not close myself is the 0x37 decision above, which is yours.

Swap ASSERT_MY_TAPROOT (0x37) for ASSERT_MY_TAPTREE (0x38) in the
vault, triggered, shared-recovery, and singleton sources, operand
for operand, the internal key staying the BIP341 NUMS point in
both puzzles. The taptree assert reads the internal key and merkle
root base consensus authenticated from the control block instead
of re-deriving the 34-byte scriptPubKey, so every puzzle spend
drops the point multiplication it paid on every path, 1,300,000
of cost per spend at the provisional constants.

The test suites re-pin the three changed mod hashes (the owner
inner program is untouched), and the curried-identity closure now
reads the internal key and merkle root off the emitted assert,
which carries no derived script_pubkey field.

Authorized by spec/CONDITIONS.md, the ASSERT_MY_TAPTREE entry
(0x38), and spec/COSTS.md section 10. Ratified as condition-record
decision 28, divergence C24. The rework itself is the acceptance
step the execution plan's cheap-identity-assert amendment states.
Regenerated from the swapped sources: every compiled program,
condition payload, instance root, instance scriptPubKey, and
singleton lifecycle txid moves, while every case name, shape,
amount, and literal txid stays. Evaluation costs are unchanged,
the opcode atom having the same width, so the vm expects differ
only inside program and result hex.

One expectation changes by spec: program_on_wrong_coin now fails
as unsatisfied_taptree_assert where the derivation assert failed
as unsatisfied_scriptpubkey_assert. CONDITIONS.md states the two
asserts are satisfied together and fail together on an honest
input, each with its own error, and this vector pins the error
swap. The two validation files' spec citations name
ASSERT_MY_TAPTREE in place of ASSERT_MY_TAPROOT.

Regeneration used a construction-for-construction generator that
first reproduced all five files byte for byte from the unswapped
sources, so the re-pin carries no silent case drift. The closure
tests recompute every program and condition payload from source
and the full corpus runner passes: 38 files, 1132 cases.
Both puzzle docs move their identity sections onto
ASSERT_MY_TAPTREE, re-pin the worked-instance mod hashes, and
record the measured saving: every spend of either puzzle costs
exactly 1,300,000 less than under ASSERT_MY_TAPROOT, the point
multiplication the taptree assert does not run. Vault spends now
range from 49,468 (matured withdrawal) to 4,054,131 (trigger),
and the singleton lifecycle runs 4,167,737 to 4,262,797. The two
remaining ASSERT_MY_TAPROOT mentions are the comparison sentences
stating that saving.
PR 60 review deferral: the filler triple (0a, 0b, 0c) was retyped
in ten test modules. conftest.py now owns the three constants and
two builders, TxInput keyword arguments and the hex fields of a
runner tx context, and every module spreads the builder instead.
The self-assert suite's FILLER_IDENTITY pair is derived from the
same constants. No behavior change: 328 tests in the touched
modules pass unchanged.
PR 60 review deferrals: the corpus-identity test was duplicated
across the vault and singleton suites with the derived-scriptPubKey
expression spelled four times, and each run re-derived the EC
tweak per input. conftest.py now owns assert_corpus_identities,
which derives each distinct root's tweak once, and both suites
call it with their files and expected filler count. The two suites
drop from about 9 seconds to under 4.
PR 60 review deferral: load_context spelled the same
present-or-None hex conversion three times for the identity
triple. _optional_hex reads them all. No behavior change.
PR 60 review deferral: the table restated what TAPROOT_ASSERTS
already holds and had one reader, which now reads the assert's
own script_pubkey field.
The acceptance the bullet states is met: both puzzles reworked
onto ASSERT_MY_TAPTREE, re-pinned, the saving measured in their
docs. Whether ASSERT_MY_TAPROOT stays in the vocabulary is Evan's
call at the rework PR's review, recorded in the bullet.
@EvanWinget

Copy link
Copy Markdown
Owner Author

Review pass (code-review skill, high, Fable finders, adversarial verify)

Ten candidates went to independent verifiers. Nine confirmed, all folded in the two commits after the plan flip:

  1. Trigger cost sentence unqualified (docs). The 4,054,131 figure is the no-revault trigger. The doc now names both variants, the revault claim adding a second CREATE_OUTPUT_TAPROOT at 6,704,696.
  2. "Parses at 220" misattributed a runner total to the parse layer (docs). The condition charge is 200 against 1,300,200 per COSTS.md section 10, and 220 is the minimal spend through evaluation and parsing together. Reworded.
  3. conftest imported as a library (structure, 12 modules). conftest.py is pytest's hook file and bare imports of it lean on conftest's special loading. Helpers moved to a plain support.py, conftest deleted.
  4. NUMS literal retyped in three modules. Now one constant in support.py.
  5. load_vector defined twice. Now one helper in support.py.
  6. The files-cases-inputs vector walk spelled three times. One condition_inputs generator, used by the audit and both closure suites.
  7. Audit filler tuple coupled to dict insertion order. Built explicitly from the three constants.
  8. validation-invariants hand-built the filler dict beside its import of the builder. Uses filler_identity().
  9. duplicate-invariants taptree pool entries hid their coupling to the filler identity in raw literals. They name the filler constants now.

Refuted after verification: consolidating the b"\x51\x20" + taproot_output_key(...) expression across suites, since tests deriving the scriptPubKey independently of any shared helper is the point of the closure.

Final: 815 tests, 1132 vector cases, lint clean, CI green.

Review findings: the trigger figure is the no-revault path, so
the sentence now names both trigger variants (6,704,696 with a
revault claim), and the one-assert figure was a runner total
called a parse cost. The condition charge is 200 against
1,300,200 per spec/COSTS.md section 10, and 220 against 1,300,220
is the minimal spend through evaluation and parsing together.
Review findings, all confirmed by adversarial verify. conftest.py
is pytest's hook file and importing a library from it leans on
conftest's special loading, so the helpers move to a plain
support.py module and no conftest exists. The NUMS point and
load_vector, previously retyped across three and two modules,
join it, and one condition_inputs generator owns the
files-cases-inputs walk the audit and both closure suites
repeated. The audit's filler tuple is built from the three
constants explicitly instead of through dict insertion order, and
the duplicate-invariants taptree pool entries name the filler
constants their satisfiability depends on. The PR 60 deferral
prescribed a conftest.py home, and this amends the letter of that
prescription while keeping its intent.
@EvanWinget
EvanWinget force-pushed the puzzle-taptree-rework branch from 1b33fcc to 77c2372 Compare August 24, 2026 03:19
@EvanWinget
EvanWinget merged commit 6e77a39 into main Aug 24, 2026
2 checks passed
@EvanWinget
EvanWinget deleted the puzzle-taptree-rework branch August 24, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant