ExperimentDocument.java

package org.hammer.audio.experiment.document;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.hammer.audio.plugin.document.DocumentValue;

/**
 * Immutable portable experiment setup and reproducibility contract.
 *
 * @param schema schema identifier
 * @param format stable document format identifier
 * @param formatVersion outer envelope version
 * @param experiment core experiment metadata
 * @param workflow canonical workflow payload
 * @param profiles portable core profile data
 * @param requiredPlugins required plugin declarations
 * @param pluginData namespaced plugin sections
 * @param assets bounded external asset references
 * @param outputs requested logical outputs
 * @param provenance creation and migration provenance
 */
@SuppressWarnings("PMD.LooseCoupling")
public record ExperimentDocument(
    String schema,
    String format,
    int formatVersion,
    ExperimentInfo experiment,
    WorkflowPayload workflow,
    DocumentValue.ObjectValue profiles,
    List<PluginRequirement> requiredPlugins,
    Map<String, Map<String, PluginSection>> pluginData,
    List<AssetReference> assets,
    List<OutputRequest> outputs,
    Provenance provenance) {

  /* Validate and defensively copy all document sections. */
  public ExperimentDocument {
    schema = requireNonBlank(schema, "schema");
    format = requireNonBlank(format, "format");
    if (formatVersion < 1) {
      throw new IllegalArgumentException("formatVersion must be positive");
    }
    Objects.requireNonNull(experiment, "experiment");
    Objects.requireNonNull(workflow, "workflow");
    Objects.requireNonNull(profiles, "profiles");
    requiredPlugins = List.copyOf(Objects.requireNonNull(requiredPlugins, "requiredPlugins"));
    pluginData = immutablePluginData(pluginData);
    assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
    outputs = List.copyOf(Objects.requireNonNull(outputs, "outputs"));
    Objects.requireNonNull(provenance, "provenance");
  }

  /** Return a fresh deeply immutable copy of plugin-owned sections. */
  @Override
  public Map<String, Map<String, PluginSection>> pluginData() {
    return immutablePluginData(pluginData);
  }

  /**
   * Core human-facing experiment metadata.
   *
   * @param id stable experiment id
   * @param name human-readable name
   * @param description experiment description
   * @param tags immutable tags
   * @param intent experiment lifecycle intent/type
   * @param sourceMode portable source mode
   * @param intendedDuration optional intended duration
   * @param applicationVersion creating application version
   */
  public record ExperimentInfo(
      String id,
      String name,
      String description,
      List<String> tags,
      String intent,
      String sourceMode,
      Duration intendedDuration,
      String applicationVersion) {

    /* Validate experiment metadata. */
    public ExperimentInfo {
      id = requireIdentifier(id, "experiment id");
      name = requireNonBlank(name, "experiment name");
      description = description == null ? "" : description;
      tags = normalizedStrings(tags, "tag");
      intent = requireIdentifier(intent, "experiment intent");
      sourceMode = requireIdentifier(sourceMode, "source mode");
      if (intendedDuration != null
          && (intendedDuration.isNegative() || intendedDuration.isZero())) {
        throw new IllegalArgumentException("intendedDuration must be positive");
      }
      applicationVersion = requireNonBlank(applicationVersion, "applicationVersion");
    }

    /** Return a fresh immutable copy of the experiment tags. */
    @Override
    public List<String> tags() {
      return List.copyOf(tags);
    }
  }

  /**
   * Canonical embedded workflow representation.
   *
   * @param format workflow representation identifier
   * @param formatVersion workflow representation version
   * @param content canonical workflow DSL
   * @param sha256 lower-case canonical SHA-256
   */
  public record WorkflowPayload(String format, int formatVersion, String content, String sha256) {

    /* Validate workflow payload identity and hash fields. */
    public WorkflowPayload {
      format = requireNonBlank(format, "workflow format");
      if (formatVersion < 1) {
        throw new IllegalArgumentException("workflow formatVersion must be positive");
      }
      content = requireNonBlank(content, "workflow content");
      sha256 = requireSha256(sha256, "workflow sha256");
    }
  }

  /**
   * Required plugin declaration.
   *
   * @param id stable plugin id
   * @param versionRange declared package compatibility range
   * @param sections required section ids
   */
  public record PluginRequirement(String id, String versionRange, List<String> sections) {

    /* Validate plugin requirement. */
    public PluginRequirement {
      id = requireIdentifier(id, "plugin id");
      versionRange = requireNonBlank(versionRange, "versionRange");
      sections = normalizedIdentifiers(sections, "plugin section id");
      if (sections.isEmpty()) {
        throw new IllegalArgumentException("required plugin must declare at least one section");
      }
    }

    /** Return a fresh immutable copy of required section identifiers. */
    @Override
    public List<String> sections() {
      return List.copyOf(sections);
    }
  }

  /**
   * One versioned plugin-owned section.
   *
   * @param schemaVersion plugin parameter schema version
   * @param algorithmVersion algorithm compatibility identifier
   * @param data plugin-owned bounded data
   */
  public record PluginSection(int schemaVersion, String algorithmVersion, DocumentValue data) {

    /* Validate plugin section metadata. */
    public PluginSection {
      if (schemaVersion < 1) {
        throw new IllegalArgumentException("plugin schemaVersion must be positive");
      }
      algorithmVersion = requireNonBlank(algorithmVersion, "algorithmVersion");
      Objects.requireNonNull(data, "data");
    }
  }

  /**
   * Portable reference to an external recording or dataset.
   *
   * @param id stable asset id
   * @param relativePath bounded relative path
   * @param mediaType expected media type
   * @param sizeBytes expected size
   * @param sha256 expected SHA-256
   */
  public record AssetReference(
      String id, String relativePath, String mediaType, long sizeBytes, String sha256) {

    /* Validate asset identity, path and digest. */
    public AssetReference {
      id = requireIdentifier(id, "asset id");
      relativePath = PortableNames.requireRelativePath(relativePath);
      mediaType = requireNonBlank(mediaType, "mediaType");
      if (sizeBytes < 0L) {
        throw new IllegalArgumentException("asset sizeBytes must not be negative");
      }
      sha256 = requireSha256(sha256, "asset sha256");
    }
  }

  /**
   * Requested logical output.
   *
   * @param id stable output id
   * @param mediaType requested media type
   * @param baseName portable basename without path separators
   */
  public record OutputRequest(String id, String mediaType, String baseName) {

    /* Validate output request. */
    public OutputRequest {
      id = requireIdentifier(id, "output id");
      mediaType = requireNonBlank(mediaType, "output mediaType");
      baseName = PortableNames.requireBaseName(baseName);
    }
  }

  /**
   * Portable document provenance.
   *
   * @param creatorDisplayName creator display identity
   * @param verifiedAccount optional verified account reference
   * @param createdAt creation instant
   * @param modifiedAt modification instant
   * @param softwareVersion software version
   * @param canonicalSha256 canonical document hash
   * @param migrationNotes immutable migration notes
   */
  public record Provenance(
      String creatorDisplayName,
      String verifiedAccount,
      Instant createdAt,
      Instant modifiedAt,
      String softwareVersion,
      String canonicalSha256,
      List<String> migrationNotes) {

    /* Validate provenance fields. */
    public Provenance {
      creatorDisplayName = requireNonBlank(creatorDisplayName, "creatorDisplayName");
      verifiedAccount = verifiedAccount == null ? "" : verifiedAccount;
      Objects.requireNonNull(createdAt, "createdAt");
      Objects.requireNonNull(modifiedAt, "modifiedAt");
      if (modifiedAt.isBefore(createdAt)) {
        throw new IllegalArgumentException("modifiedAt must not be before createdAt");
      }
      softwareVersion = requireNonBlank(softwareVersion, "softwareVersion");
      canonicalSha256 =
          canonicalSha256 == null || canonicalSha256.isBlank()
              ? ""
              : requireSha256(canonicalSha256, "canonicalSha256");
      migrationNotes = normalizedStrings(migrationNotes, "migration note");
    }

    /** Return a fresh immutable copy of migration notes. */
    @Override
    public List<String> migrationNotes() {
      return List.copyOf(migrationNotes);
    }
  }

  private static Map<String, Map<String, PluginSection>> immutablePluginData(
      Map<String, Map<String, PluginSection>> source) {
    Objects.requireNonNull(source, "pluginData");
    TreeMap<String, Map<String, PluginSection>> plugins = new TreeMap<>();
    source.forEach(
        (pluginId, sections) -> {
          String validPluginId = requireIdentifier(pluginId, "plugin data id");
          Objects.requireNonNull(sections, "plugin sections");
          TreeMap<String, PluginSection> copiedSections = new TreeMap<>();
          sections.forEach(
              (sectionId, section) ->
                  copiedSections.put(
                      requireIdentifier(sectionId, "plugin section id"),
                      Objects.requireNonNull(section, "plugin section")));
          plugins.put(validPluginId, Collections.unmodifiableMap(copiedSections));
        });
    return Collections.unmodifiableMap(plugins);
  }

  private static List<String> normalizedIdentifiers(List<String> values, String label) {
    ArrayList<String> normalized = new ArrayList<>();
    for (String value : Objects.requireNonNull(values, label + "s")) {
      normalized.add(requireIdentifier(value, label));
    }
    normalized.sort(String::compareTo);
    return List.copyOf(normalized);
  }

  private static List<String> normalizedStrings(List<String> values, String label) {
    ArrayList<String> normalized = new ArrayList<>();
    for (String value : Objects.requireNonNull(values, label + "s")) {
      normalized.add(requireNonBlank(value, label));
    }
    normalized.sort(String::compareTo);
    return List.copyOf(normalized);
  }

  static String requireIdentifier(String value, String label) {
    String checked = requireNonBlank(value, label);
    if (!checked.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}")) {
      throw new IllegalArgumentException(label + " is not a portable identifier: " + checked);
    }
    return checked;
  }

  static String requireSha256(String value, String label) {
    String checked = requireNonBlank(value, label).toLowerCase(java.util.Locale.ROOT);
    if (!checked.matches("[0-9a-f]{64}")) {
      throw new IllegalArgumentException(label + " must be a lower-case SHA-256");
    }
    return checked;
  }

  static String requireNonBlank(String value, String label) {
    Objects.requireNonNull(value, label);
    if (value.isBlank()) {
      throw new IllegalArgumentException(label + " must not be blank");
    }
    return value;
  }
}