diff --git a/README.md b/README.md index ae7dc09..e666194 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ ci/lint/lint.sh ## Using the tools -The editable install adds five console scripts: +The editable install adds seven console scripts: ``` .venv/bin/bitlisp [tx.json] # REPL with stepping debugger @@ -52,6 +52,8 @@ The editable install adds five console scripts: .venv/bin/bitlisp-asm [text] # text to serialized bytecode hex .venv/bin/bitlisp-disasm [hex] # serialized bytecode hex to text .venv/bin/bitlisp-compile [source] # v0 language source to bytecode hex +.venv/bin/bitlisp-curry [hex] --arg # fix values into a program +.venv/bin/bitlisp-uncurry [hex] # split a curried program back out ``` `bitlisp-run` reports the verdict, the emitted conditions, and the diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 5d591f2..bdf5ce6 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -86,10 +86,13 @@ descriptions, and commit messages* - [x] **Unit 6b: includes and the compile-time forms.** Split from unit 6 (decision by Evan, 2026-08-16): an include mechanism needs its own recorded decision against the self-containment rule, a program compiling identically pasted into the REPL. Scope expanded and resequenced ahead of unit 7 (decision by Evan, 2026-08-16, on a two-corpus census of tibetswap and chia-gaming): source-level include of shared constants and functions, computed compile-time constants, and inline functions. Every production puzzle file in both corpora imports shared definitions, and tibetswap defines nine of every ten functions inline. The census corrected the earlier deferral's premise: chia-gaming never abandoned source-level import, its compiled-sibling hash plumbing lives in its build layer outside the language, and that import stays deferred with `bitlisp-compile -T` covering the need manually in v0. `let` stays out, two uses across both corpora, both in tests. `assign` stays held on unit 7 evidence with a pre-registered trigger: benchmark-puzzle helpers that exist only to name intermediate values. Landed 2026-08-16 as `include`, computed `defconstant`, and `defun-inline`, stated in `docs/lang/language.md`. Decisions by Evan, 2026-08-16, via the approved unit plan: the self-containment rule is amended to the form plus its include files resolved through the same explicit search path everywhere, a repeat include dedupes by resolved file and a cycle errors where the classic reference dies on the collision or recurses without bound, `defconstant` evaluates its value on the reference VM under the default budget (a pinned break with unit 4's verbatim semantics, the modern defconst behavior under the classic keyword), and `defun-inline` keeps call-by-name laziness while closing classic's probe-verified sharp edges: arity checked, quoted content untouched, shadowing impossible, expansion depth-capped. Compiled representatives joined `compiled-programs.json`. - [x] **Unit 6c: the macro reversal.** Landed 2026-08-16 as the removal of `defmacro`, `qq`, and `unquote` and the addition of `assert`, `and`, and `or` as fixed compiler forms with classic utility_macros semantics, reversing unit 6 (decision by Evan, 2026-08-16, both sides steelmanned under ground rule 3). The evidence: three usage surveys (Chia's canonical 91-puzzle corpus, the corpora vendored in references/, and chia-gaming) found no novel macro in any deployed puzzle, short-circuit assert, and, and or the entire production vocabulary, and chia-gaming's production referee choosing built-in destructuring over the structural macro sitting unbuilt beside it. The reserved-word set change breaks compatibility in both directions, pinned by the loader test. Compiled representatives joined `vectors/vm/compiled-programs.json`, and `macro-programs.json` left with the feature. - [x] **Unit 6d: the forms two puzzles asked for** (added 2026-08-22, decision by Evan, skeleton change under ground rule 6): `let` with several bindings, compiled as the naming helpers are written by hand, `list*` consing its arguments onto a tail, the list library completed, and a shared puzzle test harness. Evidence: the eight authoring observations in `docs/puzzles/singleton.md`. Declared widths for curried values stay on the typed v1 ledger. Sequenced after the identity-assert rework and before the payment pool, so the remaining puzzles are written with the forms rather than rewritten. Landed 2026-08-23: `let` compiles as the one-shot naming helper with parallel bindings matching the modern Chialisp dialect, `list*` is a recorded tooling divergence, `append`, `reverse`, `sum`, and `last` complete `puzzles/lib/list.blib`, and `python/tests/puzzle_support.py` owns the shared harness. Compiled representatives joined `compiled-programs.json`, and both puzzles' bytes did not move. -- [ ] **Unit 7: the four benchmark puzzles, written in the language, as the acceptance artifact:** vault, payment pool, async offer, singleton wrapper. This phase makes them exist and run, Phase 4 measures them. One PR per puzzle where size warrants. Style constraint (decision by Evan, 2026-08-16, simplified by unit 6c, widened by unit 6b's resequencing): the puzzles are plain functions, the fixed forms, and the unit 6b surfaces, there being no macro system, and the authoring experience feeds the typed v1 gate's ledger note on whether unit 6c's cut ever binds. Vault first, resequenced ahead of the pool (decision by Evan, 2026-08-16): core semantics match BIP-345, one PR, plus the keyless leader/follower consolidation path over the message ledger with its theft vectors, the evaluation doc's section 3.2 benchmark. Recovery posture is a curried per-instance choice, keyless or keyed (decision by Evan, 2026-08-16). The vault's dispatch helpers exist only to name a reconstructed root once, the pre-registered assign trigger firing, evidence for the unit 7 ledger note. Singleton wrapper second, landed 2026-08-22 (PR 59), resequenced ahead of the pool and the offer: the Chia shape does not port, because a txid commits input outpoints but not input scripts and no VM operator performs the taproot tweak, so the lineage scriptPubKey is constant per launch and the state rides in a tagged OP_RETURN of the creating transaction, read back through two transaction preimages. The constant scriptPubKey is the built shape, both sides stated in `docs/puzzles/singleton.md` (decision by Evan, 2026-08-22, on the census below), and the taproot-tweak operator condition-record decision 3 declined is flagged for the Phase 4 gate with this puzzle as the case that answers decision 3's objection. The helpers that name a txid and an inner hash once are the assign trigger firing again. Value question answered 2026-08-22 in that doc (census of the vendored Chia corpora plus the Bitcoin constructions): Ark, Lightning, vaults, and pools get identity from the root outpoint and need no singleton, the lineage machinery is what the fungible asset token needs, and the remaining lineage effort belongs there. The token is a capability-and-risk study, not a goal: BitLisp does not want fungible asset tokens on Bitcoin, it wants to know what the vocabulary can build and what that exposes (decision by Evan, 2026-08-22). The name BAT1 applies if a standard is ever written. Eight authoring observations across the vault and the singleton are recorded there for the ledger. The fungible asset token joins the unit as a capability-and-risk study (decision by Evan, 2026-08-22): not a goal, built on the singleton's lineage machinery to know what the vocabulary can build and what it exposes, BAT1 only if a standard is ever written. Remaining order: the identity-assert rework of both landed puzzles, unit 6d, then the payment pool, the async offer, and the token study. +- [ ] **Unit 7: the four benchmark puzzles, written in the language, as the acceptance artifact:** vault, payment pool, async offer, singleton wrapper. This phase makes them exist and run, Phase 4 measures them. One PR per puzzle where size warrants. Style constraint (decision by Evan, 2026-08-16, simplified by unit 6c, widened by unit 6b's resequencing): the puzzles are plain functions, the fixed forms, and the unit 6b surfaces, there being no macro system, and the authoring experience feeds the typed v1 gate's ledger note on whether unit 6c's cut ever binds. Vault first, resequenced ahead of the pool (decision by Evan, 2026-08-16): core semantics match BIP-345, one PR, plus the keyless leader/follower consolidation path over the message ledger with its theft vectors, the evaluation doc's section 3.2 benchmark. Recovery posture is a curried per-instance choice, keyless or keyed (decision by Evan, 2026-08-16). The vault's dispatch helpers exist only to name a reconstructed root once, the pre-registered assign trigger firing, evidence for the unit 7 ledger note. Singleton wrapper second, landed 2026-08-22 (PR 59), resequenced ahead of the pool and the offer: the Chia shape does not port, because a txid commits input outpoints but not input scripts and no VM operator performs the taproot tweak, so the lineage scriptPubKey is constant per launch and the state rides in a tagged OP_RETURN of the creating transaction, read back through two transaction preimages. The constant scriptPubKey is the built shape, both sides stated in `docs/puzzles/singleton.md` (decision by Evan, 2026-08-22, on the census below), and the taproot-tweak operator condition-record decision 3 declined is flagged for the Phase 4 gate with this puzzle as the case that answers decision 3's objection. The helpers that name a txid and an inner hash once are the assign trigger firing again. Value question answered 2026-08-22 in that doc (census of the vendored Chia corpora plus the Bitcoin constructions): Ark, Lightning, vaults, and pools get identity from the root outpoint and need no singleton, the lineage machinery is what the fungible asset token needs, and the remaining lineage effort belongs there. The token is a capability-and-risk study, not a goal: BitLisp does not want fungible asset tokens on Bitcoin, it wants to know what the vocabulary can build and what that exposes (decision by Evan, 2026-08-22). The name BAT1 applies if a standard is ever written. Eight authoring observations across the vault and the singleton are recorded there for the ledger. The fungible asset token joins the unit as a capability-and-risk study (decision by Evan, 2026-08-22): not a goal, built on the singleton's lineage machinery to know what the vocabulary can build and what it exposes, BAT1 only if a standard is ever written. Resequenced 2026-09-05 (decision by Evan, skeleton change under ground rule 6, on the whole-repo review in PR 66): the commitment scheme (unit 8) and the vault measurement (unit 9) come before the pool, because the vault's serialized program is 2,562 bytes, about 640 vb under the witness discount before its solution, control block, and transaction, against the 526 vb single-exit gate and the evaluation doc's 336 to 374 vb envelope estimate. Remaining order: unit 8, unit 9, then the payment pool, then the async offer. The token study leaves the unit: deferred past Phase 5, built only if a reviewer asks what the vocabulary exposes. +- [ ] **Unit 8: the commitment scheme** (added 2026-09-05, decision by Evan, pulled forward from Phase 4): `spec/SPEC.md` sections 2 and 3 written as a real design with the vault as the workload. The leaf version, what the leaf commits to, the witness layout, per-path leaves so a spend reveals only the executed path, the currying discipline, and whether shared library code (`tx-wire`, `curry-hash`) is committed once or revealed per spend. A Fable design session, landing in `spec/` before any code. The singleton's constant-scriptPubKey workaround is the first case it must answer. +- [ ] **Unit 9: the vault measured under unit 8** (added 2026-09-05, decision by Evan): the vault re-pinned under the commitment scheme and every spend path's vbyte total recorded in the evaluation doc against the 526 vb gate, pass or miss. The number decides the pool's shape and the compiler question below before the pool is written. **Standing constraints:** +- **Language frozen** (2026-09-05, decision by Evan): no new special forms, library surfaces, or authoring conveniences until a puzzle shows a gap that costs witness bytes. The one language question left open is code size: whether the compiler gains an optimizer (environment rebuild per call, `let` at one apply plus one cons per binding) or the benchmarks take the pre-registered hand-written fallback, decided on the unit 9 number. - **Hard scope boundary.** v0 is a small s-expression language with Chialisp-class ergonomics, BitLisp-native, no BLS vocabulary. Out of scope: static types, a Rue-class frontend, editor tooling, optimization beyond the obvious. The typed v1 question is a separate gate after Phase 5 review (ledger notes: 2026-08-11, types would let the REPL print by known type. 2026-08-16, whether the unit 6c macro cut holds for v1, decided on the unit 7 authoring experience. 2026-08-22, eight observations from two puzzles in `docs/puzzles/singleton.md`: `let` first, domain guards as the typing evidence, a standard list library, `list*`, a shared test harness, and a Phase 4 encoding-operator cost question). - **Compiler correctness story.** The compiler sits outside spec-before-code, but compiler bugs become fund-loss bugs for users: every construct gets compile-and-run tests against the reference VM, and every compiled benchmark puzzle is pinned as a vector the same day. - **Fallback, pre-registered:** if v0 slips, Phase 4 proceeds with the stock Chialisp toolchain off-tree, constrained to the CLVM intersection. The stopgap never enters the tree. The fallback covers the language units only; the front end does not slip out of the phase. @@ -104,7 +107,9 @@ descriptions, and commit messages* **Goal:** replace every (est.) in the evaluation doc; make the gate empirical; settle the hardened-impl structure. -- [ ] Serialize the benchmark puzzles. Measure bytes. +- [ ] Serialize the benchmark puzzles. Measure bytes. The vault's first-order number moved to Phase 3 unit 9 (2026-09-05). +- [ ] One honest comparator, not eight (2026-09-05, decision by Evan): a CTV plus CSFS pool exit from the current public work, measured the same way as ours, replaces the roadmap's benchmark-per-candidate item. The other candidates keep their (est.) columns and the essay says so. +- [ ] The weight mapping before any "fits the gate" claim (2026-09-05): cost units are CLVM's and the gate is in vbytes, and nothing about fees can be said until the bridge exists. - [ ] Measure what a transaction-parsing or fixed-width encoding operator family would remove from a lineage spend's cost and bytes before deciding one (added 2026-08-22, decision by Evan): the singleton's serialization library is the workload, and the preimage bytes stay in the witness either way. - [ ] Revisit the taproot tweak operator condition-record decision 3 declined, at the asset token study (added 2026-08-22, decision by Evan): the singleton answers decision 3's objection, and the token decides whether state in the scriptPubKey is worth a point multiplication. - [ ] Commitment-hash utility for the front end (queued 2026-08-14): a command printing what a scriptPubKey commits to. Deferred here because the commitment scheme decides its output. @@ -126,13 +131,14 @@ descriptions, and commit messages* **Goal:** the confidence experiment. -- [ ] Essay from the evaluation doc spine, open problems stated plainly. +- [ ] Narrow hostile review of `spec/VALIDATION.md` now, not after Phase 4 (2026-09-05, decision by Evan): the validator is finished, it is the novel surface, and a structural finding there reworks everything built on it. One reader who wants it to be wrong, given the spec, the vector runner, and the invariant suites. The fresh-reader predictability exercise owed from Phase 2 runs as part of it. +- [ ] Essay from the evaluation doc spine, open problems stated plainly, at a tenth the length of the record docs. - [ ] Publish `spec/` publicly with the essay. - [ ] Website: bitlisp.org (bitlisp.com and .net redirect) serving the essay, rendered spec, and docs from a pinned commit. The site never gates sharing. - [ ] In-web playground once the site exists: Pyodide running the real Python reference VM and the v0 compiler. A JavaScript reimplementation is explicitly rejected. - [ ] Delving Bitcoin post; direct note to AJ with the measured artifacts and an invitation to break VALIDATION.md. - [ ] Track objections and revise the evaluation doc against them, including downward. -- [ ] Owed from Phase 2: the fresh-reader predictability exercise. +- [ ] Owed from Phase 2: the fresh-reader predictability exercise, folded into the validator review above (2026-09-05). **Done when:** essay live; at least 3 substantive external technical responses engaged; the evaluation doc revised against actual objections. diff --git a/python/bitlisp/conditions.py b/python/bitlisp/conditions.py index 827b795..ad0049e 100644 --- a/python/bitlisp/conditions.py +++ b/python/bitlisp/conditions.py @@ -470,23 +470,37 @@ def _parse_int(atom, what): return value +def _parse_amount(atom, what): + """A satoshi amount: a minimally encoded integer in 0 to + MAX_MONEY, the one domain every amount operand shares.""" + amount = _parse_int(atom, what) + if not 0 <= amount <= MAX_MONEY: + raise BitLispError("bad_condition_arg", f"{what} out of range: {amount}") + return amount + + +def _script_pubkey_atom(atom, what, minimum): + """A scriptPubKey operand: an atom of at most MAX_SCRIPT_PUBKEY_SIZE + bytes. A claimed output's script must be non-empty, the + self-assert and specifier comparands may be empty.""" + if not is_atom(atom): + raise BitLispError("bad_condition_arg", f"{what} must be an atom") + if not minimum <= len(atom) <= MAX_SCRIPT_PUBKEY_SIZE: + raise BitLispError( + "bad_condition_arg", + f"{what} must be {minimum} to {MAX_SCRIPT_PUBKEY_SIZE} bytes, " + f"got {len(atom)}", + ) + return atom + + def _parse_create_output(args): if len(args) != 2: raise BitLispError( "bad_condition_arity", f"CREATE_OUTPUT takes 2 arguments, got {len(args)}" ) - script_pubkey, amount_atom = args - if not is_atom(script_pubkey): - raise BitLispError("bad_condition_arg", "scriptPubKey must be an atom") - if not 1 <= len(script_pubkey) <= MAX_SCRIPT_PUBKEY_SIZE: - raise BitLispError( - "bad_condition_arg", - f"scriptPubKey must be 1 to {MAX_SCRIPT_PUBKEY_SIZE} bytes, " - f"got {len(script_pubkey)}", - ) - amount = _parse_int(amount_atom, "CREATE_OUTPUT amount") - if not 0 <= amount <= MAX_MONEY: - raise BitLispError("bad_condition_arg", f"amount out of range: {amount}") + script_pubkey = _script_pubkey_atom(args[0], "scriptPubKey", 1) + amount = _parse_amount(args[1], "CREATE_OUTPUT amount") return CreateOutput(script_pubkey, amount) @@ -541,9 +555,7 @@ def _parse_create_output_taproot(args, meter): ) internal_key, merkle_root, amount_atom = args _check_taproot_components(internal_key, merkle_root) - amount = _parse_int(amount_atom, "CREATE_OUTPUT_TAPROOT amount") - if not 0 <= amount <= MAX_MONEY: - raise BitLispError("bad_condition_arg", f"amount out of range: {amount}") + amount = _parse_amount(amount_atom, "CREATE_OUTPUT_TAPROOT amount") meter.charge(CONDITION_COSTS[CREATE_OUTPUT_TAPROOT]) script_pubkey = _derive_taproot_spk(internal_key, merkle_root) return CreateOutputTaproot(internal_key, merkle_root, amount, script_pubkey) @@ -579,18 +591,9 @@ def _parse_assert_my_scriptpubkey(args): "bad_condition_arity", f"ASSERT_MY_SCRIPTPUBKEY takes 1 argument, got {len(args)}", ) - atom = args[0] - if not is_atom(atom): - raise BitLispError( - "bad_condition_arg", "ASSERT_MY_SCRIPTPUBKEY operand must be an atom" - ) - if len(atom) > MAX_SCRIPT_PUBKEY_SIZE: - raise BitLispError( - "bad_condition_arg", - f"ASSERT_MY_SCRIPTPUBKEY operand must be at most " - f"{MAX_SCRIPT_PUBKEY_SIZE} bytes, got {len(atom)}", - ) - return AssertMyScriptPubKey(atom) + return AssertMyScriptPubKey( + _script_pubkey_atom(args[0], "ASSERT_MY_SCRIPTPUBKEY operand", 0) + ) def _parse_assert_my_amount(args): @@ -599,10 +602,7 @@ def _parse_assert_my_amount(args): "bad_condition_arity", f"ASSERT_MY_AMOUNT takes 1 argument, got {len(args)}", ) - amount = _parse_int(args[0], "ASSERT_MY_AMOUNT operand") - if not 0 <= amount <= MAX_MONEY: - raise BitLispError("bad_condition_arg", f"amount out of range: {amount}") - return AssertMyAmount(amount) + return AssertMyAmount(_parse_amount(args[0], "ASSERT_MY_AMOUNT operand")) def _parse_assert_my_taptree(args): @@ -652,12 +652,7 @@ def _parse_specifier(commitment, args, name): fields = [] for kind, atom in zip(SPECIFIER_OPERANDS[commitment], args, strict=True): if kind == "amount": - value = _parse_int(atom, f"{name} specifier amount") - if not 0 <= value <= MAX_MONEY: - raise BitLispError( - "bad_condition_arg", f"specifier amount out of range: {value}" - ) - fields.append(value) + fields.append(_parse_amount(atom, f"{name} specifier amount")) continue if kind == "outpoint": fields.append(_fixed_width_atom(atom, f"{name} outpoint", OUTPOINT_SIZE)) @@ -666,17 +661,7 @@ def _parse_specifier(commitment, args, name): fields.append(_fixed_width_atom(atom, f"{name} {kind}", 32)) continue if kind == "script_pubkey": - if not is_atom(atom): - raise BitLispError( - "bad_condition_arg", f"{name} {kind} must be an atom" - ) - if len(atom) > MAX_SCRIPT_PUBKEY_SIZE: - raise BitLispError( - "bad_condition_arg", - f"{name} scriptPubKey must be at most " - f"{MAX_SCRIPT_PUBKEY_SIZE} bytes, got {len(atom)}", - ) - fields.append(atom) + fields.append(_script_pubkey_atom(atom, f"{name} scriptPubKey", 0)) continue raise AssertionError(f"unhandled specifier operand kind: {kind}") return Specifier(commitment, tuple(fields)) @@ -765,10 +750,7 @@ def _parse_reserve_fee(args): "bad_condition_arity", f"RESERVE_FEE takes 1 argument, got {len(args)}", ) - reserve = _parse_int(args[0], "RESERVE_FEE operand") - if not 0 <= reserve <= MAX_MONEY: - raise BitLispError("bad_condition_arg", f"reserve out of range: {reserve}") - return ReserveFee(reserve) + return ReserveFee(_parse_amount(args[0], "RESERVE_FEE operand")) def _parse_reserved(opcode, args): diff --git a/python/bitlisp/validation.py b/python/bitlisp/validation.py index 0cebad4..285e994 100644 --- a/python/bitlisp/validation.py +++ b/python/bitlisp/validation.py @@ -397,15 +397,12 @@ def check_fee_reserve(tx): def check_seals(tx): """The seal family: each condition is an equality against a quantity derived from the assembled transaction, SEAL against - its txid, SEAL_OUTPUTS against its outputs hash. Derived only - when a seal is present: most transactions carry none.""" - txid = None - outputs_hash = None + its txid, SEAL_OUTPUTS against its outputs hash.""" + txid = tx.txid + outputs_hash = tx.outputs_hash for tx_input in tx.inputs: for cond in tx_input.conditions or (): if isinstance(cond, Seal): - if txid is None: - txid = tx.txid if cond.txid != txid: raise BitLispError( "unsatisfied_seal_assert", @@ -413,8 +410,6 @@ def check_seals(tx): f"transaction's txid is {txid.hex()}", ) elif isinstance(cond, SealOutputs): - if outputs_hash is None: - outputs_hash = tx.outputs_hash if cond.outputs_hash != outputs_hash: raise BitLispError( "unsatisfied_seal_assert", diff --git a/python/bitlisp_tools/compiler.py b/python/bitlisp_tools/compiler.py index b359c02..1a67362 100644 --- a/python/bitlisp_tools/compiler.py +++ b/python/bitlisp_tools/compiler.py @@ -7,8 +7,8 @@ themselves, names resolve to environment paths or inline constant values, and the special forms are program, defun, defun-inline, defconstant, include, if, let, list, list*, assert, and, and or. -Everything -else a source expression can say is an operator application. +Everything else a source expression can say is an operator +application. A compiled program's environment is the pair (function tree . args). The function tree holds every reachable function body, balanced in @@ -336,36 +336,46 @@ def __init__(self): self.inlines = {} self.constants = {} + def __contains__(self, name): + return name in self.functions or name in self.inlines or name in self.constants + def _claim(self, symbol, taken): name = _check_name(symbol, "definition") - if ( - name in self.functions - or name in self.inlines - or name in self.constants - or name in taken - ): + if name in self or name in taken: raise CompileError(f"{name!r} is already defined", symbol.offset) return name + def add(self, form, taken=frozenset()): + """Adds one declaration form, dispatched on its keyword. An + include form is not a declaration: the caller splices its + file's declarations and adds each of those.""" + keyword = declaration_keyword(form) + if keyword == _DEFUN: + return self.add_defun(form, taken) + if keyword == _DEFUN_INLINE: + return self.add_defun_inline(form, taken) + if keyword == _DEFCONSTANT: + return self.add_defconstant(form, taken) + raise CompileError("expected defun, defun-inline, defconstant, or include") + + def _add_function(self, form, keyword, space, taken): + items = _form_items(form, keyword, 4) + name = self._claim(items[1], taken) + arity = _check_params(items[2]) + space[name] = (items[2], items[3], arity) + return name + def add_defun(self, form, taken=frozenset()): """Adds one (defun name params body) source form. The body is stored as written and compiles when a program reaches it, so definitions may reference names that arrive later.""" - items = _form_items(form, _DEFUN, 4) - name = self._claim(items[1], taken) - arity = _check_params(items[2]) - self.functions[name] = (items[2], items[3], arity) - return name + return self._add_function(form, _DEFUN, self.functions, taken) def add_defun_inline(self, form, taken=frozenset()): """Adds one (defun-inline name params body) source form. The body is stored as written and splices at each call site a program reaches, never entering the function tree.""" - items = _form_items(form, _DEFUN_INLINE, 4) - name = self._claim(items[1], taken) - arity = _check_params(items[2]) - self.inlines[name] = (items[2], items[3], arity) - return name + return self._add_function(form, _DEFUN_INLINE, self.inlines, taken) def add_defconstant(self, form, taken=frozenset()): """Adds one (defconstant name value) source form. The value @@ -457,10 +467,7 @@ def _bind_inline_params(params, arguments): index += 1 spine = spine[1] if isinstance(spine, Symbol): - rest = NIL - for argument in reversed(arguments[index:]): - rest = _proper_list(_CONS, argument, rest) - bindings[spine.name] = rest + bindings[spine.name] = _cons_onto(arguments[index:], NIL) while stack: tree, node = stack.pop() if isinstance(tree, Symbol): @@ -537,10 +544,9 @@ def _proper_items(node, what, offset_hint=None): while is_pair(node): items.append(node[0]) node = node[1] - if node != NIL and not isinstance(node, Symbol): - raise CompileError(f"{what} takes a proper argument list", offset_hint) - if isinstance(node, Symbol): - raise CompileError(f"{what} takes a proper argument list", node.offset) + if node != NIL: + offset = node.offset if isinstance(node, Symbol) else offset_hint + raise CompileError(f"{what} takes a proper argument list", offset) return items @@ -857,9 +863,7 @@ def _let(self, head, tail, bindings): rebound[name] = _proper_list(_APPLY, _quote(bound), environment) rebound.update(bound_paths) body = self.expression(items[1], rebound) - rest = int_to_atom(root) - for value in reversed(values): - rest = _proper_list(_CONS, value, rest) + rest = _cons_onto(values, int_to_atom(root)) if has_tree: rest = _proper_list(_CONS, int_to_atom(_LEFT), rest) return _proper_list(_APPLY, _quote(body), rest) @@ -912,12 +916,8 @@ def _call(self, head, tail, bindings): # The callee sees the caller's layout rebuilt: the function # tree it received at path 2, consed onto the evaluated # arguments as a proper list. - argument_list = NIL - for argument in reversed(arguments): - argument_list = _proper_list( - _CONS, self.expression(argument, bindings), argument_list - ) - environment = _proper_list(_CONS, int_to_atom(_LEFT), argument_list) + compiled = [self.expression(argument, bindings) for argument in arguments] + environment = _proper_list(_CONS, int_to_atom(_LEFT), _cons_onto(compiled, NIL)) return _proper_list(_APPLY, int_to_atom(self.fn_paths[name]), environment) def _operator(self, op, tail, bindings): @@ -1162,18 +1162,8 @@ def compile_program(source, include_paths=()): _check_params(params) defs = Definitions() for declaration, origin in _spliced(items[1:-1], include_paths): - keyword = declaration_keyword(declaration) try: - if keyword == _DEFUN: - defs.add_defun(declaration) - elif keyword == _DEFUN_INLINE: - defs.add_defun_inline(declaration) - elif keyword == _DEFCONSTANT: - defs.add_defconstant(declaration) - else: - raise CompileError( - "expected defun, defun-inline, defconstant, or include" - ) + defs.add(declaration) except CompileError as exc: # An included declaration's offsets index its own file's # text, so the error names the file, as a function body's diff --git a/python/bitlisp_tools/repl.py b/python/bitlisp_tools/repl.py index 13b27ec..cef9e67 100644 --- a/python/bitlisp_tools/repl.py +++ b/python/bitlisp_tools/repl.py @@ -392,7 +392,7 @@ def _declare(self, line): tree, self.include_paths, self.loaded_includes ): try: - self._add_declaration(declaration) + self.defs.add(declaration, set(self.names)) except CompileError as exc: raise CompileError(f'in include "{origin}": {exc}') from None except CompileError: @@ -404,19 +404,7 @@ def _declare(self, line): self.loaded_includes = snapshot[3] raise return - self._add_declaration(tree) - - def _add_declaration(self, tree): - taken = set(self.names) - keyword = declaration_keyword(tree) - if keyword == "defun": - self.defs.add_defun(tree, taken) - elif keyword == "defun-inline": - self.defs.add_defun_inline(tree, taken) - elif keyword == "defconstant": - self.defs.add_defconstant(tree, taken) - else: - raise CompileError("expected defun, defun-inline, defconstant, or include") + self.defs.add(tree, set(self.names)) @_survives def do_def(self, arg): @@ -438,11 +426,7 @@ def do_def(self, arg): if name in RESERVED_WORDS or name in CONDITION_CONSTANTS: print(f"error: {name!r} is reserved by the language") return - if ( - name in self.defs.functions - or name in self.defs.inlines - or name in self.defs.constants - ): + if name in self.defs: print(f"error: {name!r} is already defined") return nodes = assemble_many(body, self.names) diff --git a/tools/run_vectors.py b/tools/run_vectors.py index 29bb224..112115b 100755 --- a/tools/run_vectors.py +++ b/tools/run_vectors.py @@ -24,6 +24,7 @@ import json import sys +from dataclasses import fields from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -121,128 +122,45 @@ def run_vm_case(case): raise VectorError(f"expected {expect}, got {outcome}") -def _specifier_json(specifier): - """The pinned JSON form of a participant specifier: the - commitment value and the fields in operand order, amounts as - integers, everything else as hex.""" +def _json_value(value): + """The pinned JSON form of one parsed operand: integers as + integers, bytes as hex, a participant specifier as its + commitment value and its fields in operand order.""" + from bitlisp.conditions import Specifier + + if isinstance(value, bool) or not isinstance(value, int | bytes | Specifier): + raise VectorError(f"no JSON form for operand {value!r}") + if isinstance(value, int): + return value + if isinstance(value, bytes): + return value.hex() return { - "commitment": specifier.commitment, - "fields": [ - field if isinstance(field, int) else field.hex() - for field in specifier.fields - ], + "commitment": value.commitment, + "fields": [_json_value(field) for field in value.fields], } def _condition_json(cond): - """The pinned JSON form of one parsed condition.""" + """The pinned JSON form of one parsed condition: its opcode, then + every operand under its model field name. A reserved condition's + raw argument nodes pin as serialized hex.""" from bitlisp import serialize - from bitlisp.conditions import ( - Announce, - AssertAnnouncement, - AssertLocktimeHeight, - AssertLocktimeTime, - AssertMyAmount, - AssertMyOutpoint, - AssertMyScriptPubKey, - AssertMyTaptree, - AssertMyTxid, - AssertSequenceHeight, - AssertSequenceTime, - AssertSig, - Assure, - CreateOutput, - CreateOutputTaproot, - Require, - Reserved, - ReserveFee, - Seal, - SealOutputs, - ) + from bitlisp.conditions import Reserved - if isinstance(cond, AssertLocktimeHeight): - return {"opcode": cond.opcode, "height": cond.height} - if isinstance(cond, AssertLocktimeTime): - return {"opcode": cond.opcode, "time": cond.time} - if isinstance(cond, AssertSequenceHeight): - return {"opcode": cond.opcode, "blocks": cond.blocks} - if isinstance(cond, AssertSequenceTime): - return {"opcode": cond.opcode, "units": cond.units} - if isinstance(cond, CreateOutput): - return { - "opcode": cond.opcode, - "script_pubkey": cond.script_pubkey.hex(), - "amount": cond.amount, - } - if isinstance(cond, CreateOutputTaproot): - return { - "opcode": cond.opcode, - "internal_key": cond.internal_key.hex(), - "merkle_root": cond.merkle_root.hex(), - "amount": cond.amount, - "script_pubkey": cond.script_pubkey.hex(), - } - if isinstance(cond, AssertSig): - return { - "opcode": cond.opcode, - "pubkey": cond.pubkey.hex(), - "message": cond.message.hex(), - "signature": cond.signature.hex(), - } - if isinstance(cond, AssertMyOutpoint): - return {"opcode": cond.opcode, "outpoint": cond.outpoint.hex()} - if isinstance(cond, AssertMyTxid): - return {"opcode": cond.opcode, "txid": cond.txid.hex()} - if isinstance(cond, AssertMyScriptPubKey): - return {"opcode": cond.opcode, "script_pubkey": cond.script_pubkey.hex()} - if isinstance(cond, AssertMyAmount): - return {"opcode": cond.opcode, "amount": cond.amount} - if isinstance(cond, AssertMyTaptree): - return { - "opcode": cond.opcode, - "internal_key": cond.internal_key.hex(), - "merkle_root": cond.merkle_root.hex(), - } - if isinstance(cond, Announce): - return { - "opcode": cond.opcode, - "namespace": cond.namespace.hex(), - "payload": cond.payload.hex(), - } - if isinstance(cond, AssertAnnouncement): - return { - "opcode": cond.opcode, - "announcer": _specifier_json(cond.announcer), - "namespace": cond.namespace.hex(), - "payload": cond.payload.hex(), - } - if isinstance(cond, Assure): - return { - "opcode": cond.opcode, - "assurer_commitment": cond.assurer_commitment, - "requirer": _specifier_json(cond.requirer), - "message": cond.message.hex(), - } - if isinstance(cond, Require): - return { - "opcode": cond.opcode, - "assurer": _specifier_json(cond.assurer), - "requirer_commitment": cond.requirer_commitment, - "message": cond.message.hex(), - } - if isinstance(cond, ReserveFee): - return {"opcode": cond.opcode, "reserve": cond.reserve} - if isinstance(cond, Seal): - return {"opcode": cond.opcode, "txid": cond.txid.hex()} - if isinstance(cond, SealOutputs): - return {"opcode": cond.opcode, "outputs_hash": cond.outputs_hash.hex()} if isinstance(cond, Reserved): return { "opcode": cond.opcode, "cost": cond.cost, "args": [serialize(arg).hex() for arg in cond.args], } - raise VectorError(f"no JSON form for condition type {type(cond).__name__}") + return { + "opcode": cond.opcode, + **{ + field.name: _json_value(getattr(cond, field.name)) + for field in fields(cond) + if field.name != "opcode" + }, + } def run_conditions_case(case):