diff --git a/knowledge/catalog.json b/knowledge/catalog.json index a83aa9ba..15cf234a 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -536,6 +536,76 @@ "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", + "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 target equality tests, count accumulation, and input cleanup; 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/u32", "name": "u32 byte-word arithmetic", diff --git a/knowledge/comparisons/arithmetic.md b/knowledge/comparisons/arithmetic.md index ac978360..ba7500dd 100644 --- a/knowledge/comparisons/arithmetic.md +++ b/knowledge/comparisons/arithmetic.md @@ -22,6 +22,7 @@ differ. Follow each catalog configuration before comparing numbers. | Compressed total-domain u32 addition | two-item compressed wire | 1,016 | 11-byte representative witness; byte baseline is 78 bytes and 20-byte witness | | Fixed-width u4 ordering | `lexicographic_le(128)` | 7,500 | 256 data items; 4,354 non-push opcodes | | 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 | | u32 population count | `u32_popcount()` | 455 | 262-item peak; 256-item byte table | | 32 checked nibbles to LSB bits | `u4_nibbles_to_lsb(32)` | 440 | 50-item peak; one output bit per input | | 16 checked nibbles to four bit planes | u4 table plus stack transpose | 776 | 125-item peak; 33-byte witness | diff --git a/knowledge/primitives/index.md b/knowledge/primitives/index.md index dc6e0c8a..4de00c0c 100644 --- a/knowledge/primitives/index.md +++ b/knowledge/primitives/index.md @@ -12,6 +12,7 @@ the source. Read a page together with its comparison page and evidence record. - [Signed radix-32 window decoder](signed-radix32-decoder.md) - [Fixed-width u4 lexicographic comparison](u4-lexicographic.md) - [Checked u4 parity projection](u4-parity.md) +- [Checked fixed-symbol u4 occurrence count](u4-count.md) - [Checked u4 least-significant-bit projection](u4-lsb.md) - [u32 word arithmetic](u32.md) - [Compressed total-domain u32 addition](u32-compressed-add.md) diff --git a/knowledge/primitives/u4-count.md b/knowledge/primitives/u4-count.md new file mode 100644 index 00000000..6320faca --- /dev/null +++ b/knowledge/primitives/u4-count.md @@ -0,0 +1,35 @@ +# 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 equality scans. The target is +validated at script-generation time and must be in `0..=15`. The operation +preserves unrelated lower main-stack and alt-stack state and consumes only the +input batch. + +The representative configuration counts target `0` across 16 canonical +one-byte witness nibbles. It includes all range checks, equality tests, +Boolean-to-count additions, and input cleanup; it excludes input pushes, the +terminal predicate, unrelated live state, and transaction context. 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 69277d7c..f84c78e1 100644 --- a/src/arithmetic/u4/README.md +++ b/src/arithmetic/u4/README.md @@ -46,10 +46,13 @@ each input with the same output-restoration boundary. | `verify_canonical_nibble()` | 10 bytes | 4 items | not recorded | | `lexicographic_le(128)` | 7500 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 LSB batch, 32 nibbles | 440 bytes | 50 items | 328 | | Checked 16-nibble bit-plane transpose | 776 bytes | 125 items | 573 | | Checked 32-nibble bit reversal | 344 bytes | 51 items | 232 | +The fixed-symbol count fixture uses 33 serialized witness bytes for 16 data items and returns one numeric count. + 65 serialized witness bytes for the representative parity batch. 65 serialized witness bytes for the representative LSB batch. diff --git a/src/arithmetic/u4/count.rs b/src/arithmetic/u4/count.rs new file mode 100644 index 00000000..a21d69f5 --- /dev/null +++ b/src/arithmetic/u4/count.rs @@ -0,0 +1,101 @@ +//! Fixed-symbol occurrence counts for checked u4 limbs. + +use super::stack::u4_drop; +use crate::support::script::*; + +/// Largest batch that fits the 1,000-item stack limit without unrelated state. +pub const U4_COUNT_MAX_BATCH: u32 = 1_000 - 2; + +/// 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_EQUAL + 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_script, script::script}, + }; + + #[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_TRUE + }); + assert!(!result.success, "accepted invalid nibble {invalid}"); + } + 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() + ); + } + + #[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 5507dfa9..ed8047da 100644 --- a/src/arithmetic/u4/mod.rs +++ b/src/arithmetic/u4/mod.rs @@ -3,6 +3,7 @@ pub mod bit_planes; pub mod bit_reverse; pub mod bits; pub mod compare; +pub mod count; pub mod logic; pub mod lsb; pub mod parity; diff --git a/tests/primitive_metrics.rs b/tests/primitive_metrics.rs index 9f61d3f0..16801c85 100644 --- a/tests/primitive_metrics.rs +++ b/tests/primitive_metrics.rs @@ -4912,6 +4912,44 @@ fn u4_parity_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_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() {