Skip to content

Entropy Visualizer — Ruby source

Visualize the randomness quality of any data. See Shannon entropy, byte frequency distribution, chi-squared score, and a visual entropy heatmap.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Entropy Visualizer — pure byte-level randomness analysis. No DOM, no deps.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Entropy Visualizer tool,
#           ported from src/lib/entropy-visualizer.ts (the canonical
#           TypeScript implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Everything is deterministic: the same bytes always produce the same
# numbers. Input is a binary String (or anything whose #bytes are analyzed
# via each_byte).

module EntropyVisualizer
  EntropyAnalysis = Struct.new(
    :shannon_entropy, :chi_squared, :chi_squared_p_value,
    :serial_correlation, :monte_carlo_pi, :byte_frequencies,
    :block_entropies, :verdict, keyword_init: true
  )

  # Bytes per heatmap block.
  BLOCK_SIZE = 16

  # Degrees of freedom for the byte-frequency chi-squared test
  # (256 bins - 1 constraint).
  CHI_SQUARED_DF = 255

  class << self
    # Count occurrences of each byte value 0-255.
    def count_bytes(data)
      freq = Array.new(256, 0)
      data.each_byte { |b| freq[b] += 1 }
      freq
    end

    # Shannon entropy H = -sum p(x)*log2(p(x)) in bits per byte, computed
    # from a frequency histogram. Zero-count bins contribute nothing.
    # +total+ must be the sum of +frequencies+.
    def shannon_bits_per_byte(frequencies, total)
      return 0 if total <= 0

      h = 0.0
      frequencies.each do |count|
        next if count.zero?

        p = count.to_f / total
        h -= p * Math.log2(p)
      end
      h
    end

    # Pearson chi-squared comparing observed byte counts against a uniform
    # expectation E = total/256 per bin.
    def chi_squared_statistic(frequencies, total)
      raise ArgumentError, 'chi-squared needs a positive sample size' if total <= 0

      expected = total / 256.0
      chi2 = 0.0
      frequencies.each do |observed|
        diff = observed - expected
        chi2 += (diff * diff) / expected
      end
      chi2
    end

    # Regularized upper incomplete gamma function Q(a, x) = G(a,x)/G(a), via
    # the power series (x < a+1) or the Lentz continued fraction (otherwise).
    # Numerical Recipes §6.2.
    def gamma_q(a, x)
      return Float::NAN if a <= 0 || x < 0
      return 1.0 if x.zero?

      if x < a + 1
        # Series for P(a,x); Q = 1 - P
        ap = a.to_f
        sum = 1.0 / a
        del = sum
        1000.times do
          ap += 1
          del *= x / ap
          sum += del
          break if del.abs < sum.abs * 1e-15
        end
        (1 - sum * Math.exp(-x + a * Math.log(x) - ln_gamma(a))).clamp(0.0, 1.0)
      else
        # Continued fraction for Q(a,x)
        fpmin = 1e-300
        b = x + 1 - a
        c = 1.0 / fpmin
        d = 1.0 / b
        h = d
        (1..1000).each do |i|
          an = -i * (i - a)
          b += 2
          d = an * d + b
          d = fpmin if d.abs < fpmin
          c = b + an / c
          c = fpmin if c.abs < fpmin
          d = 1.0 / d
          del = d * c
          h *= del
          break if (del - 1).abs < 1e-15
        end
        (Math.exp(-x + a * Math.log(x) - ln_gamma(a)) * h).clamp(0.0, 1.0)
      end
    end

    # Upper-tail p-value for a chi-squared statistic with +df+ degrees of
    # freedom.
    def chi_squared_p(chi2, df = CHI_SQUARED_DF)
      return Float::NAN if chi2 < 0 || df <= 0

      gamma_q(df / 2.0, chi2 / 2.0)
    end

    # Circular serial correlation between consecutive bytes (the `ent`
    # tool's metric): scc = (sum(xy) - (sum(x))^2/n) / (sum(x^2) -
    # (sum(x))^2/n) over the pair sequence (x0,x1), (x1,x2), ...,
    # (x(n-1),x0). 0 = uncorrelated, +/-1 = perfectly (anti)correlated.
    # Constant input has a zero denominator -> reported as 0 (nothing to
    # correlate); inputs shorter than 2 bytes are also 0.
    def serial_correlation_coefficient(data)
      bytes = data.bytes
      n = bytes.length
      return 0.0 if n < 2

      sum = 0
      sum_sq = 0
      sum_xy = 0
      bytes.each_with_index do |x, i|
        y = bytes[(i + 1) % n]
        sum += x
        sum_sq += x * x
        sum_xy += x * y
      end
      mean_sq = (sum * sum).to_f / n
      denom = sum_sq - mean_sq
      return 0.0 if denom.zero?

      (sum_xy - mean_sq) / denom
    end

    # Monte Carlo pi estimate: consecutive byte pairs are (x, y) points in a
    # 256x256 square; the fraction inside the inscribed circle (center
    # 127.5, radius 128) times 4 estimates pi. Inputs with fewer than 2
    # bytes -> 0.
    def monte_carlo_pi_estimate(data)
      bytes = data.bytes
      pairs = bytes.length / 2
      return 0.0 if pairs.zero?

      inside = 0
      pairs.times do |i|
        dx = bytes[2 * i] - 127.5
        dy = bytes[2 * i + 1] - 127.5
        inside += 1 if dx * dx + dy * dy <= 128 * 128
      end
      (4.0 * inside) / pairs
    end

    # Shannon entropy (bits/byte) of each consecutive +block_size+-byte
    # block.
    def block_entropies(data, block_size = BLOCK_SIZE)
      raise ArgumentError, 'blockSize must be at least 1' if block_size < 1

      blocks = []
      data.bytes.each_slice(block_size) do |chunk|
        counts = Array.new(256, 0)
        chunk.each { |b| counts[b] += 1 }
        blocks << shannon_bits_per_byte(counts, chunk.length)
      end
      blocks
    end

    # Map a Shannon entropy (bits/byte) to the verdict scale.
    def verdict_from_shannon(bits_per_byte)
      return 'excellent' if bits_per_byte > 7.5
      return 'good' if bits_per_byte > 6.0
      return 'suspicious' if bits_per_byte > 4.0

      'low'
    end

    # Full analysis of a byte sequence. Raises on empty input — there is
    # nothing to measure and every metric would be undefined.
    def analyze_entropy(data)
      if data.bytesize.zero?
        raise ArgumentError, 'Nothing to analyze - provide at least 1 byte of data.'
      end

      total = data.bytesize
      freq = count_bytes(data)
      shannon = shannon_bits_per_byte(freq, total)
      chi2 = chi_squared_statistic(freq, total)
      EntropyAnalysis.new(
        shannon_entropy: shannon,
        chi_squared: chi2,
        chi_squared_p_value: chi_squared_p(chi2),
        serial_correlation: serial_correlation_coefficient(data),
        monte_carlo_pi: monte_carlo_pi_estimate(data),
        byte_frequencies: freq,
        block_entropies: block_entropies(data),
        verdict: verdict_from_shannon(shannon)
      )
    end

    private

    # Lanczos approximation (g=7, 9 coefficients) of ln Gamma(x).
    def ln_gamma(x)
      g = [
        0.99999999999980993, 676.5203681218851, -1259.1392167224028,
        771.32342877765313, -176.61502916214059, 12.507343278686905,
        -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7
      ]
      if x < 0.5
        # Reflection formula: Gamma(x)*Gamma(1-x) = pi / sin(pi*x)
        return Math.log(Math::PI / Math.sin(Math::PI * x)) - ln_gamma(1 - x)
      end

      x -= 1
      a = g[0]
      t = x + 7.5
      (1..8).each { |i| a += g[i] / (x + i) }
      0.5 * Math.log(2 * Math::PI) + (x + 0.5) * Math.log(t) - t + Math.log(a)
    end
  end
end

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →