From 01208f4143d6e24b29839590ab263b5e3b5fd430 Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Fri, 14 Aug 2026 03:29:06 +0300 Subject: [PATCH] add some testing scripts --- hash_testing/new_testing/generate_input.py | 264 +++++++++++ .../hash_funcs/jenkinsOAAT/bin_hash | Bin 0 -> 16448 bytes .../hash_funcs/jenkinsOAAT/bin_hash.c | 109 +++++ .../new_testing/hash_funcs/jenkinsOAAT/hash.c | 13 + .../new_testing/plot_probability_map.py | 445 ++++++++++++++++++ hash_testing/new_testing/probability_map.py | 364 ++++++++++++++ .../probability_maps/jenkinsOAAT.csv | 5 + .../probability_maps/jenkinsOAAT_bits.png | Bin 0 -> 38919 bytes .../jenkinsOAAT_deviation.png | Bin 0 -> 25412 bytes .../new_testing/test_plot_probability_map.py | 91 ++++ .../new_testing/test_probability_map.py | 140 ++++++ 11 files changed, 1431 insertions(+) create mode 100755 hash_testing/new_testing/generate_input.py create mode 100755 hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash create mode 100644 hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash.c create mode 100644 hash_testing/new_testing/hash_funcs/jenkinsOAAT/hash.c create mode 100755 hash_testing/new_testing/plot_probability_map.py create mode 100755 hash_testing/new_testing/probability_map.py create mode 100644 hash_testing/new_testing/probability_maps/jenkinsOAAT.csv create mode 100644 hash_testing/new_testing/probability_maps/jenkinsOAAT_bits.png create mode 100644 hash_testing/new_testing/probability_maps/jenkinsOAAT_deviation.png create mode 100644 hash_testing/new_testing/test_plot_probability_map.py create mode 100644 hash_testing/new_testing/test_probability_map.py diff --git a/hash_testing/new_testing/generate_input.py b/hash_testing/new_testing/generate_input.py new file mode 100755 index 0000000..756e601 --- /dev/null +++ b/hash_testing/new_testing/generate_input.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Generate unique words similar to a given word by applying random mutations.""" + +from __future__ import annotations + +import argparse +import random +import string +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +DEFAULT_ALPHABET = string.ascii_letters + string.digits + "_" + +OPERATION_ALIASES = { + "1": "replace", + "replace": "replace", + "2": "delete", + "delete": "delete", + "3": "add", + "add": "add", + "4": "swap", + "swap": "swap", + "5": "case", + "case": "case", + "6": "first", + "first": "first", + "7": "last", + "last": "last", +} + +OPERATION_HELP = """операция изменения: + 1, replace заменить случайный символ + 2, delete удалить случайный символ + 3, add добавить символ в случайную позицию + 4, swap переставить два соседних символа + 5, case сменить регистр случайного символа + 6, first изменить первый символ + 7, last изменить последний символ""" + + +class MutationError(ValueError): + """Raised when the selected mutation cannot be applied.""" + + +def different_character(current: str, alphabet: str, rng: random.Random) -> str: + choices = [character for character in alphabet if character != current] + if not choices: + raise MutationError("алфавит не содержит символа, отличного от заменяемого") + return rng.choice(choices) + + +def replace_character(word: str, alphabet: str, rng: random.Random) -> str: + if not word: + raise MutationError("нельзя заменить символ в пустом слове") + index = rng.randrange(len(word)) + replacement = different_character(word[index], alphabet, rng) + return word[:index] + replacement + word[index + 1 :] + + +def delete_character(word: str, _alphabet: str, rng: random.Random) -> str: + if not word: + raise MutationError("нельзя удалить символ из пустого слова") + index = rng.randrange(len(word)) + return word[:index] + word[index + 1 :] + + +def add_character(word: str, alphabet: str, rng: random.Random) -> str: + index = rng.randrange(len(word) + 1) + return word[:index] + rng.choice(alphabet) + word[index:] + + +def swap_adjacent(word: str, _alphabet: str, rng: random.Random) -> str: + indexes = [ + index for index in range(len(word) - 1) if word[index] != word[index + 1] + ] + if not indexes: + raise MutationError( + "для перестановки нужны хотя бы два соседних различных символа" + ) + index = rng.choice(indexes) + return word[:index] + word[index + 1] + word[index] + word[index + 2 :] + + +def change_case(word: str, _alphabet: str, rng: random.Random) -> str: + indexes = [] + replacements: dict[int, str] = {} + for index, character in enumerate(word): + swapped = character.swapcase() + if swapped != character and len(swapped) == 1: + indexes.append(index) + replacements[index] = swapped + if not indexes: + raise MutationError("в слове нет символов, у которых можно сменить регистр") + index = rng.choice(indexes) + return word[:index] + replacements[index] + word[index + 1 :] + + +def change_first(word: str, alphabet: str, rng: random.Random) -> str: + if not word: + raise MutationError("нельзя изменить первый символ пустого слова") + replacement = different_character(word[0], alphabet, rng) + return replacement + word[1:] + + +def change_last(word: str, alphabet: str, rng: random.Random) -> str: + if not word: + raise MutationError("нельзя изменить последний символ пустого слова") + replacement = different_character(word[-1], alphabet, rng) + return word[:-1] + replacement + + +Mutation = Callable[[str, str, random.Random], str] +MUTATIONS: dict[str, Mutation] = { + "replace": replace_character, + "delete": delete_character, + "add": add_character, + "swap": swap_adjacent, + "case": change_case, + "first": change_first, + "last": change_last, +} + + +def parse_operation(value: str) -> str: + try: + return OPERATION_ALIASES[value.lower()] + except KeyError as error: + valid = ", ".join(OPERATION_ALIASES) + raise argparse.ArgumentTypeError( + f"неизвестная операция {value!r}; допустимы: {valid}" + ) from error + + +def generate_words( + source: str, + count: int, + operation: str, + operation_count: int, + alphabet: str = DEFAULT_ALPHABET, + seed: int | None = None, + max_attempts: int | None = None, +) -> list[str]: + """Generate up to ``count`` unique mutations made by exactly N operations.""" + if count < 1: + raise ValueError("количество выходных слов должно быть положительным") + if operation_count < 1: + raise ValueError("количество операций должно быть положительным") + if not alphabet: + raise ValueError("алфавит не должен быть пустым") + + mutation = MUTATIONS[operation] + rng = random.Random(seed) + attempt_limit = max_attempts or max(10_000, count * 1_000) + words: list[str] = [] + seen: set[str] = set() + + for _attempt in range(attempt_limit): + candidate = source + try: + for _ in range(operation_count): + candidate = mutation(candidate, alphabet, rng) + except MutationError: + continue + + if candidate != source and candidate not in seen: + seen.add(candidate) + words.append(candidate) + if len(words) == count: + return words + + return words + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Создаёт список уникальных слов, похожих на исходное. " + "Каждое слово получается независимо от исходного ровно заданным " + "числом случайных операций." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + f"{OPERATION_HELP}\n\n" + "Пример:\n" + " python3 nexus.py example -o swap -n 20 -k 2 --seed 42" + ), + ) + parser.add_argument("word", help="исходное слово") + parser.add_argument( + "-o", "--operation", required=True, type=parse_operation, help=OPERATION_HELP + ) + parser.add_argument( + "-n", + "--count", + type=int, + default=10, + help="верхняя граница числа уникальных выходных слов (по умолчанию: 10)", + ) + parser.add_argument( + "-k", + "--operations", + type=int, + default=1, + help="число операций над каждым словом (по умолчанию: 1)", + ) + parser.add_argument( + "--alphabet", + default=DEFAULT_ALPHABET, + help=("символы для добавления и замены " f"(по умолчанию: {DEFAULT_ALPHABET})"), + ) + parser.add_argument( + "--seed", type=int, help="seed генератора для воспроизводимого результата" + ) + parser.add_argument( + "--max-attempts", + type=int, + help="предельное число попыток собрать уникальные слова", + ) + parser.add_argument( + "--output", + type=Path, + help="записать слова в файл вместо стандартного вывода", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.max_attempts is not None and args.max_attempts < 1: + parser.error("--max-attempts должен быть положительным") + + try: + words = generate_words( + source=args.word, + count=args.count, + operation=args.operation, + operation_count=args.operations, + alphabet=args.alphabet, + seed=args.seed, + max_attempts=args.max_attempts, + ) + except (MutationError, ValueError) as error: + parser.error(str(error)) + + if len(words) < args.count: + print( + f"warning: operation={args.operation}: generated {len(words)} " + f"of at most {args.count} unique words", + file=sys.stderr, + ) + + output = "".join(f"{word}\n" for word in words) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output, encoding="utf-8") + else: + sys.stdout.write(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash b/hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash new file mode 100755 index 0000000000000000000000000000000000000000..81868a8a50cd33ae87dc9ca1afab18f98a5d37a0 GIT binary patch literal 16448 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 I0|zVq4%v;L&;S4c literal 0 HcmV?d00001 diff --git a/hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash.c b/hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash.c new file mode 100644 index 0000000..2856005 --- /dev/null +++ b/hash_testing/new_testing/hash_funcs/jenkinsOAAT/bin_hash.c @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include + +#define JOAAT_SEED UINT32_C(0x9e3779b9) + +static uint32_t jenkins_oaat(const unsigned char *data, size_t length) +{ + uint32_t hash = JOAAT_SEED; + + for (size_t index = 0; index < length; ++index) { + hash += data[index]; + hash += hash << 10; + hash ^= hash >> 6; + } + + hash += hash << 3; + hash ^= hash >> 11; + hash += hash << 15; + return hash; +} + +static int is_ascii_trailing_space(unsigned char character) +{ + return character == ' ' || character == '\t' || character == '\n' || + character == '\r' || character == '\v' || character == '\f'; +} + +static int read_stdin(unsigned char **data, size_t *length) +{ + size_t capacity = 256; + unsigned char *buffer = malloc(capacity); + + if (buffer == NULL) { + return -1; + } + + *length = 0; + for (;;) { + size_t available = capacity - *length; + size_t bytes_read = fread(buffer + *length, 1, available, stdin); + *length += bytes_read; + + if (bytes_read < available) { + if (ferror(stdin)) { + free(buffer); + return -1; + } + break; + } + + if (capacity > SIZE_MAX / 2) { + free(buffer); + return -1; + } + capacity *= 2; + + unsigned char *larger_buffer = realloc(buffer, capacity); + if (larger_buffer == NULL) { + free(buffer); + return -1; + } + buffer = larger_buffer; + } + + *data = buffer; + return 0; +} + +int main(int argc, char **argv) +{ + const unsigned char *word; + unsigned char *stdin_buffer = NULL; + size_t length; + + if (argc > 2) { + fprintf(stderr, "usage: %s [ASCII_WORD]\n", argv[0]); + return EXIT_FAILURE; + } + + if (argc == 2) { + word = (const unsigned char *)argv[1]; + length = strlen(argv[1]); + } else { + if (read_stdin(&stdin_buffer, &length) != 0) { + fprintf(stderr, "failed to read input\n"); + return EXIT_FAILURE; + } + word = stdin_buffer; + } + + while (length > 0 && is_ascii_trailing_space(word[length - 1])) { + --length; + } + + for (size_t index = 0; index < length; ++index) { + if (word[index] > 0x7f) { + fprintf(stderr, "input must contain ASCII characters only\n"); + free(stdin_buffer); + return EXIT_FAILURE; + } + } + + printf("%08" PRIx32 "\n", jenkins_oaat(word, length)); + free(stdin_buffer); + return EXIT_SUCCESS; +} \ No newline at end of file diff --git a/hash_testing/new_testing/hash_funcs/jenkinsOAAT/hash.c b/hash_testing/new_testing/hash_funcs/jenkinsOAAT/hash.c new file mode 100644 index 0000000..e0bbbd5 --- /dev/null +++ b/hash_testing/new_testing/hash_funcs/jenkinsOAAT/hash.c @@ -0,0 +1,13 @@ +unsigned int hash(const char* str) { + unsigned int hash = 0x9e3779b9; + const unsigned char* p = (const unsigned char*)str; + while (*p) { + hash += *p++; + hash += (hash << 10); + hash ^= (hash >> 6); + } + hash += (hash << 3); + hash ^= (hash >> 11); + hash += (hash << 15); + return hash; +} diff --git a/hash_testing/new_testing/plot_probability_map.py b/hash_testing/new_testing/plot_probability_map.py new file mode 100755 index 0000000..62708df --- /dev/null +++ b/hash_testing/new_testing/plot_probability_map.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Render avalanche bit-probability CSV files as two PNG bar charts.""" + +from __future__ import annotations + +import argparse +import csv +import math +from dataclasses import dataclass +from pathlib import Path +from statistics import fmean +from typing import Sequence + +from PIL import Image, ImageDraw, ImageFont + +BACKGROUND = "#f7f8fa" +PANEL = "#ffffff" +GRID = "#d9dee7" +TEXT = "#172033" +MUTED = "#637083" +REFERENCE = "#d24b4b" +COLORS = ( + "#377eb8", + "#4daf4a", + "#984ea3", + "#ff7f00", + "#e41a1c", + "#00a6a6", + "#a65628", +) + + +@dataclass(frozen=True) +class ProbabilityRow: + operation: str + pairs: int + probabilities: list[float] + + +@dataclass(frozen=True) +class ChartScale: + minimum: float + maximum: float + ticks: tuple[float, ...] + + +def nice_step(value: float) -> float: + exponent = math.floor(math.log10(value)) + fraction = value / 10**exponent + nice_fraction = min((1.0, 2.0, 2.5, 5.0, 10.0), key=lambda item: abs(item - fraction)) + return nice_fraction * 10**exponent + + +def make_scale( + values: Sequence[float], + *, + hard_limits: tuple[float, float], + reference: float | None = None, +) -> ChartScale: + """Build a padded shared scale constrained to hard limits.""" + hard_minimum, hard_maximum = hard_limits + finite_values = [value for value in values if math.isfinite(value)] + if reference is not None: + finite_values.append(reference) + if not finite_values: + finite_values = [hard_minimum, hard_maximum] + + minimum = min(finite_values) + maximum = max(finite_values) + if minimum == maximum: + expansion = (hard_maximum - hard_minimum) * 0.1 + minimum -= expansion / 2 + maximum += expansion / 2 + + span = maximum - minimum + padded_minimum = max(hard_minimum, minimum - span * 0.1) + padded_maximum = min(hard_maximum, maximum + span * 0.1) + step = nice_step(max((padded_maximum - padded_minimum) / 5, 1e-12)) + scaled_minimum = max(hard_minimum, math.floor(padded_minimum / step) * step) + scaled_maximum = min(hard_maximum, math.ceil(padded_maximum / step) * step) + if scaled_minimum == scaled_maximum: + scaled_minimum, scaled_maximum = hard_minimum, hard_maximum + + tick_count = round((scaled_maximum - scaled_minimum) / step) + ticks = [scaled_minimum + index * step for index in range(tick_count + 1)] + if reference is not None and scaled_minimum <= reference <= scaled_maximum: + ticks.append(reference) + normalized_ticks = tuple( + sorted({round(value, 12) for value in ticks if scaled_minimum <= value <= scaled_maximum}) + ) + return ChartScale(scaled_minimum, scaled_maximum, normalized_ticks) + + +def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + names = ( + "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" + if bold + else "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + if bold + else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ) + for name in names: + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def read_probability_map(path: Path) -> list[ProbabilityRow]: + """Read operation rows and numerically ordered bit columns from a CSV file.""" + try: + stream = path.open(encoding="utf-8", newline="") + except OSError as error: + raise ValueError(f"не удалось открыть {path}: {error}") from error + + with stream: + reader = csv.DictReader(stream) + fields = reader.fieldnames + if not fields or "operation" not in fields or "pairs" not in fields: + raise ValueError("CSV должен содержать колонки operation и pairs") + + bit_fields: list[tuple[int, str]] = [] + for field in fields: + if not field.startswith("bit_"): + continue + try: + bit_fields.append((int(field.removeprefix("bit_")), field)) + except ValueError as error: + raise ValueError(f"некорректная битовая колонка: {field}") from error + bit_fields.sort() + if not bit_fields: + raise ValueError("CSV не содержит колонок bit_N") + expected_bits = list(range(len(bit_fields))) + actual_bits = [bit for bit, _field in bit_fields] + if actual_bits != expected_bits: + raise ValueError("битовые колонки должны непрерывно идти от bit_0") + + rows: list[ProbabilityRow] = [] + for line_number, row in enumerate(reader, start=2): + operation = (row.get("operation") or "").strip() + if not operation: + raise ValueError(f"строка {line_number}: пустая операция") + try: + pairs = int(row["pairs"] or "") + except (TypeError, ValueError) as error: + raise ValueError( + f"строка {line_number}: некорректное число пар" + ) from error + if pairs < 0: + raise ValueError(f"строка {line_number}: число пар меньше нуля") + + probabilities: list[float] = [] + for _bit, field in bit_fields: + try: + value = float(row[field] or "") + except (TypeError, ValueError) as error: + raise ValueError( + f"строка {line_number}: некорректное значение {field}" + ) from error + if not math.isnan(value) and not 0.0 <= value <= 1.0: + raise ValueError( + f"строка {line_number}: {field} должен быть от 0 до 1" + ) + probabilities.append(value) + rows.append(ProbabilityRow(operation, pairs, probabilities)) + + if not rows: + raise ValueError("CSV не содержит строк с операциями") + return rows + + +def mean_absolute_deviation(probabilities: Sequence[float]) -> float: + """Return the mean |p - 0.5| over finite bit probabilities.""" + deviations = [ + abs(probability - 0.5) + for probability in probabilities + if math.isfinite(probability) + ] + return fmean(deviations) if deviations else float("nan") + + +def text_width( + draw: ImageDraw.ImageDraw, + text: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, +) -> int: + box = draw.textbbox((0, 0), text, font=font) + return round(box[2] - box[0]) + + +def draw_centered_text( + draw: ImageDraw.ImageDraw, + center_x: float, + y: float, + text: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + fill: str = TEXT, +) -> None: + draw.text( + (center_x - text_width(draw, text, font) / 2, y), + text, + font=font, + fill=fill, + ) + + +def draw_bit_panel( + draw: ImageDraw.ImageDraw, + bounds: tuple[int, int, int, int], + row: ProbabilityRow, + color: str, + scale: ChartScale, +) -> None: + left, top, right, bottom = bounds + title_font = load_font(22, bold=True) + label_font = load_font(14) + tick_font = load_font(12) + + draw.rounded_rectangle(bounds, radius=12, fill=PANEL, outline=GRID, width=1) + draw.text((left + 18, top + 13), row.operation, font=title_font, fill=TEXT) + pairs_text = f"pairs: {row.pairs}" + draw.text( + (right - 18 - text_width(draw, pairs_text, label_font), top + 17), + pairs_text, + font=label_font, + fill=MUTED, + ) + + plot_left = left + 54 + plot_right = right - 18 + plot_top = top + 55 + plot_bottom = bottom - 42 + plot_height = plot_bottom - plot_top + + scale_span = scale.maximum - scale.minimum + for probability in scale.ticks: + y = round(plot_bottom - (probability - scale.minimum) / scale_span * plot_height) + line_color = REFERENCE if probability == 0.5 else GRID + line_width = 2 if probability == 0.5 else 1 + draw.line((plot_left, y, plot_right, y), fill=line_color, width=line_width) + label = f"{probability:.3g}" + draw.text( + (plot_left - 8 - text_width(draw, label, tick_font), y - 7), + label, + font=tick_font, + fill=MUTED, + ) + + bit_count = len(row.probabilities) + slot_width = (plot_right - plot_left) / bit_count + bar_width = max(1, int(slot_width * 0.72)) + for bit, probability in enumerate(row.probabilities): + if not math.isfinite(probability): + continue + center = plot_left + (bit + 0.5) * slot_width + x0 = round(center - bar_width / 2) + x1 = round(center + bar_width / 2) + y = round( + plot_bottom + - (probability - scale.minimum) / scale_span * plot_height + ) + draw.rectangle((x0, y, x1, plot_bottom), fill=color) + + tick_step = max(1, math.ceil(bit_count / 16)) + for bit in range(0, bit_count, tick_step): + center = plot_left + (bit + 0.5) * slot_width + label = str(bit) + draw.text( + (center - text_width(draw, label, tick_font) / 2, plot_bottom + 7), + label, + font=tick_font, + fill=MUTED, + ) + draw_centered_text( + draw, + (plot_left + plot_right) / 2, + bottom - 21, + "output bit (0 = LSB)", + tick_font, + MUTED, + ) + + +def render_bit_probabilities( + rows: Sequence[ProbabilityRow], output: Path, title: str +) -> None: + columns = 2 if len(rows) > 1 else 1 + panel_width = 760 + panel_height = 330 + gap = 18 + margin = 24 + title_height = 70 + row_count = math.ceil(len(rows) / columns) + width = margin * 2 + columns * panel_width + (columns - 1) * gap + height = title_height + margin + row_count * panel_height + (row_count - 1) * gap + + image = Image.new("RGB", (width, height), BACKGROUND) + draw = ImageDraw.Draw(image) + scale = make_scale( + [probability for row in rows for probability in row.probabilities], + reference=0.5, + hard_limits=(0.0, 1.0), + ) + draw_centered_text(draw, width / 2, 18, title, load_font(30, bold=True)) + draw_centered_text( + draw, + width / 2, + 52, + f"Shared scale {scale.minimum:.3g}–{scale.maximum:.3g}; red line = ideal p=0.5", + load_font(14), + MUTED, + ) + + for index, row in enumerate(rows): + column = index % columns + grid_row = index // columns + left = margin + column * (panel_width + gap) + top = title_height + grid_row * (panel_height + gap) + draw_bit_panel( + draw, + (left, top, left + panel_width, top + panel_height), + row, + COLORS[index % len(COLORS)], + scale, + ) + + output.parent.mkdir(parents=True, exist_ok=True) + image.save(output, "PNG", optimize=True) + + +def render_mean_deviations( + rows: Sequence[ProbabilityRow], output: Path, title: str +) -> None: + width, height = 1200, 720 + image = Image.new("RGB", (width, height), BACKGROUND) + draw = ImageDraw.Draw(image) + draw_centered_text(draw, width / 2, 22, title, load_font(30, bold=True)) + draw_centered_text( + draw, + width / 2, + 58, + "Mean absolute deviation from ideal avalanche probability: mean(|p - 0.5|)", + load_font(15), + MUTED, + ) + + plot_left, plot_right = 90, width - 40 + plot_top, plot_bottom = 110, height - 120 + plot_height = plot_bottom - plot_top + deviations = [mean_absolute_deviation(row.probabilities) for row in rows] + scale = make_scale(deviations, hard_limits=(0.0, 0.5)) + scale_span = scale.maximum - scale.minimum + tick_font = load_font(13) + label_font = load_font(15) + value_font = load_font(14, bold=True) + + for value in scale.ticks: + y = round(plot_bottom - (value - scale.minimum) / scale_span * plot_height) + draw.line((plot_left, y, plot_right, y), fill=GRID, width=1) + label = f"{value:.3g}" + draw.text( + (plot_left - 10 - text_width(draw, label, tick_font), y - 7), + label, + font=tick_font, + fill=MUTED, + ) + + slot_width = (plot_right - plot_left) / len(rows) + bar_width = min(105, max(20, int(slot_width * 0.62))) + for index, (row, deviation) in enumerate(zip(rows, deviations, strict=True)): + center = plot_left + (index + 0.5) * slot_width + x0 = round(center - bar_width / 2) + x1 = round(center + bar_width / 2) + if math.isfinite(deviation): + y = round( + plot_bottom + - (deviation - scale.minimum) / scale_span * plot_height + ) + draw.rectangle((x0, y, x1, plot_bottom), fill=COLORS[index % len(COLORS)]) + value_label = f"{deviation:.4f}" + else: + y = plot_bottom + value_label = "n/a" + draw_centered_text(draw, center, max(plot_top, y - 22), value_label, value_font) + draw_centered_text(draw, center, plot_bottom + 12, row.operation, label_font) + draw_centered_text( + draw, center, plot_bottom + 36, f"pairs: {row.pairs}", tick_font, MUTED + ) + + draw.line((plot_left, plot_top, plot_left, plot_bottom), fill=TEXT, width=2) + draw.line((plot_left, plot_bottom, plot_right, plot_bottom), fill=TEXT, width=2) + output.parent.mkdir(parents=True, exist_ok=True) + image.save(output, "PNG", optimize=True) + + +def generate_plots(source: Path, output_directory: Path | None = None) -> tuple[Path, Path]: + rows = read_probability_map(source) + destination = output_directory or source.parent + bit_output = destination / f"{source.stem}_bits.png" + deviation_output = destination / f"{source.stem}_deviation.png" + display_name = source.stem + + render_bit_probabilities(rows, bit_output, f"{display_name}: per-bit avalanche map") + render_mean_deviations( + rows, + deviation_output, + f"{display_name}: deviation from p=0.5", + ) + return bit_output, deviation_output + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Создаёт две PNG-гистограммы из CSV вероятностной карты: " + "вероятности по битам для каждой операции и среднее |p-0.5|." + ) + ) + parser.add_argument("csv_file", type=Path, help="CSV из probability_map.py") + parser.add_argument( + "-o", + "--output-dir", + type=Path, + help="каталог PNG (по умолчанию каталог исходного CSV)", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + outputs = generate_plots(args.csv_file, args.output_dir) + except ValueError as error: + raise SystemExit(f"error: {error}") from error + + for output in outputs: + print(f"wrote {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hash_testing/new_testing/probability_map.py b/hash_testing/new_testing/probability_map.py new file mode 100755 index 0000000..d9bfd9c --- /dev/null +++ b/hash_testing/new_testing/probability_map.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Build bit-probability maps for hash functions and word mutation types. + +For every hash listed in HASHES, the script compares each source word hash with +hashes of generated similar words. A table cell contains the probability that +the corresponding output bit changed (XOR with its source word hash). +""" + +from __future__ import annotations + +import argparse +import csv +import re +import stat +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import TextIO + +from generate_input import ( + DEFAULT_ALPHABET, + MUTATIONS, + MutationError, + generate_words, + parse_operation, +) + +ROOT = Path(__file__).resolve().parent +HASH_FUNCS_DIR = ROOT / "hash_funcs" +DEFAULT_OUTPUT_DIR = ROOT / "probability_maps" + +# Add directory names from hash_funcs here to include more implementations. +HASHES = [ + "jenkinsOAAT", +] + +HEX_HASH = re.compile(r"(?:0[xX])?([0-9a-fA-F]+)") +ProbabilityRow = tuple[int, list[float]] + + +class HashToolError(RuntimeError): + """Raised when a hash executable cannot be prepared or invoked.""" + + +def prepare_hash(hash_name: str, hash_funcs_dir: Path = HASH_FUNCS_DIR) -> Path: + """Return an executable hash tool, building or preparing it if necessary.""" + if not hash_name or Path(hash_name).name != hash_name: + raise HashToolError(f"некорректное имя хэша: {hash_name!r}") + + hash_directory = hash_funcs_dir / hash_name + if not hash_directory.is_dir(): + raise HashToolError(f"не найдена папка хэша: {hash_directory}") + + binary = hash_directory / "bin_hash" + if binary.is_file(): + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + return binary + + sources = ( + (hash_directory / "bin_hash.c", "cc"), + (hash_directory / "bin_hash.cpp", "c++"), + (hash_directory / "bin_hash.cc", "c++"), + (hash_directory / "bin_hash.cxx", "c++"), + ) + for source, compiler in sources: + if not source.is_file(): + continue + command = [ + compiler, + "-O2", + "-Wall", + "-Wextra", + "-Wpedantic", + "-Werror", + str(source), + "-o", + str(binary), + ] + if compiler == "cc": + command[1:1] = ["-std=c11"] + else: + command[1:1] = ["-std=c++17"] + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise HashToolError( + f"не удалось скомпилировать {source}: {details}" + ) + return binary + + python_source = hash_directory / "bin_hash.py" + if python_source.is_file(): + content = python_source.read_text(encoding="utf-8") + if not content.startswith("#!"): + python_source.write_text( + "#!/usr/bin/env python3\n" + content, + encoding="utf-8", + ) + python_source.chmod( + python_source.stat().st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH + ) + return python_source + + raise HashToolError( + f"для {hash_name} не найден bin_hash, bin_hash.c/cpp/cc/cxx или bin_hash.py" + ) + + +def run_hash(executable: Path, word: str) -> tuple[int, int]: + """Run a hash tool and return its integer value and explicit output width.""" + try: + result = subprocess.run( + [str(executable), word], + text=True, + capture_output=True, + check=False, + ) + except OSError as error: + raise HashToolError(f"не удалось запустить {executable}: {error}") from error + + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise HashToolError( + f"{executable} завершился с кодом {result.returncode}: {details}" + ) + + output = result.stdout.strip() + match = HEX_HASH.fullmatch(output) + if match is None: + raise HashToolError( + f"{executable} вернул не шестнадцатеричный хэш: {output!r}" + ) + + digits = match.group(1) + return int(digits, 16), len(digits) * 4 + + +def bit_probabilities( + source_hash: int, changed_hashes: Sequence[int], bits: int +) -> list[float]: + """Calculate per-bit change probabilities, ordered from LSB to MSB.""" + if bits < 1: + raise ValueError("число бит должно быть положительным") + if not changed_hashes: + raise ValueError("список изменённых хэшей не должен быть пустым") + + changed_counts = [0] * bits + for changed_hash in changed_hashes: + difference = source_hash ^ changed_hash + for bit in range(bits): + changed_counts[bit] += (difference >> bit) & 1 + + sample_count = len(changed_hashes) + return [count / sample_count for count in changed_counts] + + +def write_csv_table(stream: TextIO, rows: Mapping[str, ProbabilityRow]) -> None: + """Write one operation-by-bit probability table as CSV.""" + if not rows: + raise ValueError("таблица вероятностей не должна быть пустой") + + widths = {len(probabilities) for _pair_count, probabilities in rows.values()} + if len(widths) != 1: + raise ValueError("все строки таблицы должны иметь одинаковое число бит") + bits = widths.pop() + + writer = csv.writer(stream, lineterminator="\n") + writer.writerow( + ["operation", "pairs", *(f"bit_{bit}" for bit in range(bits))] + ) + for operation, (pair_count, probabilities) in rows.items(): + writer.writerow( + [ + operation, + pair_count, + *(f"{probability:.6f}" for probability in probabilities), + ] + ) + + +def build_probability_table( + executable: Path, + sources: Sequence[str], + operations: Sequence[str], + count: int, + operation_count: int, + alphabet: str, + seed: int | None, + max_attempts: int | None, +) -> dict[str, ProbabilityRow]: + """Aggregate avalanche probabilities across all source words.""" + if not sources: + raise ValueError("нужно указать хотя бы одно исходное слово") + + source_hashes: list[int] = [] + bits: int | None = None + for source in sources: + source_hash, source_bits = run_hash(executable, source) + if bits is None: + bits = source_bits + elif source_bits != bits: + raise HashToolError( + f"{executable} вернул хэши разной ширины: " + f"{bits} и {source_bits} бит" + ) + source_hashes.append(source_hash) + + assert bits is not None + table: dict[str, ProbabilityRow] = {} + + for operation in operations: + differences: list[int] = [] + for source, source_hash in zip(sources, source_hashes, strict=True): + words = generate_words( + source=source, + count=count, + operation=operation, + operation_count=operation_count, + alphabet=alphabet, + seed=seed, + max_attempts=max_attempts, + ) + if len(words) < count: + print( + f"warning: operation={operation} word={source!r}: " + f"generated {len(words)} of at most {count} unique words", + file=sys.stderr, + ) + for word in words: + changed_hash, changed_bits = run_hash(executable, word) + if changed_bits != bits: + raise HashToolError( + f"{executable} вернул хэши разной ширины: " + f"{bits} и {changed_bits} бит" + ) + differences.append(source_hash ^ changed_hash) + + if differences: + table[operation] = ( + len(differences), + bit_probabilities(0, differences, bits), + ) + else: + table[operation] = (0, [float("nan")] * bits) + + return table + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Строит для каждого хэша CSV-таблицу вероятностей изменения " + "выходных битов. bit_0 — младший бит." + ) + ) + parser.add_argument( + "words", + nargs="+", + help="одно или несколько исходных ASCII-слов", + ) + parser.add_argument( + "-o", + "--operation", + action="append", + type=parse_operation, + help=( + "тип изменения (номер или имя как в generate_input.py); " + "можно повторять, по умолчанию используются все типы" + ), + ) + parser.add_argument( + "-n", + "--count", + type=int, + default=10, + help=( + "верхняя граница числа уникальных изменённых слов для каждого " + "исходного слова и типа (по умолчанию: 10)" + ), + ) + parser.add_argument( + "-k", + "--operations", + type=int, + default=1, + help="число операций над каждым словом (по умолчанию: 1)", + ) + parser.add_argument( + "--alphabet", + default=DEFAULT_ALPHABET, + help="алфавит для добавления и замены", + ) + parser.add_argument( + "--seed", type=int, help="seed генератора для воспроизводимого результата" + ) + parser.add_argument( + "--max-attempts", + type=int, + help="предельное число попыток собрать уникальные слова", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help=( + "каталог для CSV-таблиц " + f"(по умолчанию: {DEFAULT_OUTPUT_DIR})" + ), + ) + parser.add_argument( + "--hash", + dest="hashes", + action="append", + help="проверить только указанный хэш; можно повторять (по умолчанию HASHES)", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.count < 1: + parser.error("--count должен быть положительным") + if args.operations < 1: + parser.error("--operations должен быть положительным") + if args.max_attempts is not None and args.max_attempts < 1: + parser.error("--max-attempts должен быть положительным") + + operations = list(dict.fromkeys(args.operation or MUTATIONS.keys())) + hashes = list(dict.fromkeys(args.hashes or HASHES)) + if not hashes: + parser.error("массив HASHES не должен быть пустым") + + args.output.mkdir(parents=True, exist_ok=True) + try: + for hash_name in hashes: + executable = prepare_hash(hash_name) + table = build_probability_table( + executable=executable, + sources=args.words, + operations=operations, + count=args.count, + operation_count=args.operations, + alphabet=args.alphabet, + seed=args.seed, + max_attempts=args.max_attempts, + ) + output_path = args.output / f"{hash_name}.csv" + with output_path.open("w", encoding="utf-8", newline="") as stream: + write_csv_table(stream, table) + print(f"wrote {output_path}") + except (HashToolError, MutationError, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hash_testing/new_testing/probability_maps/jenkinsOAAT.csv b/hash_testing/new_testing/probability_maps/jenkinsOAAT.csv new file mode 100644 index 0000000..8d768d0 --- /dev/null +++ b/hash_testing/new_testing/probability_maps/jenkinsOAAT.csv @@ -0,0 +1,5 @@ +operation,pairs,bit_0,bit_1,bit_2,bit_3,bit_4,bit_5,bit_6,bit_7,bit_8,bit_9,bit_10,bit_11,bit_12,bit_13,bit_14,bit_15,bit_16,bit_17,bit_18,bit_19,bit_20,bit_21,bit_22,bit_23,bit_24,bit_25,bit_26,bit_27,bit_28,bit_29,bit_30,bit_31 +replace,1922,0.486472,0.498959,0.483351,0.501561,0.503122,0.495317,0.523413,0.466701,0.520812,0.496878,0.513007,0.503122,0.490114,0.510926,0.482310,0.494277,0.511446,0.505723,0.498959,0.501561,0.512487,0.509365,0.489594,0.514048,0.495317,0.519771,0.505203,0.515609,0.517690,0.489074,0.498959,0.508845 +add,2111,0.506869,0.505448,0.502132,0.502132,0.496921,0.502132,0.505921,0.505921,0.494552,0.507342,0.493605,0.502605,0.492658,0.501658,0.495026,0.508764,0.496921,0.486973,0.507816,0.518238,0.513501,0.506395,0.513974,0.488394,0.504500,0.482236,0.510185,0.520133,0.500711,0.504027,0.520133,0.497868 +first,186,0.451613,0.435484,0.440860,0.462366,0.521505,0.494624,0.510753,0.435484,0.462366,0.483871,0.505376,0.521505,0.451613,0.532258,0.462366,0.505376,0.473118,0.505376,0.446237,0.473118,0.489247,0.537634,0.440860,0.521505,0.537634,0.500000,0.548387,0.575269,0.575269,0.494624,0.462366,0.505376 +last,186,0.516129,0.500000,0.483871,0.510753,0.510753,0.483871,0.478495,0.521505,0.569892,0.500000,0.478495,0.494624,0.462366,0.521505,0.510753,0.500000,0.569892,0.494624,0.505376,0.510753,0.521505,0.478495,0.478495,0.543011,0.500000,0.548387,0.532258,0.451613,0.500000,0.500000,0.516129,0.521505 diff --git a/hash_testing/new_testing/probability_maps/jenkinsOAAT_bits.png b/hash_testing/new_testing/probability_maps/jenkinsOAAT_bits.png new file mode 100644 index 0000000000000000000000000000000000000000..e940149e6e3f89d0d98399416d0404b2f7187386 GIT binary patch literal 38919 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%0DxCiNYEM-ursaEfWRoa^SnMLt`J{)XYaYUc7~vc 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 literal 0 HcmV?d00001 diff --git a/hash_testing/new_testing/test_plot_probability_map.py b/hash_testing/new_testing/test_plot_probability_map.py new file mode 100644 index 0000000..eac1cbf --- /dev/null +++ b/hash_testing/new_testing/test_plot_probability_map.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Behavior tests for plot_probability_map.py.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +import plot_probability_map + + +class ProbabilityMapPlotTests(unittest.TestCase): + def test_reads_bit_columns_and_ignores_operation_and_pairs(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "hash.csv" + path.write_text( + "operation,pairs,bit_0,bit_1\n" + "replace,4,0.25,0.75\n" + "delete,2,0.5,nan\n", + encoding="utf-8", + ) + + rows = plot_probability_map.read_probability_map(path) + + self.assertEqual(rows[0].operation, "replace") + self.assertEqual(rows[0].pairs, 4) + self.assertEqual(rows[0].probabilities, [0.25, 0.75]) + self.assertEqual(rows[1].operation, "delete") + self.assertEqual(rows[1].pairs, 2) + self.assertEqual(len(rows[1].probabilities), 2) + + def test_mean_absolute_deviation_from_half_ignores_nan(self) -> None: + result = plot_probability_map.mean_absolute_deviation( + [0.25, 0.5, 0.75, float("nan")] + ) + + self.assertEqual(result, 1 / 6) + + def test_shared_scale_zooms_to_all_values_and_reference(self) -> None: + scale = plot_probability_map.make_scale( + [0.45, 0.48, 0.52, 0.55], + reference=0.5, + hard_limits=(0.0, 1.0), + ) + + self.assertGreater(scale.minimum, 0.0) + self.assertLess(scale.maximum, 1.0) + self.assertLessEqual(scale.minimum, 0.45) + self.assertGreaterEqual(scale.maximum, 0.55) + self.assertIn(0.5, scale.ticks) + + def test_deviation_scale_uses_data_range_instead_of_fixed_half(self) -> None: + scale = plot_probability_map.make_scale( + [0.05, 0.08], hard_limits=(0.0, 0.5) + ) + + self.assertGreater(scale.minimum, 0.0) + self.assertLess(scale.maximum, 0.5) + self.assertLessEqual(scale.minimum, 0.05) + self.assertGreaterEqual(scale.maximum, 0.08) + + def test_generates_two_nonempty_png_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + source = root / "sample.csv" + source.write_text( + "operation,pairs,bit_0,bit_1,bit_2,bit_3\n" + "replace,4,0.25,0.50,0.75,1.0\n" + "delete,2,0.10,0.20,0.30,0.40\n", + encoding="utf-8", + ) + + outputs = plot_probability_map.generate_plots(source, root / "plots") + + self.assertEqual(len(outputs), 2) + for output in outputs: + self.assertTrue(output.is_file()) + with Image.open(output) as image: + self.assertEqual(image.format, "PNG") + self.assertGreater(image.width, 300) + self.assertGreater(image.height, 200) + colors = image.convert("RGB").getcolors(maxcolors=1_000_000) + self.assertIsNotNone(colors) + self.assertGreater(len(colors or []), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/hash_testing/new_testing/test_probability_map.py b/hash_testing/new_testing/test_probability_map.py new file mode 100644 index 0000000..95c34a8 --- /dev/null +++ b/hash_testing/new_testing/test_probability_map.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Behavior tests for probability_map.py.""" + +from __future__ import annotations + +import csv +import io +import os +import subprocess +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +import generate_input +import probability_map + + +class PrepareHashTests(unittest.TestCase): + def test_compiles_c_source_when_binary_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + hash_directory = root / "constant" + hash_directory.mkdir() + (hash_directory / "bin_hash.c").write_text( + "#include \n" + "int main(void) { puts(\"00000001\"); return 0; }\n", + encoding="utf-8", + ) + + executable = probability_map.prepare_hash("constant", root) + + self.assertEqual(executable, hash_directory / "bin_hash") + self.assertEqual( + subprocess.check_output([executable, "word"], text=True).strip(), + "00000001", + ) + + def test_adds_python_shebang_and_execute_permission(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + hash_directory = root / "python_hash" + hash_directory.mkdir() + source = hash_directory / "bin_hash.py" + source.write_text("print('00000002')\n", encoding="utf-8") + + executable = probability_map.prepare_hash("python_hash", root) + + self.assertEqual(executable, source) + self.assertTrue(os.access(source, os.X_OK)) + self.assertTrue( + source.read_text(encoding="utf-8").startswith( + "#!/usr/bin/env python3\n" + ) + ) + self.assertEqual( + subprocess.check_output([executable, "word"], text=True).strip(), + "00000002", + ) + + +class ProbabilityTests(unittest.TestCase): + def test_counts_changed_hash_bits_relative_to_source(self) -> None: + probabilities = probability_map.bit_probabilities( + source_hash=0b0000, + changed_hashes=[0b0001, 0b0011, 0b0010, 0b0000], + bits=4, + ) + + self.assertEqual(probabilities, [0.5, 0.5, 0.0, 0.0]) + + def test_csv_table_has_operation_rows_and_bit_columns(self) -> None: + stream = io.StringIO() + + probability_map.write_csv_table( + stream, + { + "replace": (4, [0.25, 0.75]), + "delete": (2, [0.5, 0.0]), + }, + ) + + rows = list(csv.reader(io.StringIO(stream.getvalue()))) + self.assertEqual(rows[0], ["operation", "pairs", "bit_0", "bit_1"]) + self.assertEqual(rows[1], ["replace", "4", "0.250000", "0.750000"]) + self.assertEqual(rows[2], ["delete", "2", "0.500000", "0.000000"]) + + def test_parser_accepts_multiple_source_words(self) -> None: + arguments = probability_map.build_parser().parse_args(["first", "second"]) + + self.assertEqual(arguments.words, ["first", "second"]) + + def test_aggregates_samples_from_multiple_words_and_warns_on_shortfall(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + executable = Path(temporary_directory) / "hash.py" + executable.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "print(f'{sum(sys.argv[1].encode()):08x}')\n", + encoding="utf-8", + ) + executable.chmod(0o755) + warnings = io.StringIO() + + with redirect_stderr(warnings): + table = probability_map.build_probability_table( + executable=executable, + sources=["A", "B"], + operations=["delete"], + count=10, + operation_count=1, + alphabet=generate_input.DEFAULT_ALPHABET, + seed=1, + max_attempts=None, + ) + + pair_count, probabilities = table["delete"] + self.assertEqual(pair_count, 2) + self.assertEqual( + probabilities[:8], + [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + ) + self.assertEqual(warnings.getvalue().count("delete"), 2) + + +class GenerateWordsTests(unittest.TestCase): + def test_count_is_an_upper_bound_when_unique_results_are_exhausted(self) -> None: + words = generate_input.generate_words( + source="abc", + count=100, + operation="delete", + operation_count=1, + seed=42, + ) + + self.assertEqual(set(words), {"ab", "ac", "bc"}) + + +if __name__ == "__main__": + unittest.main()