From a3b6599630d164838f121d9b6a7661876a2a36cc Mon Sep 17 00:00:00 2001 From: Fernando Pelliccioni Date: Thu, 23 Apr 2026 06:28:26 +0200 Subject: [PATCH] Sync to kth/0.82.0 + expose vm::metrics to Python Surfaces the script execution metrics (op-cost, sig-check, hash-digest-iteration tallies plus limit predicates) so Python debuggers can inspect runtime cost alongside the existing program / interpreter / debug_snapshot bindings. `vm_program_get_metrics` now advertises the return type as `"Metrics" | None` in the stubs instead of the previous generic `object | None`. --- conanfile.py | 2 +- include/kth/py-native/capsule_names.h | 2 + include/kth/py-native/vm/metrics.h | 39 ++++ kth_native.pyi | 28 ++- setup.py | 1 + src/module.c | 2 + src/vm/metrics.cpp | 261 ++++++++++++++++++++++++ src/vm/program.cpp | 2 +- tests/test_vm_metrics.py | 272 ++++++++++++++++++++++++++ 9 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 include/kth/py-native/vm/metrics.h create mode 100644 src/vm/metrics.cpp create mode 100644 tests/test_vm_metrics.py diff --git a/conanfile.py b/conanfile.py index 7b07419..b83a636 100644 --- a/conanfile.py +++ b/conanfile.py @@ -40,7 +40,7 @@ class KnuthPyNative(ConanFile): # Single unified Knuth package; previously this used the standalone # c-api/@kth/stable recipe, which no longer exists. def requirements(self): - self.requires("kth/0.81.1", transitive_headers=True, transitive_libs=True) + self.requires("kth/0.82.0", transitive_headers=True, transitive_libs=True) def generate(self): # Stage headers and static libs from kth AND all its transitive diff --git a/include/kth/py-native/capsule_names.h b/include/kth/py-native/capsule_names.h index 8eb4cb4..06afd30 100644 --- a/include/kth/py-native/capsule_names.h +++ b/include/kth/py-native/capsule_names.h @@ -67,6 +67,7 @@ extern "C" { #define KTH_PY_CAPSULE_VM_DEBUG_SNAPSHOT "kth.vm.debug_snapshot" #define KTH_PY_CAPSULE_VM_DEBUG_SNAPSHOT_LIST "kth.vm.debug_snapshot_list" #define KTH_PY_CAPSULE_VM_INTERPRETER "kth.vm.interpreter" +#define KTH_PY_CAPSULE_VM_METRICS "kth.vm.metrics" #define KTH_PY_CAPSULE_VM_PROGRAM "kth.vm.program" #define KTH_PY_CAPSULE_WALLET_EC_COMPRESSED_LIST "kth.wallet.ec_compressed_list" #define KTH_PY_CAPSULE_WALLET_EC_PRIVATE "kth.wallet.ec_private" @@ -111,6 +112,7 @@ void kth_py_native_chain_utxo_list_capsule_dtor(PyObject* capsule); void kth_py_native_core_binary_capsule_dtor(PyObject* capsule); void kth_py_native_vm_debug_snapshot_capsule_dtor(PyObject* capsule); void kth_py_native_vm_debug_snapshot_list_capsule_dtor(PyObject* capsule); +void kth_py_native_vm_metrics_capsule_dtor(PyObject* capsule); void kth_py_native_vm_program_capsule_dtor(PyObject* capsule); void kth_py_native_wallet_ec_private_capsule_dtor(PyObject* capsule); void kth_py_native_wallet_ec_public_capsule_dtor(PyObject* capsule); diff --git a/include/kth/py-native/vm/metrics.h b/include/kth/py-native/vm/metrics.h new file mode 100644 index 0000000..0414211 --- /dev/null +++ b/include/kth/py-native/vm/metrics.h @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Knuth Project developers. +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef KTH_PY_NATIVE_VM_METRICS_H_ +#define KTH_PY_NATIVE_VM_METRICS_H_ + +#define PY_SSIZE_T_CLEAN +#include + +#ifdef __cplusplus +extern "C" { +#endif + +PyObject* kth_py_native_vm_metrics_copy(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_destruct(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_sig_checks(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_op_cost(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_hash_digest_iterations(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_add_op_cost(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_add_push_op(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_add_hash_iterations(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_add_sig_checks(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_is_over_op_cost_limit(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_is_over_op_cost_limit_simple(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_is_over_hash_iters_limit(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_has_valid_script_limits(PyObject* self, PyObject* arg); +PyObject* kth_py_native_vm_metrics_set_script_limits(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_set_native_script_limits(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_composite_op_cost_script_flags(PyObject* self, PyObject* args, PyObject* kwds); +PyObject* kth_py_native_vm_metrics_composite_op_cost_bool(PyObject* self, PyObject* args, PyObject* kwds); + +extern PyMethodDef kth_py_native_vm_metrics_methods[]; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // KTH_PY_NATIVE_VM_METRICS_H_ diff --git a/kth_native.pyi b/kth_native.pyi index 5ee303c..2c5ed1e 100644 --- a/kth_native.pyi +++ b/kth_native.pyi @@ -1487,7 +1487,7 @@ def vm_program_construct_from_script_program(script: "Script", x: "Program") -> def vm_program_construct_from_script_program_move(script: "Script", x: "Program", move: bool) -> Program: ... def vm_program_copy(self: Program) -> Program: ... def vm_program_destruct(self: Program) -> None: ... -def vm_program_get_metrics(self: Program) -> object | None: ... +def vm_program_get_metrics(self: Program) -> "Metrics" | None: ... def vm_program_is_valid(self: Program) -> bool: ... def vm_program_flags(self: Program) -> int: ... def vm_program_max_script_element_size(self: Program) -> int: ... @@ -1585,6 +1585,32 @@ def vm_interpreter_debug_run(snapshot: "DebugSnapshot") -> "DebugSnapshot" | Non def vm_interpreter_debug_run_traced(start: "DebugSnapshot") -> "DebugSnapshotList" | None: ... def vm_interpreter_debug_finalize(snapshot: "DebugSnapshot") -> int: ... +# ─── Metrics (auto-generated, do not edit) ───────────────────────── + +class Metrics: + """Opaque handle to a `kth::domain::machine::metrics`. Constructed by + `vm_metrics_construct_*` and released by + `vm_metrics_destruct`.""" + ... + +def vm_metrics_copy(self: Metrics) -> Metrics: ... +def vm_metrics_destruct(self: Metrics) -> None: ... +def vm_metrics_sig_checks(self: Metrics) -> int: ... +def vm_metrics_op_cost(self: Metrics) -> int: ... +def vm_metrics_hash_digest_iterations(self: Metrics) -> int: ... +def vm_metrics_add_op_cost(self: Metrics, cost: int) -> None: ... +def vm_metrics_add_push_op(self: Metrics, stack_item_length: int) -> None: ... +def vm_metrics_add_hash_iterations(self: Metrics, message_length: int, is_two_round_hash: bool) -> None: ... +def vm_metrics_add_sig_checks(self: Metrics, n_checks: int) -> None: ... +def vm_metrics_is_over_op_cost_limit(self: Metrics, flags: int) -> bool: ... +def vm_metrics_is_over_op_cost_limit_simple(self: Metrics) -> bool: ... +def vm_metrics_is_over_hash_iters_limit(self: Metrics) -> bool: ... +def vm_metrics_has_valid_script_limits(self: Metrics) -> bool: ... +def vm_metrics_set_script_limits(self: Metrics, flags: int, script_sig_size: int) -> None: ... +def vm_metrics_set_native_script_limits(self: Metrics, standard: bool, script_sig_size: int) -> None: ... +def vm_metrics_composite_op_cost_script_flags(self: Metrics, flags: int) -> int: ... +def vm_metrics_composite_op_cost_bool(self: Metrics, standard: bool) -> int: ... + # ─── WalletData (auto-generated, do not edit) ───────────────────────── class WalletData: diff --git a/setup.py b/setup.py index 66285eb..6346bfd 100644 --- a/setup.py +++ b/setup.py @@ -227,6 +227,7 @@ def run(self): 'src/vm/interpreter.cpp', 'src/vm/debug_snapshot.cpp', 'src/vm/debug_snapshot_list.cpp', + 'src/vm/metrics.cpp', # Hand-written async-callback bridge to safe_chain; not # generator-driven because the shape doesn't fit ClassConfig. 'src/chain/chain.cpp', diff --git a/src/module.c b/src/module.c index 20ad20c..cd6df6e 100644 --- a/src/module.c +++ b/src/module.c @@ -62,6 +62,7 @@ #include #include #include +#include #include // ── AUTO-GENERATED INCLUDES END ─────────────────────────────────────── // `word_list.h` is the only hand-written binding still here — every @@ -935,6 +936,7 @@ PyInit_kth_native(void) { KTH_REGISTER_METHODS(kth_py_native_vm_debug_snapshot_methods); KTH_REGISTER_METHODS(kth_py_native_vm_debug_snapshot_list_methods); KTH_REGISTER_METHODS(kth_py_native_vm_interpreter_methods); + KTH_REGISTER_METHODS(kth_py_native_vm_metrics_methods); KTH_REGISTER_METHODS(kth_py_native_wallet_wallet_data_methods); #undef KTH_REGISTER_METHODS diff --git a/src/vm/metrics.cpp b/src/vm/metrics.cpp new file mode 100644 index 0000000..ca002a3 --- /dev/null +++ b/src/vm/metrics.cpp @@ -0,0 +1,261 @@ +// Copyright (c) 2016-present Knuth Project developers. +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// PyCapsule destructor — released by GC when the capsule is +// collected. Explicit `destruct` calls set the capsule name to +// "kth.destroyed", so PyCapsule_IsValid returns false and this +// destructor becomes a no-op (no double-free). +void kth_py_native_vm_metrics_capsule_dtor(PyObject* capsule) { + if ( ! PyCapsule_IsValid(capsule, KTH_PY_CAPSULE_VM_METRICS)) return; + kth_metrics_mut_t handle = (kth_metrics_mut_t)PyCapsule_GetPointer(capsule, KTH_PY_CAPSULE_VM_METRICS); + if (handle != NULL) kth_vm_metrics_destruct(handle); +} + +PyObject* +kth_py_native_vm_metrics_copy(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_copy(self_handle); + if (result == NULL) { + PyErr_SetString(PyExc_MemoryError, "kth: allocation failed"); + return NULL; + } + PyObject* capsule = PyCapsule_New((void*)result, KTH_PY_CAPSULE_VM_METRICS, kth_py_native_vm_metrics_capsule_dtor); + if (capsule == NULL) { + kth_vm_metrics_destruct(result); + return NULL; + } + return capsule; +} + +PyObject* +kth_py_native_vm_metrics_destruct(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_destruct(self_handle); + PyCapsule_SetName(py_self, "kth.destroyed"); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_sig_checks(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_sig_checks(self_handle); + return PyLong_FromUnsignedLongLong((unsigned long long)result); +} + +PyObject* +kth_py_native_vm_metrics_op_cost(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_op_cost(self_handle); + return PyLong_FromUnsignedLongLong((unsigned long long)result); +} + +PyObject* +kth_py_native_vm_metrics_hash_digest_iterations(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_hash_digest_iterations(self_handle); + return PyLong_FromUnsignedLongLong((unsigned long long)result); +} + +PyObject* +kth_py_native_vm_metrics_add_op_cost(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"cost", NULL}; + PyObject* py_self = NULL; + unsigned int cost = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OI", kwlist, &py_self, &cost)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_add_op_cost(self_handle, (uint32_t)cost); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_add_push_op(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"stack_item_length", NULL}; + PyObject* py_self = NULL; + unsigned int stack_item_length = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OI", kwlist, &py_self, &stack_item_length)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_add_push_op(self_handle, (uint32_t)stack_item_length); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_add_hash_iterations(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"message_length", (char*)"is_two_round_hash", NULL}; + PyObject* py_self = NULL; + unsigned int message_length = 0; + int is_two_round_hash = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OIp", kwlist, &py_self, &message_length, &is_two_round_hash)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_add_hash_iterations(self_handle, (uint32_t)message_length, (kth_bool_t)is_two_round_hash); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_add_sig_checks(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"n_checks", NULL}; + PyObject* py_self = NULL; + unsigned int n_checks = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OI", kwlist, &py_self, &n_checks)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_add_sig_checks(self_handle, (uint32_t)n_checks); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_is_over_op_cost_limit(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"flags", NULL}; + PyObject* py_self = NULL; + unsigned long long flags = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OK", kwlist, &py_self, &flags)) { + return NULL; + } + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_is_over_op_cost_limit(self_handle, (kth_script_flags_t)flags); + return PyBool_FromLong((long)result); +} + +PyObject* +kth_py_native_vm_metrics_is_over_op_cost_limit_simple(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_is_over_op_cost_limit_simple(self_handle); + return PyBool_FromLong((long)result); +} + +PyObject* +kth_py_native_vm_metrics_is_over_hash_iters_limit(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_is_over_hash_iters_limit(self_handle); + return PyBool_FromLong((long)result); +} + +PyObject* +kth_py_native_vm_metrics_has_valid_script_limits(PyObject* self, PyObject* py_arg0) { + PyObject* py_self = py_arg0; + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_has_valid_script_limits(self_handle); + return PyBool_FromLong((long)result); +} + +PyObject* +kth_py_native_vm_metrics_set_script_limits(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"flags", (char*)"script_sig_size", NULL}; + PyObject* py_self = NULL; + unsigned long long flags = 0; + unsigned long long script_sig_size = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OKK", kwlist, &py_self, &flags, &script_sig_size)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_set_script_limits(self_handle, (kth_script_flags_t)flags, (uint64_t)script_sig_size); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_set_native_script_limits(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"standard", (char*)"script_sig_size", NULL}; + PyObject* py_self = NULL; + int standard = 0; + unsigned long long script_sig_size = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OpK", kwlist, &py_self, &standard, &script_sig_size)) { + return NULL; + } + kth_metrics_mut_t self_handle = (kth_metrics_mut_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + kth_vm_metrics_set_native_script_limits(self_handle, (kth_bool_t)standard, (uint64_t)script_sig_size); + Py_RETURN_NONE; +} + +PyObject* +kth_py_native_vm_metrics_composite_op_cost_script_flags(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"flags", NULL}; + PyObject* py_self = NULL; + unsigned long long flags = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "OK", kwlist, &py_self, &flags)) { + return NULL; + } + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_composite_op_cost_script_flags(self_handle, (kth_script_flags_t)flags); + return PyLong_FromUnsignedLongLong((unsigned long long)result); +} + +PyObject* +kth_py_native_vm_metrics_composite_op_cost_bool(PyObject* self, PyObject* args, PyObject* kwds) { + static char* kwlist[] = {(char*)"self", (char*)"standard", NULL}; + PyObject* py_self = NULL; + int standard = 0; + if ( ! PyArg_ParseTupleAndKeywords(args, kwds, "Op", kwlist, &py_self, &standard)) { + return NULL; + } + kth_metrics_const_t self_handle = (kth_metrics_const_t)PyCapsule_GetPointer(py_self, KTH_PY_CAPSULE_VM_METRICS); + if (self_handle == NULL) return NULL; + auto const result = kth_vm_metrics_composite_op_cost_bool(self_handle, (kth_bool_t)standard); + return PyLong_FromUnsignedLongLong((unsigned long long)result); +} + +PyMethodDef kth_py_native_vm_metrics_methods[] = { + {"vm_metrics_copy", (PyCFunction)kth_py_native_vm_metrics_copy, METH_O, NULL}, + {"vm_metrics_destruct", (PyCFunction)kth_py_native_vm_metrics_destruct, METH_O, NULL}, + {"vm_metrics_sig_checks", (PyCFunction)kth_py_native_vm_metrics_sig_checks, METH_O, NULL}, + {"vm_metrics_op_cost", (PyCFunction)kth_py_native_vm_metrics_op_cost, METH_O, NULL}, + {"vm_metrics_hash_digest_iterations", (PyCFunction)kth_py_native_vm_metrics_hash_digest_iterations, METH_O, NULL}, + {"vm_metrics_add_op_cost", (PyCFunction)kth_py_native_vm_metrics_add_op_cost, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_add_push_op", (PyCFunction)kth_py_native_vm_metrics_add_push_op, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_add_hash_iterations", (PyCFunction)kth_py_native_vm_metrics_add_hash_iterations, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_add_sig_checks", (PyCFunction)kth_py_native_vm_metrics_add_sig_checks, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_is_over_op_cost_limit", (PyCFunction)kth_py_native_vm_metrics_is_over_op_cost_limit, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_is_over_op_cost_limit_simple", (PyCFunction)kth_py_native_vm_metrics_is_over_op_cost_limit_simple, METH_O, NULL}, + {"vm_metrics_is_over_hash_iters_limit", (PyCFunction)kth_py_native_vm_metrics_is_over_hash_iters_limit, METH_O, NULL}, + {"vm_metrics_has_valid_script_limits", (PyCFunction)kth_py_native_vm_metrics_has_valid_script_limits, METH_O, NULL}, + {"vm_metrics_set_script_limits", (PyCFunction)kth_py_native_vm_metrics_set_script_limits, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_set_native_script_limits", (PyCFunction)kth_py_native_vm_metrics_set_native_script_limits, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_composite_op_cost_script_flags", (PyCFunction)kth_py_native_vm_metrics_composite_op_cost_script_flags, METH_VARARGS | METH_KEYWORDS, NULL}, + {"vm_metrics_composite_op_cost_bool", (PyCFunction)kth_py_native_vm_metrics_composite_op_cost_bool, METH_VARARGS | METH_KEYWORDS, NULL}, + {NULL, NULL, 0, NULL} // sentinel +}; + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/src/vm/program.cpp b/src/vm/program.cpp index 3e92032..d6c3099 100644 --- a/src/vm/program.cpp +++ b/src/vm/program.cpp @@ -205,7 +205,7 @@ kth_py_native_vm_program_get_metrics(PyObject* self, PyObject* py_arg0) { PyErr_SetString(PyExc_RuntimeError, "kth: NULL handle returned"); return NULL; } - PyObject* capsule = PyCapsule_New((void*)result, KTH_PY_CAPSULE_CHAIN_METRICS, kth_py_native_borrowed_parent_dtor); + PyObject* capsule = PyCapsule_New((void*)result, KTH_PY_CAPSULE_VM_METRICS, kth_py_native_borrowed_parent_dtor); if (capsule == NULL) return NULL; Py_INCREF(py_self); if (PyCapsule_SetContext(capsule, py_self) != 0) { diff --git a/tests/test_vm_metrics.py b/tests/test_vm_metrics.py new file mode 100644 index 0000000..19d5e0b --- /dev/null +++ b/tests/test_vm_metrics.py @@ -0,0 +1,272 @@ +# Copyright (c) 2016-present Knuth Project developers. +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Tests for the vm_metrics binding. + +`metrics` is the VM's script-execution accounting object: op-cost, +sig-check, and hash-digest-iteration tallies plus the limit +predicates. The invariants mirror the C-API tests +(`src/c-api/test/vm/metrics.cpp`) and the domain-level tests +(`src/domain/test/machine/metrics.cpp`). +""" + +import kth_native as nat + + +TWO_PUSH_BYTES = bytes((0x51, 0x51)) + +# Reproduced from `script_limits.hpp` so the assertions below spell +# out the expected values. +HASH_ITER_FACTOR_CONSENSUS = 64 # non-standard +HASH_ITER_FACTOR_STANDARD = 192 # 64 * 3 +SIG_CHECK_COST_FACTOR = 26_000 # may2025::sig_check_cost_factor + +# Anchor for script_limits computations at n=100 scriptSig bytes: +# op_cost_limit = (n + 41) * 800 = 112'800 +# hash_iters_limit (non-std, factor 7) = 493 +# hash_iters_limit (standard, factor 1) = 70 +SCRIPT_SIG_SIZE = 100 + +# Script flag bit exposed in kth/0.82.0 capi (`kth_script_flags_bch_vm_limits_standard`). +# Mirrored here because the Python binding doesn't re-export flag +# constants — we just hand-roll the bit. +SCRIPT_FLAGS_BCH_VM_LIMITS_STANDARD = 1 << 57 + + +def _fresh_metrics(): + # metrics has no explicit constructor binding. Obtain an owned + # handle by copying the borrowed view from a default program — + # matches the C-API fixture pattern. + script = nat.chain_script_construct_from_data(TWO_PUSH_BYTES, False) + program = nat.vm_program_construct_from_script(script) + borrowed = nat.vm_program_get_metrics(program) + owned = nat.vm_metrics_copy(borrowed) + return owned + + +# ─── Default state ──────────────────────────────────────────────── + +def test_default_metrics_counters_are_zero(): + m = _fresh_metrics() + assert nat.vm_metrics_sig_checks(m) == 0 + assert nat.vm_metrics_op_cost(m) == 0 + assert nat.vm_metrics_hash_digest_iterations(m) == 0 + assert nat.vm_metrics_has_valid_script_limits(m) is False + + +def test_without_script_limits_is_over_predicates_are_false(): + # Before `set_*_script_limits` is called, the limit predicates + # must short-circuit on the absent `script_limits_`. Otherwise a + # caller that forgot to initialise limits would silently + # over-report limit violations based on an uninitialised ceiling. + m = _fresh_metrics() + nat.vm_metrics_add_op_cost(m, 1_000_000_000) + nat.vm_metrics_add_hash_iterations(m, 1 << 20, True) + assert nat.vm_metrics_is_over_op_cost_limit_simple(m) is False + assert nat.vm_metrics_is_over_op_cost_limit(m, 0) is False + assert nat.vm_metrics_is_over_hash_iters_limit(m) is False + + +# ─── Counter accumulation ───────────────────────────────────────── + +def test_add_op_cost_accumulates(): + m = _fresh_metrics() + nat.vm_metrics_add_op_cost(m, 10) + nat.vm_metrics_add_op_cost(m, 20) + nat.vm_metrics_add_op_cost(m, 100) + assert nat.vm_metrics_op_cost(m) == 130 + + +def test_add_push_op_is_alias_of_add_op_cost(): + # `add_push_op` is the BCHN-TallyPushOp-shaped alias. Must be + # byte-for-byte equivalent to `add_op_cost`. + via_push = _fresh_metrics() + nat.vm_metrics_add_push_op(via_push, 5) + nat.vm_metrics_add_push_op(via_push, 17) + + via_op_cost = _fresh_metrics() + nat.vm_metrics_add_op_cost(via_op_cost, 5) + nat.vm_metrics_add_op_cost(via_op_cost, 17) + + assert nat.vm_metrics_op_cost(via_push) == nat.vm_metrics_op_cost(via_op_cost) == 22 + + +def test_add_sig_checks_accumulates(): + m = _fresh_metrics() + nat.vm_metrics_add_sig_checks(m, 1) + nat.vm_metrics_add_sig_checks(m, 1) + nat.vm_metrics_add_sig_checks(m, 15) # e.g. 15-key multisig + assert nat.vm_metrics_sig_checks(m) == 17 + + +# ─── Hash-iteration formula ─────────────────────────────────────── + +def test_add_hash_iterations_one_round_short_message(): + # iters = is_two_round + 1 + ((msg_len + 8) / 64) + # msg_len = 0, one-round: 0 + 1 + 0 = 1 + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 0, False) + assert nat.vm_metrics_hash_digest_iterations(m) == 1 + + +def test_add_hash_iterations_two_round_short_message(): + # msg_len = 0, two-round: 1 + 1 + 0 = 2 + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 0, True) + assert nat.vm_metrics_hash_digest_iterations(m) == 2 + + +def test_add_hash_iterations_one_round_one_block(): + # msg_len = 56, one-round: 0 + 1 + ((56 + 8) / 64) = 2 + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 56, False) + assert nat.vm_metrics_hash_digest_iterations(m) == 2 + + +def test_add_hash_iterations_accumulates_across_calls(): + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 0, False) # 1 + nat.vm_metrics_add_hash_iterations(m, 0, True) # 2 + nat.vm_metrics_add_hash_iterations(m, 56, False) # 2 + assert nat.vm_metrics_hash_digest_iterations(m) == 5 + + +# ─── composite_op_cost ──────────────────────────────────────────── + +def test_composite_op_cost_on_zero_metrics_is_zero(): + m = _fresh_metrics() + assert nat.vm_metrics_composite_op_cost_bool(m, False) == 0 + assert nat.vm_metrics_composite_op_cost_bool(m, True) == 0 + + +def test_composite_op_cost_sig_checks_contribution(): + # 3 sig checks → 3 * 26'000 = 78'000. Standard/non-standard bit + # is moot because hash_digest_iterations_ is 0. + m = _fresh_metrics() + nat.vm_metrics_add_sig_checks(m, 3) + assert nat.vm_metrics_composite_op_cost_bool(m, False) == 3 * SIG_CHECK_COST_FACTOR + assert nat.vm_metrics_composite_op_cost_bool(m, True) == 3 * SIG_CHECK_COST_FACTOR + + +def test_composite_op_cost_hash_iters_weighted_by_standard(): + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 0, False) # +1 iter + assert nat.vm_metrics_composite_op_cost_bool(m, False) == HASH_ITER_FACTOR_CONSENSUS # 64 + assert nat.vm_metrics_composite_op_cost_bool(m, True) == HASH_ITER_FACTOR_STANDARD # 192 + + +def test_composite_op_cost_composes_all_contributions(): + # 500 (op_cost) + 2 * 64 (two-round hash iters) + 1 * 26'000 (sig_checks) + # = 500 + 128 + 26'000 = 26'628 + m = _fresh_metrics() + nat.vm_metrics_add_op_cost(m, 500) + nat.vm_metrics_add_hash_iterations(m, 0, True) # +2 + nat.vm_metrics_add_sig_checks(m, 1) + assert nat.vm_metrics_composite_op_cost_bool(m, False) == ( + 500 + 2 * HASH_ITER_FACTOR_CONSENSUS + 1 * SIG_CHECK_COST_FACTOR + ) + + +def test_composite_op_cost_flags_routes_through_is_vm_limits_standard(): + # The flags-taking overload picks the 192x / 64x factor based on + # whether `bch_vm_limits_standard` is present in the bitmask. + m = _fresh_metrics() + nat.vm_metrics_add_hash_iterations(m, 0, False) + + assert ( + nat.vm_metrics_composite_op_cost_script_flags(m, SCRIPT_FLAGS_BCH_VM_LIMITS_STANDARD) + == nat.vm_metrics_composite_op_cost_bool(m, True) + ) + assert ( + nat.vm_metrics_composite_op_cost_script_flags(m, 0) + == nat.vm_metrics_composite_op_cost_bool(m, False) + ) + + +# ─── set_script_limits / set_native_script_limits ───────────────── + +def test_set_script_limits_activates_has_valid_script_limits(): + m = _fresh_metrics() + assert nat.vm_metrics_has_valid_script_limits(m) is False + + nat.vm_metrics_set_script_limits(m, 0, SCRIPT_SIG_SIZE) + assert nat.vm_metrics_has_valid_script_limits(m) is True + + +def test_set_native_script_limits_picks_hash_iters_ceiling_from_standard(): + # hash_iters_limit = ((n + 41) * factor) / 2, with factor = 1 for + # standard and factor = 7 for non-standard (consensus). At n=100: + # standard → 70 + # non-standard → 493 + # Pump the counter to 79 (msg_len=5000, one-round): crosses 70 + # but not 493 — proving `set_native_script_limits` persists the + # 'standard' bit into the stored limits. + consensus = _fresh_metrics() + nat.vm_metrics_set_native_script_limits(consensus, False, SCRIPT_SIG_SIZE) + + standard = _fresh_metrics() + nat.vm_metrics_set_native_script_limits(standard, True, SCRIPT_SIG_SIZE) + + nat.vm_metrics_add_hash_iterations(consensus, 5000, False) # +79 + nat.vm_metrics_add_hash_iterations(standard, 5000, False) # +79 + + assert nat.vm_metrics_is_over_hash_iters_limit(consensus) is False + assert nat.vm_metrics_is_over_hash_iters_limit(standard) is True + + +# ─── Limit crossings ────────────────────────────────────────────── + +def test_is_over_op_cost_limit_crosses_when_composite_exceeds_ceiling(): + # op_cost_limit at n=100 is 112'800. With no iters and no sig + # checks, composite_op_cost == op_cost_, so push op_cost right + # below the ceiling, then cross it. + m = _fresh_metrics() + nat.vm_metrics_set_native_script_limits(m, False, SCRIPT_SIG_SIZE) + nat.vm_metrics_add_op_cost(m, 112_800) + assert nat.vm_metrics_is_over_op_cost_limit_simple(m) is False + + nat.vm_metrics_add_op_cost(m, 1) + assert nat.vm_metrics_is_over_op_cost_limit_simple(m) is True + + +def test_is_over_hash_iters_limit_crosses_when_iters_exceed_ceiling(): + # hash_iters_limit at n=100 non-standard: 493. + # msg_len = 30'000, one-round: 0 + 1 + (30'008 / 64) = 469. + # Two calls → 938, crossing 493. + m = _fresh_metrics() + nat.vm_metrics_set_native_script_limits(m, False, SCRIPT_SIG_SIZE) + nat.vm_metrics_add_hash_iterations(m, 30_000, False) # +469 + assert nat.vm_metrics_is_over_hash_iters_limit(m) is False + nat.vm_metrics_add_hash_iterations(m, 30_000, False) # +469 → 938 + assert nat.vm_metrics_is_over_hash_iters_limit(m) is True + + +# ─── copy + borrowed view from program ──────────────────────────── + +def test_copy_is_independent_of_source(): + a = _fresh_metrics() + nat.vm_metrics_add_op_cost(a, 500) + + b = nat.vm_metrics_copy(a) + assert nat.vm_metrics_op_cost(b) == 500 + + # Mutating the copy must not bleed back into the source. + nat.vm_metrics_add_op_cost(b, 100) + assert nat.vm_metrics_op_cost(a) == 500 + assert nat.vm_metrics_op_cost(b) == 600 + + +def test_vm_program_get_metrics_returns_queryable_borrowed_view(): + # `vm_program_get_metrics` returns a borrowed capsule into the + # program's internal metrics field. It must be queryable with + # the same accessors as an owned metrics handle — the capsule + # wrapper keeps `program` alive for the lifetime of the view. + script = nat.chain_script_construct_from_data(TWO_PUSH_BYTES, False) + program = nat.vm_program_construct_from_script(script) + borrowed = nat.vm_program_get_metrics(program) + assert borrowed is not None + # Default program → all counters zero. + assert nat.vm_metrics_sig_checks(borrowed) == 0 + assert nat.vm_metrics_op_cost(borrowed) == 0 + assert nat.vm_metrics_hash_digest_iterations(borrowed) == 0