From e3308ed9bee5edc71959fdbdc7d3d7687c857b8d Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:44:11 +0100 Subject: [PATCH 1/3] Add docs for the mbo/diff sub-library --- README.md | 2 +- mbo/diff/README.md | 165 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 mbo/diff/README.md diff --git a/README.md b/README.md index 01092aa..90c6514 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The C++ library is organized in functional groups each residing in their own dir - mbo/container:limited_vector_cc, mbo/container/limited_vector.h - class `LimitedVector`: A space limited, constexpr compliant `vector`. - Diff - - `namespace mbo::diff` + - `namespace mbo::diff` - library docs: [mbo/diff/README.md](mbo/diff/README.md) - mbo/diff:diff_cc, mbo/diff/diff.h - class `Diff`: A class that implements line based diffing in unified, context, normal or side-by-side output format (`DiffOptions::output_format`), using the Myers minimal diff algorithm by default (`DiffOptions::algorithm` also offers `naive` and `direct`). - mbo/diff diff --git a/mbo/diff/README.md b/mbo/diff/README.md new file mode 100644 index 0000000..e51119d --- /dev/null +++ b/mbo/diff/README.md @@ -0,0 +1,165 @@ +# mbo/diff: Unified-Diffing Utilities + +Part of the **MBO** C++20 library ecosystem, `mbo/diff` provides lightweight utilities for generating unified diffs, a standalone command-line diffing tool, and Bazel macros designed for integration testing against golden files. + +--- + +## 1. C++ API Reference: `mbo::diff::Diff` + +The C++ library resides under the namespace `mbo::diff`. It is built to leverage C++20 features (such as `std::string_view` and `std::span`) and integrates natively with Google's Abseil library. + +### `DiffOptions` Struct + +The behavior of the diff engine and formatting output is fully controlled via the `mbo::diff::DiffOptions` struct: + +| Field Name | Type | Default | Description | +| :----------------------- | :------------- | :-------------------- | :--------------------------------------------------------------------------------------------------- | +| `unified_lines` | `size_t` | `3` | The number of context lines to display above and below each diff hunk. | +| `ignore_case` | `bool` | `false` | If `true`, performs a case-insensitive comparison of lines. | +| `ignore_spaces` | `IgnoreSpaces` | `IgnoreSpaces::kNone` | Controls how whitespace is treated. See [Whitespace Configuration](#whitespace-configuration) below. | +| `ignore_blank_lines` | `bool` | `false` | If `true`, runs of empty or whitespace-only lines that are added or removed are ignored. | +| `normalize_line_endings` | `bool` | `true` | Standardizes `\r\n` (Windows) and `\n` (Unix) line endings to `\n` before computing the diff. | + +#### Whitespace Configuration (`IgnoreSpaces` Enum) + +- **`IgnoreSpaces::kNone`**: Strict match. Every whitespace character is treated as significant. +- **`IgnoreSpaces::kTrailing`**: Ignores trailing whitespace at the end of each line. +- **`IgnoreSpaces::kChange`**: Ignores changes in the _amount_ of whitespace (e.g., multiple spaces or tabs are treated as a single space), but requires at least some whitespace if it acts as a delimiter. +- **`IgnoreSpaces::kAll`**: Completely ignores all whitespace characters during the comparison. + +--- + +### Basic C++ Usage + +```cpp +#include "mbo/diff/diff.h" +#include "absl/status/statusor.h" +#include +#include + +int main() { + std::string_view original = "Line 1\nLine 2\n\nLine 3\n"; + std::string_view modified = "line 1\nLine 2 changed\nLine 3\n"; + + // Configure high-precision diff rules + mbo::diff::DiffOptions options; + options.unified_lines = 2; + options.ignore_case = true; + options.ignore_blank_lines = true; + options.ignore_spaces = mbo::diff::IgnoreSpaces::kTrailing; + + absl::StatusOr diff_output = mbo::diff::Diff::FormatUnified( + "src/original.txt", original, + "src/modified.txt", modified, + options + ); + + if (diff_output.ok()) { + if (diff_output->empty()) { + std::cout << "Files are identical under the current configuration." << std::endl; + } else { + std::cout << *diff_output << std::endl; + } + } else { + std::cerr << "Diff failed to execute: " << diff_output.status().message() << std::endl; + } + + return 0; +} + +``` + +--- + +## 2. Command-Line Tool Reference: `unified_diff` + +The `unified_diff` binary exposes the underlying C++ diffing configurations via standard command-line flags. + +### CLI Parameters & Flags + +| Flag | Long Option | Type | Default | Description | +| ---- | ------------------------- | ------ | ------- | ------------------------------------------------- | +| `-u` | `--unified` | `int` | `3` | Number of context lines to output around changes. | +| `-i` | `--ignore-case` | `bool` | `false` | Ignore case differences in file contents. | +| `-w` | `--ignore-all-space` | `bool` | `false` | Ignore all white space when comparing lines. | +| `-b` | `--ignore-space-change` | `bool` | `false` | Ignore changes in amount of white space. | +| `-B` | `--ignore-blank-lines` | `bool` | `false` | Ignore changes whose lines are all blank. | +| `-Z` | `--ignore-trailing-space` | `bool` | `false` | Ignore white space at line end. | +| | `--strip-trailing-cr` | `bool` | `true` | Strip carriage return (`\r`) at the end of lines. | + +### CLI Compilation & Usage + +Build the tool with Bazel: + +```bash +bazel build //mbo/diff:unified_diff + +``` + +Perform a custom, whitespace-insensitive diff with 5 context lines: + +```bash +./bazel-bin/mbo/diff/unified_diff \ + --unified=5 \ + --ignore-space-change \ + --ignore-blank-lines \ + path/to/original.txt path/to/modified.txt + +``` + +--- + +## 3. Bazel Integration: `diff_test` Macro + +The `diff_test` Bazel macro (loaded from `@mbo//mbo/diff:diff.bzl`) acts as a wrapper around the `unified_diff` binary, running comparisons as part of your standard Bazel test suite. + +### Macro Arguments Reference + +When declaring a `diff_test` target in your `BUILD` file, the following arguments are available: + +| Argument | Type | Required | Description | +| --------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------- | +| `name` | `string` | **Yes** | A unique name for this test target. | +| `file_a` | `label` | **Yes** | The first file to compare (often a generated file/target output). | +| `file_b` | `label` | **Yes** | The second file to compare (often your expected "golden" file). | +| `unified` | `int` | No (Default: `3`) | The number of context lines to display on match failure. | +| `ignore_case` | `bool` | No (Default: `false`) | If `true`, enables case-insensitive comparison. | +| `ignore_space_change` | `bool` | No (Default: `false`) | Ignores changes in whitespace spacing amount. | +| `ignore_blank_lines` | `bool` | No (Default: `false`) | Ignores runs of empty lines. | +| `args` | `list of strings` | No | Extra command-line arguments to pass directly to the underlying binary. | +| `data` | `list of labels` | No | Additional runfiles required by the test. | + +### Advanced `BUILD` Example + +This configuration dynamically generates an output file, then uses `diff_test` with custom matching rules to ignore formatting differences: + +```bazel +load("@mbo//mbo/diff:diff.bzl", "diff_test") + +# Generate some configuration or build output +genrule( + name = "generate_config", + srcs = ["template.conf"], + outs = ["generated.conf"], + cmd = "$(location //tools:config_builder) --input=$< --output=$@", + tools = ["//tools:config_builder"], +) + +# Perform strict comparison but ignore minor whitespace styling and blank lines +diff_test( + name = "verify_config_generation", + file_a = ":generated.conf", + file_b = "//testdata:golden_config.conf", + ignore_space_change = True, + ignore_blank_lines = True, + unified = 5, +) + +``` + +Run the validation test with: + +```bash +bazel test //path/to/package:verify_config_generation + +``` From cc578e1d185881dfcbee54f2166c31b047e337eb Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:05:23 +0100 Subject: [PATCH 2/3] Docs updates --- mbo/diff/README.md | 4 +- mbo/hash/README.md | 128 ++++++++++++++++++++++++--------------------- 2 files changed, 72 insertions(+), 60 deletions(-) diff --git a/mbo/diff/README.md b/mbo/diff/README.md index e51119d..0942a62 100644 --- a/mbo/diff/README.md +++ b/mbo/diff/README.md @@ -20,7 +20,9 @@ The behavior of the diff engine and formatting output is fully controlled via th | `ignore_blank_lines` | `bool` | `false` | If `true`, runs of empty or whitespace-only lines that are added or removed are ignored. | | `normalize_line_endings` | `bool` | `true` | Standardizes `\r\n` (Windows) and `\n` (Unix) line endings to `\n` before computing the diff. | -#### Whitespace Configuration (`IgnoreSpaces` Enum) +#### Whitespace Configuration + +A.k.a. (`IgnoreSpaces` Enum) - **`IgnoreSpaces::kNone`**: Strict match. Every whitespace character is treated as significant. - **`IgnoreSpaces::kTrailing`**: Ignores trailing whitespace at the end of each line. diff --git a/mbo/hash/README.md b/mbo/hash/README.md index dee2263..8ae8ca2 100644 --- a/mbo/hash/README.md +++ b/mbo/hash/README.md @@ -1,25 +1,27 @@ # mbo/hash - fast, constexpr-safe, non-cryptographic hashing -Fast, constexpr-safe, non-cryptographic hashing, built around the in-house +Fast, `constexpr`-safe, non-cryptographic hashing, built around the in-house **mumbo/jumbo and dumbo** family: notice-free, pure Apache-2.0, and MUM-based -(widening multiply). All three pass [SMHasher3](https://gitlab.com/fwojcik/smhasher3) -clean (188/188). `mumbo` (64-bit) is the all-round default: among the fastest -hashes here on every machine we measured, SMHasher3-clean, notice-free Apache-2.0, -and with both streaming and a Starlark port - few alternatives combine all of -these. Its native 128-bit sibling `jumbo` is the only clean native 128 we -measured and an excellent 128-bit choice. `dumbo` is a compact single-lane -companion with a very different profile - fastest here on tiny keys, slower on -bulk - that trades reach for size, not quality. - -It also ships a **build-seed mangle** (`hash_mangle.h`): restricted/limited, -constexpr-safe compile-time hash mangling with release-time rotation enforcement. -That is hash randomization for the constexpr world, which compile-time hashing -otherwise rules out. +(widening multiply). All three algorithm cleanly pass the quality assesment of +[SMHasher3](https://gitlab.com/fwojcik/smhasher3) with a rating of 188/188. + +The `mumbo` algorithm (64-bit) is the all-round default of this library. It is +among the fastest hashes offered in the lirbrary on every machine we measured. +Is is SMHasher3-clean, notice-free Apache-2.0 licensed, and offers both a +streaming API and a [Starlark](https://github.com/bazelbuild/starlark) port so +you can natively use the algorithm in [Bazel](https://bazel.build/) projects. +Its native 128-bit sibling `jumbo` is the only clean native 128 we measured. +Last but not least `dumbo` is a compact single-lane companion with a very a +different profile - fastest (here) on tiny keys, slower on bulk, but still +passing SMHasher2 and thus offering fully prooven hash algorithm quality. + +This sub-library also ships a **build-seed mangle** (`hash_mangle.h`) wrapper +which offers restricted/limited, `constexpr`-safe compile-time hash mangling +with release-time rotation enforcement. That is hash randomization for the +`constexpr` world, which compile-time hashing otherwise rules out. The third-party algorithms (rapidhash, xxh3/xxh64, murmur3, siphash, fnv1a) are -exact transcriptions, kept for interop and comparison. Algorithm reference and -API listing: see the [repository README](../../README.md). Last but not least we -provide quality (SMHasher3) and performance measurements for all algorithms below. +exact transcriptions. They are provided for interoperability and comparison. ## Offerings @@ -27,51 +29,53 @@ Three entry points, split by contract: - **`hash.h` / `:hash_cc` - deterministic hashing.** `GetHash64` / `GetHash128` / `GetHash32`, the `Hasher` container functor, and - `Streamer` incremental hashing - all constexpr-safe and fully - reproducible for a given library version. Use for hash tables (heterogeneous - string lookup), tokenization/interning, compile-time hashing + `Streamer` incremental hashing - all `constexpr`-safe and fully + reproducible for a given library version. Designer for use in hash tables + (heterogeneous string lookup), tokenization/interning, compile-time hashing (`static_assert`, switch-on-hash), and cross-process consistency within one - build. Values are not a persistence or wire format. + build. Values are not appropriate as a persistence mechanism or wire format. - **`hash_mangle.h` / `:hash_mangle_cc` - deliberately unstable hashing.** `GetHash` / `MangledHasher`: `GetHash64` XORed with one build-selected constant, so values do not compare across independently configured builds. - Use when hash values must not quietly become load-bearing (persisted tables, - golden values, cross-build protocols) - the instability is the feature. - Still constexpr; semantics and design rationale in the build-seed mangle - section, the flags in the Configuration section below. -- **`hash_extra.h` / `:hash_extra_cc` - NOTICE-bearing algorithms.** Canonical - rapidhash, xxh3, and xxh64 transcriptions, for interop with externally - defined values and for comparison. Shipping a binary that links this target - requires shipping the repository-root [NOTICE](../../NOTICE). + Use when hash values must not become a dependency (persisted tables, golden + values, cross-build protocols) - the instability is the feature. While this is + all still `constexpr` read up on semantics and design rationale in the below + section on the build-seed mangling. +- **`hash_extra.h` / `:hash_extra_cc` - NOTICE-brequiring algorithms.** These + canonical algorithms (rapidhash, xxh3, and xxh64) are completely verified and + ested transcriptions, for interoperability with externally defined values and + for comparison. Shipping a binary that links this target requires shipping the + repository-root [NOTICE](../../NOTICE). ## Principles - **Canonical or honest**: third-party algorithms (rapidhash, XXH64/XXH3, MurmurHash3, SipHash, FNV-1a) are transcriptions producing the exact published reference values on every platform, pinned by reference vectors - and differential tests against the reference libraries. The in-house `mumbo` - algorithm is documented with its measured quality and performance data - (below) and its design iterations. + and differential tests against the reference libraries. The in-house `mumbo` / + `jumbo` and `dumbo` family of algorithms is documented with its measured + quality and performance data (below) and its design iterations. - **constexpr-safe single path**: compile-time and run-time evaluation always agree; streaming (where provided) equals the one-shot value by contract. - **Apache-2.0 with clean attribution**: transcription notices live in the repository-root [NOTICE](../../NOTICE); [LICENSE](../../LICENSE) stays pure - Apache-2.0. No crypto-library - dependencies - digests and hashes are spec-frozen pure functions that we + Apache-2.0 and are free of crypto-library dependencies. All hash algorithms + (and similarily all digest algorithms) are spec-frozen pure functions that we verify against official vectors instead of trusting an unverifiable supply - chain (see [mbo/digest/README.md](../digest/README.md) for the full argument). + chain (also see [mbo/digest/README.md](../digest/README.md)). - **Non-cryptographic hash-table hashes, with one keyed exception**: the defaults and comparison algorithms are fast hashes for hash tables and - interning - their values are neither stable across versions nor safe against - adversaries. `siphash` is the deliberate exception, a keyed PRF included as - the hash-flooding-resistant choice when the seed is a secret; it is still a - hash-table hash (`GetHash64` / `Hasher`), not a message digest. Cryptographic - **message digests** and MACs (SHA-2/3, MD5 interop, BLAKE2/3, HMAC) are a - different contract and live in [mbo/digest](../digest/README.md). + interning. Their values are neither stable across versions nor safe against + adversaries. The one deliberate exceptions is `siphash`, a keyed PRF + ([Pseudo Random Function](https://en.wikipedia.org/wiki/Pseudorandom_function_family)) + included as the hash-flooding-resistant choice when the seed is a secret. It + is still a hash-table hash (`GetHash64` / `Hasher`), not a message digest. +- **message digests**: Cryptographic and MACs (SHA-2/3, MD5 interop, BLAKE2/3, + HMAC) are a different contract and live in [mbo/digest](../digest/README.md). ## Algorithm overview -This is the at-a-glance map; the `SMHasher3` column is a PASS/FAIL summary only. +This is the at-a-glance overview- map. The `SMHasher3` column is a PASS/FAIL summary only. For the exact score and the failing families see [Quality: SMHasher3](#quality-smhasher3). @@ -91,26 +95,32 @@ For the exact score and the failing families see [Quality: SMHasher3](#quality-s -Notes: the **Starlark** column marks the hashes also implemented at build time -in [`hash.bzl`](hash.bzl) (`hash.mumbo`, `hash.dumbo`, `hash.fnv1a`), kept -byte-for-byte identical to the C++ prime and verified against it (`hash_tool`); -only the one-shot 64-bit form is ported, so the native 128-bit `jumbo` and -streaming stay C++-only. `fnv1a` is the algorithm family many `std::hash` -implementations use (e.g. MSVC) - included as the familiar baseline. `siphash` -is a keyed PRF: the DoS-resistant choice when the seed is a secret. `dumbo` is -the compact single-lane member of the MUM family: the fastest hash here for tiny -keys and SMHasher3-clean (see the design iterations), but single-lane (so it -slows on large keys) - a deliberately minimal companion to `mumbo`, not a -replacement for it. Linking `:hash_extra_cc` requires shipping the -repository-root [NOTICE](../../NOTICE) (see "Third-party components" in the -[repository README](../../README.md)). +Notes: the **Starlark** column marks the hashes that are also implemented in +Starlark for direct buil-graph construction time[`hash.bzl`](hash.bzl). Thos are +(`hash.mumbo`, `hash.dumbo`, `hash.fnv1a`) and they are byte-for-byte identical +to the C++ prime and verified against it (`hash_tool`). Only the one-shot 64-bit +form is ported, so the native 128-bit `jumbo` and streaming stay C++-only. + +Note the following algorithm specifics: + +- `fnv1a` represents the algorithm family many `std::hash` implementations use + (e.g. MSVC) and is thus included as the familiar baseline. +- `siphash` is a keyed PRF. It is the DoS-resistant choice when the seed is a + secret. +- `dumbo` is the compact single-lane member of the mbo MUM family. It is the + fastest hash here for tiny keys and still SMHasher3-clean, but it is also a + single-lane implementation and so it slows on large keys. It is a deliberate + minimal companion to `mumbo` (not a replacement for it). + +Linking `:hash_extra_cc` requires shipping the repository-root [NOTICE](../../NOTICE) +(see "Third-party components" in the [repository README](../../README.md)). ## Build-seed mangle (`hash_mangle.h` / `:hash_mangle_cc`) `mbo::hash::GetHash` and `MangledHasher` equal `GetHash64` XORed with ONE build-selected constant, so values deliberately do not compare across independently configured builds - precomputed tables or persisted values -cannot silently become load-bearing. Everything stays constexpr: the constant +cannot silently become load-bearing. Everything stays `constexpr`: the constant is generated into a header by folding the module's own version (from `MODULE.bazel` via `native.module_version()` - no duplicated version declaration anywhere) with two custom Bazel flags (see the @@ -138,7 +148,7 @@ shape the implementation: entry point participates in constant evaluation, including `GetHash`. True ASLR (absl-style: mixing in the address of a global) or any startup-time random seed cannot appear in a constant expression - adopting one would - split the API into a constexpr unmangled half and a runtime mangled half. + split the API into a `constexpr` unmangled half and a runtime mangled half. The entropy must be a compile-time constant, so it can only be injected at build time. @@ -231,7 +241,7 @@ fallback constant under `-DIS_CLANGD` purely so the editor can parse it. The two frameworks compose rather than compete - pick by contract: `absl::Hash` is per-process randomized and tuned for tiny in-process keys; -`mbo::hash` is canonical, cross-platform, constexpr, and streamable. +`mbo::hash` is canonical, cross-platform, `constexpr`, and streamable. - **Containers**: `DefaultHasher` (any `Hasher` / `MangledHasher`) drops into `absl`/`std` hash containers as the `Hash` parameter for string @@ -267,7 +277,7 @@ The two frameworks compose rather than compete - pick by contract: hash state implementing `combine` / `combine_contiguous` (plus unordered support) can execute every existing `AbslHashValue` overload, so a mumbo-backed state could swap the algorithm underneath all absl-hashable - types. Worth it only when structured types need canonical or constexpr + types. Worth it only when structured types need canonical or `constexpr` hashing; for byte keys the container functor above already does the job. ## Performance From bd7ef41beb71fe1abcb88da65b4bec133e35c4d3 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:07:08 +0100 Subject: [PATCH 3/3] Improve tables and table generation code. --- mbo/diff/README.md | 12 +- mbo/hash/README.md | 51 +- .../measurements/hash_benchmark_report.py | 500 ++++++++++-------- 3 files changed, 295 insertions(+), 268 deletions(-) diff --git a/mbo/diff/README.md b/mbo/diff/README.md index 0942a62..d1bd83b 100644 --- a/mbo/diff/README.md +++ b/mbo/diff/README.md @@ -1,8 +1,6 @@ # mbo/diff: Unified-Diffing Utilities -Part of the **MBO** C++20 library ecosystem, `mbo/diff` provides lightweight utilities for generating unified diffs, a standalone command-line diffing tool, and Bazel macros designed for integration testing against golden files. - ---- +Part of the **MBO** library ecosystem, `mbo/diff` provides lightweight utilities for generating unified diffs, a standalone command-line diffing tool, and Bazel macros designed for integration testing against golden files. ## 1. C++ API Reference: `mbo::diff::Diff` @@ -22,15 +20,13 @@ The behavior of the diff engine and formatting output is fully controlled via th #### Whitespace Configuration -A.k.a. (`IgnoreSpaces` Enum) +The `IgnoreSpaces`: - **`IgnoreSpaces::kNone`**: Strict match. Every whitespace character is treated as significant. - **`IgnoreSpaces::kTrailing`**: Ignores trailing whitespace at the end of each line. - **`IgnoreSpaces::kChange`**: Ignores changes in the _amount_ of whitespace (e.g., multiple spaces or tabs are treated as a single space), but requires at least some whitespace if it acts as a delimiter. - **`IgnoreSpaces::kAll`**: Completely ignores all whitespace characters during the comparison. ---- - ### Basic C++ Usage ```cpp @@ -71,8 +67,6 @@ int main() { ``` ---- - ## 2. Command-Line Tool Reference: `unified_diff` The `unified_diff` binary exposes the underlying C++ diffing configurations via standard command-line flags. @@ -109,8 +103,6 @@ Perform a custom, whitespace-insensitive diff with 5 context lines: ``` ---- - ## 3. Bazel Integration: `diff_test` Macro The `diff_test` Bazel macro (loaded from `@mbo//mbo/diff:diff.bzl`) acts as a wrapper around the `unified_diff` binary, running comparisons as part of your standard Bazel test suite. diff --git a/mbo/hash/README.md b/mbo/hash/README.md index 8ae8ca2..e6992f5 100644 --- a/mbo/hash/README.md +++ b/mbo/hash/README.md @@ -70,8 +70,8 @@ Three entry points, split by contract: ([Pseudo Random Function](https://en.wikipedia.org/wiki/Pseudorandom_function_family)) included as the hash-flooding-resistant choice when the seed is a secret. It is still a hash-table hash (`GetHash64` / `Hasher`), not a message digest. -- **message digests**: Cryptographic and MACs (SHA-2/3, MD5 interop, BLAKE2/3, - HMAC) are a different contract and live in [mbo/digest](../digest/README.md). +- **message digests**: Non-cryptographic Digest and MACs (SHA-2/3, MD5 interop, + BLAKE2/3, HMAC) are a different contract and live in [mbo/digest](../digest/README.md). ## Algorithm overview @@ -80,26 +80,27 @@ For the exact score and the failing families see [Quality: SMHasher3](#quality-s -| Algorithm | Bits | Available via | Starlark | NOTICE | Seeded | Streaming | SMHasher3 | -| ----------- | ---: | --------------------------------- | -------- | ----------------------- | ------ | --------- | --------- | -| `mumbo` | 64 | `hash.h` (default 64/32) | yes | none (in-house) | yes | yes | PASS | -| `jumbo` | 128 | `hash.h` (default 128) | no | none (in-house) | yes | yes (64) | PASS | -| `murmur3` | 128 | `hash.h` | no | none (public domain) | yes | no | FAIL | -| `siphash` | 64 | `hash.h` | no | none (CC0) | keyed | yes | PASS | -| `fnv1a` | 64 | `hash.h` | yes | none (public domain) | yes | no | FAIL | -| `dumbo` | 64 | `hash.h` | yes | none (in-house) | yes | no | PASS | -| `rapidhash` | 64 | `hash_extra.h` + `:hash_extra_cc` | no | **MIT - ship NOTICE** | yes | no | PASS | -| `xxh64` | 64 | `hash_extra.h` + `:hash_extra_cc` | no | **BSD-2 - ship NOTICE** | yes | yes | FAIL | -| `xxh3` | 64 | `hash_extra.h` + `:hash_extra_cc` | no | **BSD-2 - ship NOTICE** | yes | no | FAIL | -| `xxh3` | 128 | `hash_extra.h` + `:hash_extra_cc` | no | **BSD-2 - ship NOTICE** | yes | no | FAIL | +| Algorithm | Bits | SMHasher3 | Seeded | Streaming | Starlark | NOTICE | Available via | +| ----------- | ---: | --------- | ------ | --------- | -------- | ----------------------- | --------------------------------- | +| `mumbo` | 64 | **PASS** | yes | **yes** | **yes** | none (in-house) | `hash.h` (default 64/32) | +| `jumbo` | 128 | **PASS** | yes | yes (64) | no | none (in-house) | `hash.h` (default 128) | +| `murmur3` | 128 | FAIL | yes | no | no | none (public domain) | `hash.h` | +| `siphash` | 64 | **PASS** | keyed | **yes** | no | none (CC0) | `hash.h` | +| `fnv1a` | 64 | FAIL | yes | no | **yes** | none (public domain) | `hash.h` | +| `dumbo` | 64 | **PASS** | yes | no | **yes** | none (in-house) | `hash.h` | +| `rapidhash` | 64 | **PASS** | yes | no | no | **MIT - ship NOTICE** | `hash_extra.h` + `:hash_extra_cc` | +| `xxh64` | 64 | FAIL | yes | **yes** | no | **BSD-2 - ship NOTICE** | `hash_extra.h` + `:hash_extra_cc` | +| `xxh3` | 64 | FAIL | yes | no | no | **BSD-2 - ship NOTICE** | `hash_extra.h` + `:hash_extra_cc` | +| `xxh3` | 128 | FAIL | yes | no | no | **BSD-2 - ship NOTICE** | `hash_extra.h` + `:hash_extra_cc` | Notes: the **Starlark** column marks the hashes that are also implemented in -Starlark for direct buil-graph construction time[`hash.bzl`](hash.bzl). Thos are -(`hash.mumbo`, `hash.dumbo`, `hash.fnv1a`) and they are byte-for-byte identical -to the C++ prime and verified against it (`hash_tool`). Only the one-shot 64-bit -form is ported, so the native 128-bit `jumbo` and streaming stay C++-only. +Starlark for direct buil-graph construction-time[`hash.bzl`](hash.bzl) usage. +Thos are algorithms are (`hash.mumbo`, `hash.dumbo`, `hash.fnv1a`). They are +byte-for-byte identical to the C++ prime and verified against it (`hash_tool`). +Only the one-shot 64-bit form is ported, so the native 128-bit `jumbo` and +streaming stay C++-only. Note the following algorithm specifics: @@ -602,14 +603,14 @@ numbers are directly comparable. | Algorithm | Bits | Role in mbo/hash | SMHasher3 result | Failures | | ----------- | ---: | ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dumbo` | 64 | `hash.h` (compact MUM) | PASS | none | +| `dumbo` | 64 | `hash.h` (compact MUM) | **PASS** | none | | `fnv1a` | 64 | `hash.h` | 7/186 | Avalanche [3, 4, 5, 6, 7, 8, 9, 10, 12, 16, 20, 64, 128], BIC [3, 8, 11, 15], Zeroes [], Cyclic [4 cycles of 3 bytes, 4 cycles of 4 bytes, 4 cycles of 5 bytes, 4 cycles of 8 bytes, 8 cycles of 3 bytes, 8 cycles of 4 bytes, 8 cycles of 5 bytes, 8 cycles of 8 bytes, 12 cycles of 3 bytes, 12 cycles of 4 bytes, 12 cycles of 5 bytes, 12 cycles of 8 bytes, 16 cycles of 3 bytes, 16 cycles of 4 bytes, 16 cycles of 5 bytes, 16 cycles of 8 bytes], Sparse [6/2, 4/3, 4/4, 4/5, 3/6, 3/7, 3/8, 3/9, 3/10, 3/12, 3/14, 10/2, 20/3, 9/4, 5/9, 4/14, 4/16, 3/32, 3/48, 3/64, 3/96, 2/128, 2/256, 2/512, 2/1024, 2/1280] | -| `mumbo` | 64 | default (64/32/streaming) | PASS | none | -| `rapidhash` | 64 | extra (`hash_extra_cc`) | PASS | none | -| `siphash` | 64 | `hash.h` (keyed PRF) | PASS | none | +| `mumbo` | 64 | default (64/32/streaming) | **PASS** | none | +| `rapidhash` | 64 | extra (`hash_extra_cc`) | **PASS** | none | +| `siphash` | 64 | `hash.h` (keyed PRF) | **PASS** | none | | `xxh3` | 64 | extra (`hash_extra_cc`) | 166/188 | BIC [3, 8, 11], Sparse [20/3], PerlinNoise [2], Bitflip [8], SeedZeroes [1280, 8448], SeedSparse [2, 3], SeedBlockLen [8, 13, 14, 15, 16], SeedBlockOffset [0, 1, 2, 3, 4], SeedBIC [3, 8] | | `xxh64` | 64 | extra (`hash_extra_cc`) | 181/188 | SeedBlockLen [15, 19, 21, 26, 29, 30], SeedBIC [8] | -| `jumbo` | 128 | default (128) | PASS | none | +| `jumbo` | 128 | default (128) | **PASS** | none | | `murmur3` | 128 | `hash.h` | 123/188 | BIC [3], Zeroes [] | | `xxh3` | 128 | extra (`hash_extra_cc`) | 162/188 | BIC [3, 8, 15], Sparse [20/3], PerlinNoise [2], Bitflip [3, 4, 8], SeedZeroes [1280, 8448], SeedSparse [2, 3], SeedBlockLen [8, 12, 13, 14, 15, 16], SeedBlockOffset [0, 1, 2, 3, 4, 5], SeedBIC [3, 8] | @@ -751,7 +752,7 @@ reads as the compact MUM hash rather than a second tuned one: # Re-render this README from the chosen bundles, then refresh the SMHasher3 # Results table from the same bundle's measured data. - mbo/hash/measurements/hash_benchmark_report.py publish --bundles data/.tgz - mbo/hash/measurements/hash_benchmark_report.py quality --smhasher data/.tgz + mbo/hash/measurements/hash_benchmark_report.py publish --bundles data/.tgz ... + mbo/hash/measurements/hash_benchmark_report.py quality data/.tgz git add mbo/hash/README.md mbo/hash/measurements/charts ``` diff --git a/mbo/hash/measurements/hash_benchmark_report.py b/mbo/hash/measurements/hash_benchmark_report.py index 494c963..269453a 100755 --- a/mbo/hash/measurements/hash_benchmark_report.py +++ b/mbo/hash/measurements/hash_benchmark_report.py @@ -927,7 +927,7 @@ def render_results_table(algorithms, measured): for algo in algorithms: entry = _measured_entry(algo["smhasher"], measured) if entry["verdict"] == "PASS": - result, failures = "PASS", "none" + result, failures = "**PASS**", "none" else: passed, total = entry.get("passed"), entry.get("total") if passed is None or total is None or passed >= total: @@ -938,20 +938,31 @@ def render_results_table(algorithms, measured): return _md_table(headers, rows, aligns) +def _bold_if(text, condition): + return f"**{text}**" if condition else text + + +def _bold_if_yes(text): + return f"**{text}**" if text.lower() == "yes" else text + + def render_overview_table(algorithms, measured, overview_order): """Render the "## Algorithm overview" table: manual columns from the JSON, the `SMHasher3` PASS/FAIL derived from the measured verdict (so it cannot disagree with the Results table). Rows follow the overview's own curated order.""" - headers = ["Algorithm", "Bits", "Available via", "Starlark", "NOTICE", "Seeded", "Streaming", "SMHasher3"] + headers = ["Algorithm", "Bits", "SMHasher3", "Seeded", "Streaming", "Starlark", "NOTICE", "Available via"] aligns = ["l", "r", "l", "l", "l", "l", "l", "l"] by_name = {algo["smhasher"]: algo for algo in algorithms} rows = [] for name in overview_order: algo = by_name[name] entry = _measured_entry(name, measured) + verdict = _bold_if(entry["verdict"], entry["verdict"] == "PASS") + starlark = _bold_if_yes(algo["starlark"]) + streaming = _bold_if_yes(algo["streaming"]) rows.append([ - f"`{algo['algo']}`", str(algo["bits"]), algo["available_via"], algo["starlark"], - algo["notice"], algo["seeded"], algo["streaming"], entry["verdict"], + f"`{algo['algo']}`", str(algo["bits"]), verdict, + algo["seeded"], streaming, starlark, algo["notice"], algo["available_via"], ]) return _md_table(headers, rows, aligns) @@ -1085,6 +1096,243 @@ def _add_dataset_arg(parser): parser.add_argument("--bundle", help="data bundle .tgz; same as passing it positionally") +def dispatch_help(_, parser): + parser.print_help() + return 0 + + +def dispatch_run(args, stamp): + raw = _run_benchmark(args.mode, args.reps, args.min_time, args.warmup, args.config) + if args.raw: + raw_path = _timestamped(args.raw, stamp) + opener = gzip.open if raw_path.endswith(".gz") else open + with opener(raw_path, "wt") as handle: + json.dump(raw, handle) + print(f"wrote {raw_path}", file=sys.stderr) + results = distill(raw, args.mode) + _warn_context(results) + if args.out: + out_path = _timestamped(args.out, stamp) + _dump_canonical(results, out_path) + print(f"wrote {out_path}", file=sys.stderr) + if args.tables or not args.out: + print(render_tables(results)) + return 0 + + +def dispatch_store(args, stamp): + results = distill(_load_json(args.raw), args.mode) + _warn_context(results) + out_path = _timestamped(args.out, stamp) + _dump_canonical(results, out_path) + print(f"wrote {out_path}", file=sys.stderr) + return 0 + + +def dispatch_tables(args, stamp): + print(render_tables(_resolve_dataset(args))) + return 0 + + +def dispatch_compare(args, stamp): + print(render_compare(_load_dataset(args.a), _load_dataset(args.b))) + return 0 + + +def dispatch_plot(args, stamp): + results = _resolve_dataset(args) + base, ext = os.path.splitext(_timestamped(args.out, stamp)) + linear_y = args.scale == "linear-log" + # `--kind` picks latency (ns-vs-length) and/or throughput (GiB/s-vs-bound) + # sections; one SVG per section, suffixed by its tag. + for section in _sections(results): + data = section["chart"] + if not data or (args.kind != "all" and section["kind"] != args.kind): + continue + y_label, x_label = ("GiB / s", "max length") if section["kind"] == "throughput" else ("ns / op", "key length") + _svg_plot( + data, _order(list(data), section["order"]), f"mbo/hash - {section['title']}", + f"{base}_{section['tag']}{ext}", section["relabel"], linear_y=linear_y, y_label=y_label, x_label=x_label, + ) + return 0 + + +def dispatch_smhasher(args, stamp): + algos = [a.strip() for a in args.algos.split(",") if a.strip()] + names = _resolve_smhasher_names(algos) + results = { + "context": {**_provenance_context(), "smhasher": {"names": names, "smhasher3": args.smhasher3}}, + "smhasher": run_smhasher(args.smhasher3, names, args.raw_dir, stamp, args.jobs), + } + _warn_context(results) + for name, entry in results["smhasher"].items(): + score = f" ({entry['score']})" if entry.get("score") else "" + if entry["verdict"] == "ERROR": + why = f" - {entry.get('error') or 'measurement error'}" + else: + why = f" - failed: {', '.join(entry['failures'])}" if entry["failures"] else "" + print(f" {name}: {entry['verdict']}{score}{why}") + errored = [n for n, r in results["smhasher"].items() if r["verdict"] == "ERROR"] + failed = [n for n, r in results["smhasher"].items() if r["verdict"] == "FAIL"] + passed = len(names) - len(failed) - len(errored) + tail = "".join( + f"; {label}: {', '.join(bad)}" for label, bad in (("ERROR", errored), ("FAIL", failed)) if bad + ) + print(f"SMHasher3: {passed}/{len(names)} PASS" + tail) + if args.out: + out_path = _timestamped(args.out, stamp) + _dump_canonical(results, out_path) + print(f"wrote {out_path}", file=sys.stderr) + return 1 if (failed or errored) else 0 + +def dispatch_bundle(args, stamp): + results = _load_json(args.results) + ctx = results.get("context", {}) + slug = _platform_slug(ctx) + cores = ctx.get("num_cpus", "?") + compiler = _slug(ctx.get("compiler") or "cc") + sha = ((ctx.get("source") or {}).get("git_sha") or "nogit")[:8] + # Flat: the filename already carries the full machine identity, so no subdir. + os.makedirs(args.data_dir, exist_ok=True) + dest = os.path.join(args.data_dir, f"{slug}_{cores}c_{compiler}_{sha}_{stamp}.tgz") + with tarfile.open(dest, "w:gz") as tar: + tar.add(args.results, arcname="results.json") # canonical: chart + tables + verify + for path in args.include: + if path and os.path.exists(path): + tar.add(path, arcname=os.path.basename(path)) + print(dest) # stdout: bundle path (for scripting) + print(f"wrote {dest}", file=sys.stderr) + return 0 + + +def dispatch_publish(args, stamp): + os.makedirs(args.charts_dir, exist_ok=True) + rel = os.path.relpath(args.charts_dir, os.path.dirname(os.path.abspath(args.readme))) + blocks = [] # (label, bundle_path, section) - sorted by label below, not by input order + for bundle_path in args.bundles: + full = _results_from_bundle(bundle_path) # re-distilled from the bundle's raw (see B) + ctx = full.get("context", {}) + label = _machine_label(ctx) + charts = dict(_render_charts(full, _bundle_stem(ctx), args.charts_dir, label)) # tag -> filename + # `### {label}` heads the block; each section is its chart (if any) + # immediately followed by its table, in layout order. + parts = [f"### {label}", "", f""] + for section in _sections(full): + if section["tag"] in charts: + parts += ["", f"![mbo/hash {section['title']}, {label}]({rel}/{charts[section['tag']]})"] + parts += ["", f"#### {section['heading']}", "", section["table"]] + blocks.append((label, bundle_path, "\n".join(parts))) + print(f"published {label}", file=sys.stderr) + # Order sections (and the manifest) by the generated header, so the README + # is stable regardless of the order bundles were passed on the command line. + blocks.sort(key=lambda block: block[0]) + manifest = "" + region = "\n".join([_PERF_BEGIN, manifest, "", "\n\n".join(s for _, _, s in blocks), _PERF_END]) + text = open(args.readme).read() + if _PERF_BEGIN not in text or _PERF_END not in text: + raise SystemExit(f"markers not found in {args.readme}; add a {_PERF_BEGIN} ... {_PERF_END} region") + text = text[: text.index(_PERF_BEGIN)] + region + text[text.index(_PERF_END) + len(_PERF_END) :] + with open(args.readme, "w") as handle: + handle.write(text) + print(f"wrote perf section into {args.readme} ({len(args.bundles)} machine(s))", file=sys.stderr) + return 0 + + +def dispatch_verify(args, stamp): + match = re.search(r"", open(args.readme).read()) + if not match: + raise SystemExit(f"no bundle manifest in {args.readme}; run `publish` first") + bundles = match.group(1).split() + mismatches = [] + for bundle_path in bundles: + full = _results_from_bundle(bundle_path) # re-distilled from the bundle's raw (see B) + ctx = full.get("context", {}) + with tempfile.TemporaryDirectory() as tmp: + for _, name in _render_charts(full, _bundle_stem(ctx), tmp, _machine_label(ctx)): + committed = os.path.join(args.charts_dir, name) + if not os.path.exists(committed) or not filecmp.cmp(os.path.join(tmp, name), committed, shallow=False): + mismatches.append(name) + if mismatches: + print(f"VERIFY FAILED: committed charts differ from the bundle data: {', '.join(sorted(set(mismatches)))}", file=sys.stderr) + return 1 + print(f"VERIFY OK: committed charts match all {len(bundles)} bundle(s)", file=sys.stderr) + return 0 + +def dispatch_quality(args, stamp): + algorithms, overview_order = _load_algorithms() + measured = _fill_missing_measured(_measured_from_bundle(_one_path([args.bundle_pos, args.bundle], "bundle"))) + text = open(args.readme).read() + regions = [ + (_OVERVIEW_BEGIN, _OVERVIEW_END, render_overview_table(algorithms, measured, overview_order)), + (_SMH_BEGIN, _SMH_END, render_results_table(algorithms, measured)), + ] + new = text + for begin, end, body in regions: + if begin not in new or end not in new: + raise SystemExit(f"markers not found in {args.readme}; add a {begin} ... {end} region") + block = "\n".join([begin, "", body, "", end]) + new = new[: new.index(begin)] + block + new[new.index(end) + len(end) :] + if args.check: + if new != text: + print(f"VERIFY FAILED: the generated tables in {args.readme} are stale; run `quality`", file=sys.stderr) + return 1 + print("VERIFY OK: the generated overview + Results tables match hash_algorithms.json + the bundle", file=sys.stderr) + return 0 + if new != text: + with open(args.readme, "w") as handle: + handle.write(new) + print(f"wrote the overview + Results tables into {args.readme}", file=sys.stderr) + else: + print(f"the generated tables are already current in {args.readme}", file=sys.stderr) + return 0 + + +def dispatch_consistency(args, stamp): + bundles = args.bundles or sorted( + os.path.join(args.data_dir, f) for f in os.listdir(args.data_dir) if f.endswith(".tgz") + ) + if not bundles: + raise SystemExit(f"no bundles found (looked in {args.data_dir})") + # Group by the source git SHA embedded in the bundle filename + # (_c___.tgz). SMHasher3 verdicts are a + # property of the algorithm, not the machine, so bundles at the same SHA + # must report identical measurements - a difference means a broken run. + by_sha = {} + for bundle in bundles: + match = re.search(r"_([0-9a-fA-F]{8})_\d{8}_\d{6}\.tgz$", os.path.basename(bundle)) + sha = match.group(1) if match else os.path.basename(bundle) + by_sha.setdefault(sha, []).append((bundle, _measured_from_bundle(bundle))) + + def _key(entry): + if entry is None: + return None + return (entry["verdict"], entry.get("passed"), entry.get("total"), tuple(entry.get("failures") or [])) + + problems = [] + for sha, group in sorted(by_sha.items()): + if len(group) < 2: + print(f"consistency: SHA {sha}: only 1 bundle, nothing to cross-check", file=sys.stderr) + continue + ref_bundle, ref = group[0] + names = sorted(set().union(*(set(meas) for _, meas in group))) + for bundle, meas in group[1:]: + for name in names: + if _key(ref.get(name)) != _key(meas.get(name)): + problems.append( + f"SHA {sha}: {name} differs between {os.path.basename(ref_bundle)} " + f"and {os.path.basename(bundle)}: {_key(ref.get(name))} vs {_key(meas.get(name))}" + ) + for problem in problems: + print(f"VERIFY FAILED: {problem}", file=sys.stderr) + if problems: + return 1 + print( + f"VERIFY OK: {len(bundles)} bundle(s) in {len(by_sha)} source-SHA group(s) agree on all SMHasher3 measurements", + file=sys.stderr, + ) + return 0 + + def main(argv): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) @@ -1177,239 +1425,25 @@ def main(argv): p_consistency.add_argument("--data-dir", default="mbo/hash/measurements/data") args = parser.parse_args(argv) + # One stamp per invocation, so all files a run writes share it. Every # written artifact is prefixed `YYYYMMDD_HHMMSS_` so nothing is overwritten # and the filename records when it was produced. stamp = _timestamp() - - if args.command == "help": - parser.print_help() - return 0 - - if args.command == "run": - raw = _run_benchmark(args.mode, args.reps, args.min_time, args.warmup, args.config) - if args.raw: - raw_path = _timestamped(args.raw, stamp) - opener = gzip.open if raw_path.endswith(".gz") else open - with opener(raw_path, "wt") as handle: - json.dump(raw, handle) - print(f"wrote {raw_path}", file=sys.stderr) - results = distill(raw, args.mode) - _warn_context(results) - if args.out: - out_path = _timestamped(args.out, stamp) - _dump_canonical(results, out_path) - print(f"wrote {out_path}", file=sys.stderr) - if args.tables or not args.out: - print(render_tables(results)) - return 0 - - if args.command == "store": - results = distill(_load_json(args.raw), args.mode) - _warn_context(results) - out_path = _timestamped(args.out, stamp) - _dump_canonical(results, out_path) - print(f"wrote {out_path}", file=sys.stderr) - return 0 - - if args.command == "tables": - print(render_tables(_resolve_dataset(args))) - return 0 - - if args.command == "compare": - print(render_compare(_load_dataset(args.a), _load_dataset(args.b))) - return 0 - - if args.command == "plot": - results = _resolve_dataset(args) - base, ext = os.path.splitext(_timestamped(args.out, stamp)) - linear_y = args.scale == "linear-log" - # `--kind` picks latency (ns-vs-length) and/or throughput (GiB/s-vs-bound) - # sections; one SVG per section, suffixed by its tag. - for section in _sections(results): - data = section["chart"] - if not data or (args.kind != "all" and section["kind"] != args.kind): - continue - y_label, x_label = ("GiB / s", "max length") if section["kind"] == "throughput" else ("ns / op", "key length") - _svg_plot( - data, _order(list(data), section["order"]), f"mbo/hash - {section['title']}", - f"{base}_{section['tag']}{ext}", section["relabel"], linear_y=linear_y, y_label=y_label, x_label=x_label, - ) - return 0 - - if args.command == "smhasher": - algos = [a.strip() for a in args.algos.split(",") if a.strip()] - names = _resolve_smhasher_names(algos) - results = { - "context": {**_provenance_context(), "smhasher": {"names": names, "smhasher3": args.smhasher3}}, - "smhasher": run_smhasher(args.smhasher3, names, args.raw_dir, stamp, args.jobs), - } - _warn_context(results) - for name, entry in results["smhasher"].items(): - score = f" ({entry['score']})" if entry.get("score") else "" - if entry["verdict"] == "ERROR": - why = f" - {entry.get('error') or 'measurement error'}" - else: - why = f" - failed: {', '.join(entry['failures'])}" if entry["failures"] else "" - print(f" {name}: {entry['verdict']}{score}{why}") - errored = [n for n, r in results["smhasher"].items() if r["verdict"] == "ERROR"] - failed = [n for n, r in results["smhasher"].items() if r["verdict"] == "FAIL"] - passed = len(names) - len(failed) - len(errored) - tail = "".join( - f"; {label}: {', '.join(bad)}" for label, bad in (("ERROR", errored), ("FAIL", failed)) if bad - ) - print(f"SMHasher3: {passed}/{len(names)} PASS" + tail) - if args.out: - out_path = _timestamped(args.out, stamp) - _dump_canonical(results, out_path) - print(f"wrote {out_path}", file=sys.stderr) - return 1 if (failed or errored) else 0 - - if args.command == "bundle": - results = _load_json(args.results) - ctx = results.get("context", {}) - slug = _platform_slug(ctx) - cores = ctx.get("num_cpus", "?") - compiler = _slug(ctx.get("compiler") or "cc") - sha = ((ctx.get("source") or {}).get("git_sha") or "nogit")[:8] - # Flat: the filename already carries the full machine identity, so no subdir. - os.makedirs(args.data_dir, exist_ok=True) - dest = os.path.join(args.data_dir, f"{slug}_{cores}c_{compiler}_{sha}_{stamp}.tgz") - with tarfile.open(dest, "w:gz") as tar: - tar.add(args.results, arcname="results.json") # canonical: chart + tables + verify - for path in args.include: - if path and os.path.exists(path): - tar.add(path, arcname=os.path.basename(path)) - print(dest) # stdout: bundle path (for scripting) - print(f"wrote {dest}", file=sys.stderr) - return 0 - - if args.command == "publish": - os.makedirs(args.charts_dir, exist_ok=True) - rel = os.path.relpath(args.charts_dir, os.path.dirname(os.path.abspath(args.readme))) - blocks = [] # (label, bundle_path, section) - sorted by label below, not by input order - for bundle_path in args.bundles: - full = _results_from_bundle(bundle_path) # re-distilled from the bundle's raw (see B) - ctx = full.get("context", {}) - label = _machine_label(ctx) - charts = dict(_render_charts(full, _bundle_stem(ctx), args.charts_dir, label)) # tag -> filename - # `### {label}` heads the block; each section is its chart (if any) - # immediately followed by its table, in layout order. - parts = [f"### {label}", "", f""] - for section in _sections(full): - if section["tag"] in charts: - parts += ["", f"![mbo/hash {section['title']}, {label}]({rel}/{charts[section['tag']]})"] - parts += ["", f"#### {section['heading']}", "", section["table"]] - blocks.append((label, bundle_path, "\n".join(parts))) - print(f"published {label}", file=sys.stderr) - # Order sections (and the manifest) by the generated header, so the README - # is stable regardless of the order bundles were passed on the command line. - blocks.sort(key=lambda block: block[0]) - manifest = "" - region = "\n".join([_PERF_BEGIN, manifest, "", "\n\n".join(s for _, _, s in blocks), _PERF_END]) - text = open(args.readme).read() - if _PERF_BEGIN not in text or _PERF_END not in text: - raise SystemExit(f"markers not found in {args.readme}; add a {_PERF_BEGIN} ... {_PERF_END} region") - text = text[: text.index(_PERF_BEGIN)] + region + text[text.index(_PERF_END) + len(_PERF_END) :] - with open(args.readme, "w") as handle: - handle.write(text) - print(f"wrote perf section into {args.readme} ({len(args.bundles)} machine(s))", file=sys.stderr) - return 0 - - if args.command == "verify": - match = re.search(r"", open(args.readme).read()) - if not match: - raise SystemExit(f"no bundle manifest in {args.readme}; run `publish` first") - bundles = match.group(1).split() - mismatches = [] - for bundle_path in bundles: - full = _results_from_bundle(bundle_path) # re-distilled from the bundle's raw (see B) - ctx = full.get("context", {}) - with tempfile.TemporaryDirectory() as tmp: - for _, name in _render_charts(full, _bundle_stem(ctx), tmp, _machine_label(ctx)): - committed = os.path.join(args.charts_dir, name) - if not os.path.exists(committed) or not filecmp.cmp(os.path.join(tmp, name), committed, shallow=False): - mismatches.append(name) - if mismatches: - print(f"VERIFY FAILED: committed charts differ from the bundle data: {', '.join(sorted(set(mismatches)))}", file=sys.stderr) - return 1 - print(f"VERIFY OK: committed charts match all {len(bundles)} bundle(s)", file=sys.stderr) - return 0 - - if args.command == "quality": - algorithms, overview_order = _load_algorithms() - measured = _fill_missing_measured(_measured_from_bundle(_one_path([args.bundle_pos, args.bundle], "bundle"))) - text = open(args.readme).read() - regions = [ - (_OVERVIEW_BEGIN, _OVERVIEW_END, render_overview_table(algorithms, measured, overview_order)), - (_SMH_BEGIN, _SMH_END, render_results_table(algorithms, measured)), - ] - new = text - for begin, end, body in regions: - if begin not in new or end not in new: - raise SystemExit(f"markers not found in {args.readme}; add a {begin} ... {end} region") - block = "\n".join([begin, "", body, "", end]) - new = new[: new.index(begin)] + block + new[new.index(end) + len(end) :] - if args.check: - if new != text: - print(f"VERIFY FAILED: the generated tables in {args.readme} are stale; run `quality`", file=sys.stderr) - return 1 - print("VERIFY OK: the generated overview + Results tables match hash_algorithms.json + the bundle", file=sys.stderr) - return 0 - if new != text: - with open(args.readme, "w") as handle: - handle.write(new) - print(f"wrote the overview + Results tables into {args.readme}", file=sys.stderr) - else: - print(f"the generated tables are already current in {args.readme}", file=sys.stderr) - return 0 - - if args.command == "consistency": - bundles = args.bundles or sorted( - os.path.join(args.data_dir, f) for f in os.listdir(args.data_dir) if f.endswith(".tgz") - ) - if not bundles: - raise SystemExit(f"no bundles found (looked in {args.data_dir})") - # Group by the source git SHA embedded in the bundle filename - # (_c___.tgz). SMHasher3 verdicts are a - # property of the algorithm, not the machine, so bundles at the same SHA - # must report identical measurements - a difference means a broken run. - by_sha = {} - for bundle in bundles: - match = re.search(r"_([0-9a-fA-F]{8})_\d{8}_\d{6}\.tgz$", os.path.basename(bundle)) - sha = match.group(1) if match else os.path.basename(bundle) - by_sha.setdefault(sha, []).append((bundle, _measured_from_bundle(bundle))) - - def _key(entry): - if entry is None: - return None - return (entry["verdict"], entry.get("passed"), entry.get("total"), tuple(entry.get("failures") or [])) - - problems = [] - for sha, group in sorted(by_sha.items()): - if len(group) < 2: - print(f"consistency: SHA {sha}: only 1 bundle, nothing to cross-check", file=sys.stderr) - continue - ref_bundle, ref = group[0] - names = sorted(set().union(*(set(meas) for _, meas in group))) - for bundle, meas in group[1:]: - for name in names: - if _key(ref.get(name)) != _key(meas.get(name)): - problems.append( - f"SHA {sha}: {name} differs between {os.path.basename(ref_bundle)} " - f"and {os.path.basename(bundle)}: {_key(ref.get(name))} vs {_key(meas.get(name))}" - ) - for problem in problems: - print(f"VERIFY FAILED: {problem}", file=sys.stderr) - if problems: - return 1 - print( - f"VERIFY OK: {len(bundles)} bundle(s) in {len(by_sha)} source-SHA group(s) agree on all SMHasher3 measurements", - file=sys.stderr, - ) - return 0 - - return 1 + return { + "help": dispatch_help, + "run": dispatch_run, + "store": dispatch_store, + "tables": dispatch_tables, + "compare": dispatch_compare, + "plot": dispatch_plot, + "smhasher": dispatch_smhasher, + "bundle": dispatch_bundle, + "publish": dispatch_publish, + "verify": dispatch_verify, + "quality": dispatch_quality, + "consistency": dispatch_consistency, + }.get(args.command, dispatch_help)(args, stamp) if __name__ == "__main__":