DatasetAnalytics.java

package org.hammer.audio.experimental.acoustic.dataset;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

/**
 * Summary analytics computed from a {@link DatasetManifest}.
 *
 * <p>This class computes descriptive statistics over the recordings in a dataset, including label
 * distribution, sample-rate distribution, and duration distribution. Use {@link
 * #compute(DatasetManifest)} to build an analytics snapshot, and {@link #toMarkdownReport()} to
 * render a human-readable summary.
 *
 * @param datasetName the name of the dataset
 * @param recordingCount total number of recordings in the manifest
 * @param labelDistribution count of recordings per unique value of each metadata label key
 * @param sampleRateCounts count of recordings per distinct sample rate (Hz, rounded to integer)
 * @param durationStats descriptive statistics over clip durations (seconds)
 */
@SuppressWarnings({"PMD.UseConcurrentHashMap", "PMD.DanglingJavadoc"})
public record DatasetAnalytics(
    String datasetName,
    int recordingCount,
    Map<String, Map<String, Integer>> labelDistribution,
    Map<Integer, Integer> sampleRateCounts,
    DistributionStats durationStats) {

  /** Validate and defensively copy all fields. */
  public DatasetAnalytics {
    Objects.requireNonNull(datasetName, "datasetName");
    Objects.requireNonNull(labelDistribution, "labelDistribution");
    Objects.requireNonNull(sampleRateCounts, "sampleRateCounts");
    Objects.requireNonNull(durationStats, "durationStats");
    if (datasetName.isBlank()) {
      throw new IllegalArgumentException("datasetName must not be blank");
    }
    if (recordingCount < 0) {
      throw new IllegalArgumentException("recordingCount must be >= 0");
    }
    Map<String, Map<String, Integer>> labelCopy = new LinkedHashMap<>();
    for (Map.Entry<String, Map<String, Integer>> entry : labelDistribution.entrySet()) {
      labelCopy.put(entry.getKey(), Map.copyOf(entry.getValue()));
    }
    labelDistribution = Map.copyOf(labelCopy);
    sampleRateCounts = Map.copyOf(sampleRateCounts);
  }

  /**
   * Compute analytics from the given manifest.
   *
   * @param manifest imported dataset manifest; must not be {@code null}
   * @return analytics snapshot; never {@code null}
   */
  public static DatasetAnalytics compute(DatasetManifest manifest) {
    Objects.requireNonNull(manifest, "manifest");
    List<DatasetRecording> recordings = manifest.recordings();
    Map<String, Map<String, Integer>> labelDist = new LinkedHashMap<>();
    Map<Integer, Integer> sampleRateCounts = new LinkedHashMap<>();
    List<Double> durations = new ArrayList<>(recordings.size());
    for (DatasetRecording rec : recordings) {
      for (Map.Entry<String, String> label : rec.labels().entrySet()) {
        labelDist
            .computeIfAbsent(label.getKey(), k -> new LinkedHashMap<>())
            .merge(label.getValue(), 1, Integer::sum);
      }
      int srBucket = (int) Math.round(rec.sampleRateHz());
      sampleRateCounts.merge(srBucket, 1, Integer::sum);
      durations.add(rec.durationSeconds());
    }
    DistributionStats durationStats = DistributionStats.of(durations);
    return new DatasetAnalytics(
        manifest.descriptor().name(),
        recordings.size(),
        labelDist,
        sampleRateCounts,
        durationStats);
  }

  /**
   * Render a compact Markdown summary of this analytics snapshot.
   *
   * @return Markdown text; never {@code null}
   */
  @SuppressWarnings({"PMD.ConsecutiveAppendsShouldReuse", "PMD.ConsecutiveLiteralAppends"})
  public String toMarkdownReport() {
    StringBuilder sb = new StringBuilder(512);
    sb.append("# Dataset Analytics\n\n");
    sb.append("- Dataset: ").append(datasetName).append('\n');
    sb.append("- Recordings: ").append(recordingCount).append('\n');
    sb.append('\n');

    sb.append("## Duration Distribution (seconds)\n\n");
    sb.append(durationStats.toMarkdown()).append('\n');

    if (!sampleRateCounts.isEmpty()) {
      sb.append("## Sample Rate Distribution\n\n");
      sb.append("| Sample rate (Hz) | Count |\n|---:|---:|\n");
      for (Map.Entry<Integer, Integer> entry : sampleRateCounts.entrySet()) {
        sb.append("| ")
            .append(entry.getKey())
            .append(" | ")
            .append(entry.getValue())
            .append(" |\n");
      }
      sb.append('\n');
    }

    if (!labelDistribution.isEmpty()) {
      sb.append("## Label Distribution\n\n");
      for (Map.Entry<String, Map<String, Integer>> labelEntry : labelDistribution.entrySet()) {
        sb.append("### ").append(labelEntry.getKey()).append("\n\n");
        sb.append("| Value | Count |\n|---|---:|\n");
        for (Map.Entry<String, Integer> valueEntry : labelEntry.getValue().entrySet()) {
          sb.append("| ")
              .append(valueEntry.getKey())
              .append(" | ")
              .append(valueEntry.getValue())
              .append(" |\n");
        }
        sb.append('\n');
      }
    }

    return sb.toString();
  }

  /**
   * Descriptive statistics over a series of {@code double} values.
   *
   * @param count number of values
   * @param min minimum value
   * @param max maximum value
   * @param mean arithmetic mean
   * @param stddev population standard deviation
   */
  public record DistributionStats(int count, double min, double max, double mean, double stddev) {

    /**
     * Compute stats over a list of doubles. Returns a zero-valued instance when the list is empty.
     */
    public static DistributionStats of(List<Double> values) {
      Objects.requireNonNull(values, "values");
      if (values.isEmpty()) {
        return new DistributionStats(0, 0.0, 0.0, 0.0, 0.0);
      }
      double min = values.get(0);
      double max = values.get(0);
      double sum = 0.0;
      for (double v : values) {
        if (v < min) {
          min = v;
        }
        if (v > max) {
          max = v;
        }
        sum += v;
      }
      double mean = sum / values.size();
      double variance = 0.0;
      for (double v : values) {
        double diff = v - mean;
        variance += diff * diff;
      }
      double stddev = Math.sqrt(variance / values.size());
      return new DistributionStats(values.size(), min, max, mean, stddev);
    }

    /** Render this distribution as a compact Markdown snippet (bullet list). */
    public String toMarkdown() {
      if (count == 0) {
        return "- No data\n";
      }
      return String.format(
          Locale.ROOT,
          "- Count: %d%n- Min: %.3f%n- Max: %.3f%n- Mean: %.3f%n- Std dev: %.3f%n",
          count,
          min,
          max,
          mean,
          stddev);
    }
  }
}