diff --git a/knowledge/catalog.json b/knowledge/catalog.json index 13b2ea42..1d30fe2a 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -934,6 +934,79 @@ "OP-003" ] }, + { + "id": "arithmetic/u4-count", + "name": "Checked fixed-symbol u4 occurrence count", + "class": "arithmetic/word", + "summary": "Range-checked u4 batch count for one public generation-time target nibble.", + "status": "active", + "evidence": "locally-reproduced", + "execution": "unclassified", + "as_of": "2026-09-17", + "knowledge_page": "knowledge/primitives/u4-count.md", + "implementation": "src/arithmetic/u4/count.rs", + "documentation": "src/arithmetic/u4/README.md", + "tests": [ + "arithmetic::u4::count::tests::counts_boundary_and_repeated_symbols", + "arithmetic::u4::count::tests::rejects_invalid_nibbles_and_generation_bounds", + "arithmetic::u4::count::tests::preserves_surrounding_main_and_alt_stack_items", + "arithmetic::u4::count::tests::counts_nonminimal_numeric_encodings_and_preserves_boundary_state", + "arithmetic::u4::count::tests::exact_stack_frontier_admits_max_preserved_and_rejects_one_more", + "arithmetic::u4::count::tests::preserved_state_stack_frontier_is_exact", + "primitive_metrics::u4_symbol_count_metrics_are_current" + ], + "references": [ + "bitcoin-script-locked", + "bitcoin-scriptexec-locked" + ], + "techniques": [ + "digit-arithmetic", + "constant-embedding", + "range-check" + ], + "security": "Every hostile nibble is range-checked before equality scans; the target is public generation-time data, the numeric count is bounded by the batch length, and no terminal predicate is supplied.", + "stack_contract": "Consumes preserved | nibble[0] ... nibble[n-1] and returns preserved | count(target), with the last input on top and count in 0..=n.", + "configurations": [ + { + "id": "checked-target-zero-batch16", + "label": "u4_nibbles_count(0, 16)", + "parameters": { + "nibble_count": 16, + "target": 0, + "input_check": true, + "data_items": 16, + "hint_items": 0 + }, + "includes": "fragment-only: 16 numeric range checks, 16 numeric target-equality tests, count accumulation, and input cleanup; witness_bytes and witness_bytes_max describe the canonical 16-item profile, not larger accepted nonminimal encodings; excludes input pushes, witness serialization, terminal predicate, unrelated live state, and transaction context", + "script_bytes": 266, + "witness_bytes": 33, + "witness_bytes_max": 33, + "max_stack_items": 19, + "executed_opcodes": null, + "validation_weight": null, + "setup_script_bytes": 0, + "per_use_script_bytes": 266, + "metric_keys": [ + "u4_symbol_count_16", + "u4_symbol_count_16_witness", + "u4_symbol_count_16_stack", + "u4_symbol_count_16_opcodes" + ], + "static_non_push_opcodes": 186 + } + ], + "limitations": [ + "The equality scan is linear in batch length for one target", + "Counts one public target rather than returning a complete histogram", + "Static non-push opcode count is not a dynamic execution measurement", + "No Bitcoin Core consensus or relay-policy validation" + ], + "open_problems": [ + "OP-001", + "OP-002", + "OP-003" + ] + }, { "id": "arithmetic/u4-trichotomy", "name": "Checked u4 embedded-threshold trichotomy", diff --git a/knowledge/comparisons/arithmetic.md b/knowledge/comparisons/arithmetic.md index cba51cf9..b3c2a2fc 100644 --- a/knowledge/comparisons/arithmetic.md +++ b/knowledge/comparisons/arithmetic.md @@ -54,6 +54,7 @@ the current byte-oriented and decode/re-encode configurations below. | 32 checked nibbles to nonzero-power-of-two bits | `u4_nibbles_to_power_of_two(32)` | 440 | 50-item peak; one predicate bit per input | | 32 checked nibbles to modulo-three residues | `u4_nibbles_to_mod3(32)` | 440 | 50-item peak; one residue per input | | 32 checked nibbles to parity bits | `u4_nibbles_to_parity(32)` | 440 | 50-item peak; one output bit per input | +| Fixed-symbol u4 occurrence count | `u4_nibbles_count(0, 16)` | 266 | 19-item peak; one count output; target embedded; numeric equality | | 32 checked nibbles transition count | `u4_nibbles_transition_count(32)` | 588 | 35-item peak; 391 static non-push opcodes; one compact count; no table | | 32 checked nibbles to adjacent-equality bits | `u4_adjacent_equal_mask(32)` | 558 | 64-item peak; 31 output bits; 361 static non-push opcodes; no lookup table or hints | | Checked odd u4 inverse | `u4_odd_inverse_mod16` | 9 | 20-item peak; 16-item table; one data item; zero hints | diff --git a/knowledge/primitives/index.md b/knowledge/primitives/index.md index fdf211af..861291f3 100644 --- a/knowledge/primitives/index.md +++ b/knowledge/primitives/index.md @@ -21,6 +21,7 @@ the source. Read a page together with its comparison page and evidence record. - [Checked u4 nonzero-power-of-two predicate](u4-power-of-two.md) - [Checked u4 modulo-three projection](u4-mod3.md) - [Checked u4 parity projection](u4-parity.md) +- [Checked fixed-symbol u4 occurrence count](u4-count.md) - [Checked u4 transition count](u4-transition-count.md) - [Checked u4 adjacent-equality mask](u4-adjacent-equality.md) - [Checked odd u4 inverse modulo 16](u4-odd-inverse-mod16.md) diff --git a/knowledge/primitives/u4-count.md b/knowledge/primitives/u4-count.md new file mode 100644 index 00000000..bfd99c1f --- /dev/null +++ b/knowledge/primitives/u4-count.md @@ -0,0 +1,41 @@ +# Checked fixed-symbol u4 occurrence count + +`arithmetic::u4::count::u4_nibbles_count` consumes a batch of checked u4 +nibbles and returns the number of occurrences of one generation-time target +nibble. The result is a numeric ScriptNum in `0..=n`; the target is public +locking-script data. + +## Boundary and comparison + +Every hostile input is range-checked before the numeric equality scans. The +target is validated at script-generation time and must be in `0..=15`. The +operation accepts nonminimal numeric encodings when the execution profile +permits them, preserves unrelated lower main-stack and alt-stack state, and +consumes only the input batch. A standalone batch is limited to 997 items; +composition must satisfy `n + 3 + preserved_items <= 1000`. Strict local frontier +tests measure a 1,000-item peak at the maximum for batches 1, 16, 500, and +997 and with preserved main/alt items, and reject one more live item with +`StackSize`. + +The representative configuration counts target `0` across 16 canonical +one-byte witness nibbles. It includes all range checks, numeric equality tests, +Boolean-to-count additions, and input cleanup; it excludes input pushes, the +terminal predicate, unrelated live state, and transaction context. The 33-byte +witness figure is the canonical 16-item profile, not a maximum over accepted +nonminimal encodings. No hints are required. + +Evidence is `locally-reproduced`; execution is `unclassified`. The strict local +executor enforces the combined 1,000-item stack limit. No Bitcoin Core +consensus or relay-policy validation is claimed. + +This is a fixed-alphabet counting primitive, not a packed histogram or a +duplicate detector by itself. Callers can compare the count to zero, one, or a +protocol-specific bound. + +## Reproduction + +```sh +cargo test --locked arithmetic::u4::count::tests --lib +cargo test --locked --test primitive_metrics u4_symbol_count_metrics_are_current -- --exact +python3 tools/kb.py validate +``` diff --git a/src/arithmetic/u4/README.md b/src/arithmetic/u4/README.md index b8bbb4f2..64e992ec 100644 --- a/src/arithmetic/u4/README.md +++ b/src/arithmetic/u4/README.md @@ -12,6 +12,9 @@ these operations, but this module contains no hash-specific round logic. `1..=3` bit counts unless their function documents otherwise. - `parity::u4_nibbles_to_parity(nibble_count)` takes a checked batch size in `1..=982`. +- `count::u4_nibbles_count(value, nibble_count)` takes a checked batch size in + `1..=997` without preserved stack items; composition must satisfy + `nibble_count + 3 + preserved_items <= 1000`. - `cyclic_equality::u4_nibbles_to_cyclic_equality(nibble_count, offset)` takes a checked batch size in `1..=499` and returns a wrapped equality bit per input nibble. @@ -146,6 +149,7 @@ each input with the same output-restoration boundary. | `lexicographic_le(128)` | 7500 bytes | 259 items | 4354 | | `lexicographic_le_constant(128)` | 7628 bytes | 259 items | 4354 | | Checked parity batch, 32 nibbles | 440 bytes | 50 items | 328 | +| Fixed-symbol count, 16 nibbles | 266 bytes | 19 items | 186 | | Checked cyclic equality batch, 32 nibbles, offset 7 | 569 bytes | 65 items | 368 | | Checked transition-count batch, 32 nibbles | 588 bytes | 35 items | 391 | | Checked adjacent-equality batch, 32 nibbles | 558 bytes | 64 items | 361 | @@ -204,6 +208,8 @@ generated table setup is 1665 serialized witness bytes for the representative checked total-popcount batch. +The fixed-symbol count fixture uses 33 serialized witness bytes for 16 canonical data items and returns one numeric count. Nonminimal numeric encodings may be accepted under a permissive execution profile and can serialize larger. + 65 serialized witness bytes for the representative parity batch. 65 serialized witness bytes for the representative MSB batch. diff --git a/src/arithmetic/u4/count.rs b/src/arithmetic/u4/count.rs new file mode 100644 index 00000000..81d85bff --- /dev/null +++ b/src/arithmetic/u4/count.rs @@ -0,0 +1,284 @@ +//! Fixed-symbol occurrence counts for checked u4 limbs. + +use super::stack::u4_drop; +use crate::support::script::*; + +/// Largest standalone batch; callers must satisfy `batch + 3 + preserved <= 1000`. +pub const U4_COUNT_MAX_BATCH: u32 = 1_000 - 3; + +/// Count occurrences of one generation-time nibble in a checked u4 batch. +/// +/// Before: `preserved | nibble[0] | ... | nibble[n-1]`, with the last nibble +/// on top. After: `preserved | count`, where `count` is in `0..=n`. +pub fn u4_nibbles_count(value: u8, nibble_count: u32) -> Script { + assert!(value < 16, "count target must be a u4 nibble"); + assert!(nibble_count > 0, "nibble batch must not be empty"); + assert!( + nibble_count <= U4_COUNT_MAX_BATCH, + "nibble-count batch exceeds Bitcoin Script's stack limit" + ); + + script! { + for index in 0..nibble_count { + { index } OP_PICK + OP_DUP OP_0 OP_GREATERTHANOREQUAL OP_VERIFY + OP_DUP OP_16 OP_LESSTHAN OP_VERIFY + OP_DROP + } + + 0 + for index in 0..nibble_count { + { value } + { index + 2 } OP_PICK + OP_NUMEQUAL + OP_ADD + } + + OP_TOALTSTACK + { u4_drop(nibble_count) } + OP_FROMALTSTACK + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + arithmetic::u4::stack::u4_hex_to_nibbles, + support::{ + execution::{ + execute_raw_script_with_inputs_strict, execute_script, + execute_script_buf_with_options, + }, + script::{script, Script, ScriptCompilation, MAX_OPTIMIZER_INPUT_BYTES}, + }, + }; + use bitcoin_scriptexec::{ExecError, Options}; + + fn compile_boundary(body: Script) -> Vec { + script! { + { body } + for _ in 0..=MAX_OPTIMIZER_INPUT_BYTES { OP_NOP } + } + .compile_with_policy() + .to_bytes() + } + + /// Runs `alt_items` alt-stack items and `main_items` lower main-stack items + /// around a batch of zero nibbles under the strict 1,000-item limit, then + /// checks the count and every preserved value. + fn run_preserved_frontier( + main_items: u32, + alt_items: u32, + nibble_count: u32, + ) -> crate::support::execution::ExecuteInfo { + let body = script! { + for index in 0..alt_items { + { 70 + index as i64 } OP_TOALTSTACK + } + { u4_nibbles_count(0, nibble_count) } + { nibble_count as i64 } OP_EQUALVERIFY + for index in (0..main_items).rev() { + { 90 + index as i64 } OP_EQUALVERIFY + } + for index in (0..alt_items).rev() { + OP_FROMALTSTACK { 70 + index as i64 } OP_EQUALVERIFY + } + OP_TRUE + }; + execute_raw_script_with_inputs_strict( + compile_boundary(body), + (0..main_items) + .map(|index| vec![90 + index as u8]) + .chain(std::iter::repeat_n(Vec::new(), nibble_count as usize)) + .collect(), + ) + } + + #[test] + fn exact_stack_frontier_admits_max_preserved_and_rejects_one_more() { + for nibble_count in [1, 16, 500, U4_COUNT_MAX_BATCH] { + let max_preserved = (U4_COUNT_MAX_BATCH - nibble_count) as usize; + for alt_items in [0usize, 1] { + let script = compile_boundary(script! { + for _ in 0..alt_items { 77 OP_TOALTSTACK } + { u4_nibbles_count(4, nibble_count) } + for _ in 0..alt_items { OP_FROMALTSTACK } + }); + for (preserved, fits) in [(max_preserved, true), (max_preserved + 1, false)] { + let Some(main_items) = preserved.checked_sub(alt_items) else { + continue; + }; + let witness = std::iter::repeat_n(vec![99u8], main_items) + .chain(std::iter::repeat_n(vec![4u8], nibble_count as usize)) + .collect(); + let result = execute_raw_script_with_inputs_strict(script.clone(), witness); + let case = format!( + "batch {nibble_count}, main {main_items}, alt {alt_items}, preserved {preserved}" + ); + if fits { + assert!(result.error.is_none(), "{case} failed: {result}"); + assert_eq!(result.stats.max_nb_stack_items, 1_000, "{case}"); + assert_eq!(result.final_stack.len(), preserved + 1, "{case}"); + } else { + assert_eq!(result.error, Some(ExecError::StackSize), "{case}"); + } + } + } + } + } + + #[test] + fn preserved_state_stack_frontier_is_exact() { + for (main_items, alt_items) in [(1, 0), (0, 1), (1, 1), (3, 2)] { + let preserved = main_items + alt_items; + let maximum = U4_COUNT_MAX_BATCH - preserved; + let accepted = run_preserved_frontier(main_items, alt_items, maximum); + assert!( + accepted.success, + "preserved frontier main={main_items} alt={alt_items} n={maximum} failed: {accepted}" + ); + assert_eq!(accepted.stats.max_nb_stack_items, 1_000); + + let rejected = run_preserved_frontier(main_items, alt_items, maximum + 1); + assert_eq!( + rejected.error, + Some(ExecError::StackSize), + "preserved frontier main={main_items} alt={alt_items} n={} was not rejected: {rejected}", + maximum + 1 + ); + } + } + + #[test] + fn counts_boundary_and_repeated_symbols() { + for (input, target, expected) in [ + ("0123456789abcdef", 0, 1), + ("001122", 1, 2), + ("ffff", 15, 4), + ] { + let result = execute_script(script! { + { u4_hex_to_nibbles(input) } + { u4_nibbles_count(target, input.len() as u32) } + { expected } OP_EQUAL + }); + assert!(result.success, "symbol count failed for {input}: {result}"); + } + } + + #[test] + fn rejects_invalid_nibbles_and_generation_bounds() { + for invalid in [-1, 16] { + let result = execute_script(script! { + { invalid } + { u4_nibbles_count(0, 1) } + OP_DROP + OP_TRUE + }); + assert_eq!( + result.error, + Some(ExecError::Verify), + "accepted invalid nibble {invalid}: {result}" + ); + } + assert!(std::panic::catch_unwind(|| u4_nibbles_count(16, 1)).is_err()); + assert!(std::panic::catch_unwind(|| u4_nibbles_count(0, 0)).is_err()); + assert!( + std::panic::catch_unwind(|| { u4_nibbles_count(0, U4_COUNT_MAX_BATCH + 1) }).is_err() + ); + + let result = execute_raw_script_with_inputs_strict( + script! { + { u4_nibbles_count(0, U4_COUNT_MAX_BATCH) } + { U4_COUNT_MAX_BATCH as i64 } OP_EQUALVERIFY + OP_TRUE + } + .compile_with_policy() + .to_bytes(), + vec![Vec::new(); U4_COUNT_MAX_BATCH as usize], + ); + assert!(result.success, "997-item count failed: {result}"); + assert_eq!(result.stats.max_nb_stack_items, 1_000); + + for count in [U4_COUNT_MAX_BATCH + 1] { + assert!(std::panic::catch_unwind(|| u4_nibbles_count(0, count)).is_err()); + } + } + + #[test] + fn counts_nonminimal_numeric_encodings_and_preserves_boundary_state() { + let options = Options { + require_minimal: false, + enforce_stack_limit: true, + ..Default::default() + }; + let checked_script = script! { + { u4_nibbles_count(1, 3) } + 2 OP_EQUALVERIFY + OP_TRUE + } + .compile_with_policy() + .to_bytes(); + let result = execute_script_buf_with_options( + bitcoin::ScriptBuf::from_bytes(checked_script), + vec![vec![1, 0], vec![1], vec![0x80]], + options.clone(), + ) + .expect("nonminimal count execution"); + assert!(result.success, "nonminimal numeric count failed: {result}"); + + for (preserved, on_altstack) in [(996, false), (996, true)] { + let script = if on_altstack { + script! { + 77 OP_TOALTSTACK + for _ in 0..preserved { 0 } + { u4_nibbles_count(0, preserved) } + { preserved as i64 } OP_EQUALVERIFY + OP_FROMALTSTACK 77 OP_EQUALVERIFY + OP_TRUE + } + } else { + script! { + 77 + for _ in 0..preserved { 0 } + { u4_nibbles_count(0, preserved) } + { preserved as i64 } OP_EQUALVERIFY + 77 OP_EQUALVERIFY + OP_TRUE + } + }; + let result = execute_script(script); + assert!( + result.success, + "996-item count changed preserved state (alt={on_altstack}): {result}" + ); + } + + let result = execute_script(script! { + 77 + for _ in 0..U4_COUNT_MAX_BATCH { 0 } + { u4_nibbles_count(0, U4_COUNT_MAX_BATCH) } + { U4_COUNT_MAX_BATCH as i64 } OP_EQUALVERIFY + OP_TRUE + }); + assert_eq!(result.error, Some(ExecError::StackSize)); + } + + #[test] + fn preserves_surrounding_main_and_alt_stack_items() { + let result = execute_script(script! { + 77 OP_TOALTSTACK + 99 + 0 1 0 + { u4_nibbles_count(0, 3) } + 2 OP_EQUALVERIFY + 99 OP_EQUALVERIFY + OP_FROMALTSTACK 77 OP_EQUALVERIFY + OP_TRUE + }); + assert!( + result.success, + "symbol count changed surrounding state: {result}" + ); + } +} diff --git a/src/arithmetic/u4/mod.rs b/src/arithmetic/u4/mod.rs index b1105ffb..385c6f03 100644 --- a/src/arithmetic/u4/mod.rs +++ b/src/arithmetic/u4/mod.rs @@ -8,6 +8,7 @@ pub mod bits; pub mod centered; pub mod clamp; pub mod compare; +pub mod count; pub mod cyclic_equality; pub mod equality; pub mod gray; diff --git a/tests/primitive_metrics.rs b/tests/primitive_metrics.rs index 44f88df0..ccb4deb7 100644 --- a/tests/primitive_metrics.rs +++ b/tests/primitive_metrics.rs @@ -6834,6 +6834,49 @@ fn u4_odd_inverse_mod16_metrics_are_current() { ]); } +/// This isolated fixture measures a fixed-symbol u4 occurrence count. +#[test] +fn u4_symbol_count_metrics_are_current() { + const NIBBLE_COUNT: u32 = 16; + let fragment = u4::count::u4_nibbles_count(0, NIBBLE_COUNT); + let witness = vec![scriptnum(15); NIBBLE_COUNT as usize]; + let peak = max_stack_items_strict( + script! { + { fragment.clone() } + OP_DROP + OP_TRUE + }, + witness.clone(), + ); + check_readme_metrics(vec![ + Metric { + readme: "src/arithmetic/u4/README.md", + key: "u4_symbol_count_16", + value: script_len(fragment.clone()), + }, + Metric { + readme: "src/arithmetic/u4/README.md", + key: "u4_symbol_count_16_witness", + value: witness_size(&witness), + }, + Metric { + readme: "src/arithmetic/u4/README.md", + key: "u4_symbol_count_16_witness_items", + value: witness.len(), + }, + Metric { + readme: "src/arithmetic/u4/README.md", + key: "u4_symbol_count_16_stack", + value: peak, + }, + Metric { + readme: "src/arithmetic/u4/README.md", + key: "u4_symbol_count_16_opcodes", + value: static_non_push_opcodes(fragment), + }, + ]); +} + /// This isolated fixture measures only the checked u32 population count. #[test] fn u32_popcount_metrics_are_current() {