Generating Audit Hashes for Transformation Batches
A cadastral deliverable is only tamper-evident if any change to its coordinates or transformation parameters is provably detectable, which is the integrity role inside compliance report generation for agency submission and, more broadly, of Batch Transformation & Automation for Cadastral Coordinate Pipelines. This page builds the deterministic SHA-256 audit hash that makes a transformed batch both tamper-evident and reproducible: a single digest computed over a canonical serialisation of the inputs, the transformation parameters, and the outputs, such that any independent party re-running the same three ingredients arrives at the identical 64-character hex string.
Figure — inputs, parameters, and outputs enter one hasher in a fixed order and canonical form to yield a reproducible digest.
Why Canonicalisation, Not Just Hashing
SHA-256 is deterministic by construction: identical bytes always yield the identical digest. The difficulty is never the hash function — it is guaranteeing that “the same batch” produces the same bytes on a different machine, on a different day, under a different NumPy build. Two obstacles dominate. First, the textual representation of a float is not unique: repr(0.1) and a rounded string of the same value differ byte-for-byte, and JSON serialisers do not agree on float formatting. Second, a NumPy array’s in-memory bytes depend on its dtype and the machine’s endianness, so arr.tobytes() on a little-endian x86 host differs from the same logical array on a big-endian host.
Canonicalisation removes both degrees of freedom. Coordinate arrays are cast to an explicit little-endian float64 dtype (<f8) before .tobytes(), fixing width and byte order regardless of platform. Parameters are serialised as JSON with sorted keys and compact separators, and any float parameter is formatted with a fixed precision so its textual form is stable. The resulting byte stream is a canonical form: a well-defined function of the batch’s meaning, not of its incidental encoding. Only then does hashing become reproducible in the sense an auditor needs.
Complete Runnable Implementation
The module below hashes a batch from three ingredients. It exposes a small canonicaliser for arrays, a fixed-precision float formatter for scalar parameters, and the top-level hash_batch that assembles them in a fixed order.
from __future__ import annotations
import hashlib
import json
from typing import Any
import numpy as np
def canonical_array_bytes(arr: np.ndarray) -> bytes:
"""Return platform-stable bytes for a coordinate array.
The array is made contiguous and cast to little-endian float64 (`<f8`)
so its byte image is identical on any architecture. Casting also
normalises int or float32 inputs to one canonical width.
"""
canonical = np.ascontiguousarray(arr, dtype="<f8")
return canonical.tobytes()
def canonical_params(params: dict[str, Any], float_ndigits: int = 9) -> bytes:
"""Serialise transformation parameters to a canonical UTF-8 byte string.
Keys are sorted, separators are compact, and every float is rounded to a
fixed number of decimal places so its textual form cannot drift between
platforms or serialiser versions.
"""
def _normalise(value: Any) -> Any:
if isinstance(value, float):
return round(value, float_ndigits)
if isinstance(value, dict):
return {k: _normalise(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalise(v) for v in value]
return value
normalised = _normalise(params)
text = json.dumps(normalised, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
return text.encode("utf-8")
def hash_batch(
inputs: np.ndarray,
outputs: np.ndarray,
params: dict[str, Any],
float_ndigits: int = 9,
) -> str:
"""Deterministic SHA-256 over canonical inputs + parameters + outputs.
The three ingredients are fed in a fixed order with a length-prefixed
domain separator between them, so a value cannot migrate across the
boundary and forge a colliding digest. No timestamp is included, so the
hash is reproducible from the coordinates and parameters alone.
"""
h = hashlib.sha256()
for label, chunk in (
(b"inputs", canonical_array_bytes(inputs)),
(b"params", canonical_params(params, float_ndigits)),
(b"outputs", canonical_array_bytes(outputs)),
):
h.update(label)
h.update(len(chunk).to_bytes(8, "big")) # length prefix = domain sep
h.update(chunk)
return h.hexdigest()
Parameter Reference
| Name | Type | Units | Valid range | Notes |
|---|---|---|---|---|
inputs |
np.ndarray |
coordinate units | any shape | source coordinates; cast to <f8 before hashing |
outputs |
np.ndarray |
coordinate units | matching batch | transformed coordinates; canonicalised identically |
params |
dict[str, Any] |
— | JSON-serialisable | operation code, grid, method — the reproducibility metadata |
float_ndigits |
int |
decimal places | 6–12 typical | fixed float precision; 9 dp ≈ sub-mm at metre scale |
| return | str |
— | 64 hex chars | the audit digest; recompute to detect any change |
Worked Example
Hashing a three-point batch twice — with the arrays rebuilt from scratch — yields the identical digest, which is the whole point of the exercise.
inputs = np.array([[500000.0, 5000000.0],
[500100.0, 5000100.0],
[500200.0, 5000200.0]])
outputs = np.array([[500000.006, 5000000.004],
[500100.005, 5000100.007],
[500200.008, 5000200.003]])
params = {"operation_code": "EPSG:1888", "method": "NTv2",
"grid_file": "us_noaa_nadcon5.tif", "grid_version": "2021.1"}
digest = hash_batch(inputs, outputs, params)
print(len(digest), digest[:12]) # 64 <first 12 hex chars>
# Rebuild the arrays independently; the digest is byte-identical.
again = hash_batch(inputs.copy(), outputs.copy(), dict(params))
print(digest == again) # True
Validation Check
Assert both properties an audit hash must have: reproducibility across independent reconstructions, and sensitivity to a sub-millimetre change in any output coordinate.
assert hash_batch(inputs, outputs, params) == digest, "hash not reproducible"
tampered = outputs.copy()
tampered[1, 0] += 0.0005 # a 0.5 mm edit to one easting
assert hash_batch(inputs, tampered, params) != digest, "tamper went undetected"
Common Mistakes
Hashing pretty-printed or default JSON
json.dumps without sort_keys=True lets key order — and therefore the bytes — depend on dict insertion order, and default separators add whitespace that varies. Two logically identical parameter sets then hash differently. Always sort keys and use compact separators for the canonical form.Relying on float repr instead of fixed formatting
float leans on the platform's shortest-repr algorithm, which can differ across interpreter versions and locales. Round every float parameter to a fixed number of decimals (here float_ndigits) before serialising so its textual form is pinned.Ignoring array dtype and endianness
arr.tobytes() on the native array hashes whatever width and byte order the array happens to hold — float32 versus float64, little- versus big-endian — so the digest is not portable. Cast to an explicit <f8 dtype first; that fixes both width and endianness on every platform.Baking a timestamp into the hashed stream
datetime.now() in the digest makes every run produce a different hash, destroying reproducibility — an auditor can never re-derive it. Record the timestamp beside the digest, never inside it; the hash must be a function of the coordinates and parameters alone.Related
- Compliance report generation for agency submission — the parent report this digest makes tamper-evident.
- RMSE-to-agency-submission workflow in Python — the accuracy statistic that sits beside the hash in the report.
- Exporting ISO 19111 metadata for cadastral deliverables — the parameters this hash canonicalises come from the operation metadata.
- Batch Transformation & Automation for Cadastral Coordinate Pipelines — the parent reference on automating and certifying batch transformations.