PluginDocumentCatalog.java
package org.hammer.audio.experiment.document;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import org.hammer.audio.plugin.AudioAnalyzerPlugin;
import org.hammer.audio.plugin.PluginDescriptor;
import org.hammer.audio.plugin.document.DocumentDiagnostic;
import org.hammer.audio.plugin.document.DocumentValidationResult;
import org.hammer.audio.plugin.document.DocumentValue;
import org.hammer.audio.plugin.document.ExperimentDocumentContribution;
import org.hammer.audio.plugin.document.ExperimentSectionMigration;
/**
* Installed-plugin catalog for safe experiment-document inspection, migration and normalization.
*/
@SuppressWarnings({"PMD.CouplingBetweenObjects", "PMD.LooseCoupling", "PMD.UseExplicitTypes"})
public final class PluginDocumentCatalog {
private final Map<String, RegisteredPlugin> plugins;
private final LocalJsonSchemaValidator schemaValidator = new LocalJsonSchemaValidator();
/** Build a catalog and reject duplicate plugin or section identities. */
public PluginDocumentCatalog(Collection<? extends AudioAnalyzerPlugin> installedPlugins) {
Objects.requireNonNull(installedPlugins, "installedPlugins");
TreeMap<String, RegisteredPlugin> collected = new TreeMap<>();
for (AudioAnalyzerPlugin plugin : installedPlugins) {
Objects.requireNonNull(plugin, "plugin");
PluginDescriptor descriptor =
Objects.requireNonNull(plugin.descriptor(), "plugin descriptor");
TreeMap<String, ExperimentDocumentContribution> sections = new TreeMap<>();
for (ExperimentDocumentContribution contribution : plugin.experimentDocumentContributions()) {
validateContribution(contribution);
ExperimentDocumentContribution previous =
sections.put(contribution.sectionId(), contribution);
if (previous != null) {
throw new IllegalArgumentException(
"Duplicate experiment document section "
+ descriptor.id()
+ "/"
+ contribution.sectionId());
}
}
RegisteredPlugin previous =
collected.put(descriptor.id(), new RegisteredPlugin(descriptor, Map.copyOf(sections)));
if (previous != null) {
throw new IllegalArgumentException("Duplicate plugin id: " + descriptor.id());
}
}
plugins = Map.copyOf(collected);
}
/** Return an empty catalog suitable for core-only inspection. */
public static PluginDocumentCatalog empty() {
return new PluginDocumentCatalog(List.of());
}
/**
* Resolve installed plugins and return a non-mutating import preview.
*
* <p>The original document is never rewritten. The returned document is a normalized copy whose
* migration provenance is explicit.
*/
public ExperimentDocumentPreview preview(ExperimentDocument input, ExperimentDocumentCodec codec)
throws ExperimentDocumentException {
Objects.requireNonNull(input, "input");
Objects.requireNonNull(codec, "codec");
ArrayList<DocumentDiagnostic> diagnostics = new ArrayList<>();
ArrayList<String> migrations = new ArrayList<>();
Set<SectionKey> required = requiredSections(input, diagnostics);
TreeMap<String, Map<String, ExperimentDocument.PluginSection>> normalizedData = new TreeMap<>();
for (var pluginEntry : input.pluginData().entrySet()) {
String pluginId = pluginEntry.getKey();
TreeMap<String, ExperimentDocument.PluginSection> normalizedSections = new TreeMap<>();
RegisteredPlugin installed = plugins.get(pluginId);
for (var sectionEntry : pluginEntry.getValue().entrySet()) {
String sectionId = sectionEntry.getKey();
SectionKey key = new SectionKey(pluginId, sectionId);
boolean requiredSection = required.contains(key);
String pointer = sectionPointer(pluginId, sectionId);
ExperimentDocument.PluginSection section = sectionEntry.getValue();
if (installed == null) {
diagnostics.add(
diagnostic(
requiredSection
? DocumentDiagnostic.Severity.ERROR
: DocumentDiagnostic.Severity.WARNING,
pointer,
"missing-plugin",
"Plugin is not installed; section is preserved without interpretation"));
normalizedSections.put(sectionId, section);
continue;
}
ExperimentDocumentContribution contribution = installed.sections().get(sectionId);
if (contribution == null) {
diagnostics.add(
diagnostic(
requiredSection
? DocumentDiagnostic.Severity.ERROR
: DocumentDiagnostic.Severity.WARNING,
pointer,
"missing-section",
"Installed plugin does not provide this section; data is preserved"));
normalizedSections.put(sectionId, section);
continue;
}
ExperimentDocument.PluginSection normalized =
normalizeSection(
contribution,
section,
pointer,
requiredSection || contribution.requiredForExecution(),
input.experiment().sourceMode(),
diagnostics,
migrations);
normalizedSections.put(sectionId, normalized);
}
normalizedData.put(pluginId, Map.copyOf(normalizedSections));
}
for (SectionKey key : required) {
Map<String, ExperimentDocument.PluginSection> sections =
input.pluginData().get(key.pluginId());
if (sections == null || !sections.containsKey(key.sectionId())) {
diagnostics.add(
diagnostic(
DocumentDiagnostic.Severity.ERROR,
sectionPointer(key.pluginId(), key.sectionId()),
"required-section-absent",
"Required plugin section is absent from the document"));
}
}
ExperimentDocument normalized = withPluginData(input, normalizedData, migrations);
ExperimentDocument canonical = codec.decode(codec.encode(normalized));
boolean hasErrors =
diagnostics.stream().anyMatch(item -> item.severity() == DocumentDiagnostic.Severity.ERROR);
boolean compatibilityWarning =
diagnostics.stream()
.anyMatch(
item ->
item.code().startsWith("missing-")
|| item.code().startsWith("future-")
|| item.code().startsWith("algorithm-"));
return new ExperimentDocumentPreview(
canonical,
canonical.provenance().canonicalSha256(),
diagnostics,
migrations,
!hasErrors,
hasErrors || compatibilityWarning);
}
private ExperimentDocument.PluginSection normalizeSection(
ExperimentDocumentContribution contribution,
ExperimentDocument.PluginSection original,
String pointer,
boolean required,
String sourceMode,
List<DocumentDiagnostic> diagnostics,
List<String> migrations) {
if (!contribution.supportedSourceModes().isEmpty()
&& !contribution.supportedSourceModes().contains(sourceMode)) {
diagnostics.add(
diagnostic(
required ? DocumentDiagnostic.Severity.ERROR : DocumentDiagnostic.Severity.WARNING,
pointer,
"source-mode-incompatible",
"Plugin section does not support source mode " + sourceMode));
}
if (original.schemaVersion() > contribution.schemaVersion()) {
diagnostics.add(
diagnostic(
required ? DocumentDiagnostic.Severity.ERROR : DocumentDiagnostic.Severity.WARNING,
pointer + "/schemaVersion",
"future-schema-version",
"Plugin section version is newer than the installed contribution"));
return original;
}
DocumentValue migratedValue = original.data();
int version = original.schemaVersion();
while (version < contribution.schemaVersion()) {
ExperimentSectionMigration migration = migrationFrom(contribution.migrations(), version);
if (migration == null || migration.toVersion() != version + 1) {
diagnostics.add(
diagnostic(
DocumentDiagnostic.Severity.ERROR,
pointer + "/schemaVersion",
"migration-gap",
"No explicit adjacent migration from schema version " + version));
return original;
}
try {
migratedValue = Objects.requireNonNull(migration.migrate(migratedValue), "migrated value");
} catch (RuntimeException exception) {
diagnostics.add(
diagnostic(
DocumentDiagnostic.Severity.ERROR,
pointer,
"migration-failed",
"Plugin migration failed: " + exception.getClass().getSimpleName()));
return original;
}
migrations.add(
contribution.sectionId() + ":" + migration.fromVersion() + "->" + migration.toVersion());
version = migration.toVersion();
}
diagnostics.addAll(
schemaValidator.validate(
contribution.schemaJson(),
contribution.schemaSha256(),
migratedValue,
pointer + "/data"));
DocumentValidationResult semantic;
try {
semantic = contribution.validateAndNormalize(migratedValue);
} catch (RuntimeException exception) {
diagnostics.add(
diagnostic(
DocumentDiagnostic.Severity.ERROR,
pointer,
"plugin-validator-failed",
"Plugin validation failed: " + exception.getClass().getSimpleName()));
return original;
}
diagnostics.addAll(semantic.diagnostics());
if (!original.algorithmVersion().equals(contribution.algorithmVersion())) {
diagnostics.add(
diagnostic(
required ? DocumentDiagnostic.Severity.ERROR : DocumentDiagnostic.Severity.WARNING,
pointer + "/algorithmVersion",
"algorithm-incompatible",
"Document algorithm version differs from the installed contribution"));
}
return new ExperimentDocument.PluginSection(
contribution.schemaVersion(), contribution.algorithmVersion(), semantic.normalizedValue());
}
private static Set<SectionKey> requiredSections(
ExperimentDocument document, List<DocumentDiagnostic> diagnostics) {
HashSet<SectionKey> required = new HashSet<>();
for (ExperimentDocument.PluginRequirement requirement : document.requiredPlugins()) {
for (String section : requirement.sections()) {
required.add(new SectionKey(requirement.id(), section));
}
}
return required;
}
private static ExperimentSectionMigration migrationFrom(
List<ExperimentSectionMigration> migrations, int version) {
ExperimentSectionMigration found = null;
for (ExperimentSectionMigration migration : migrations) {
if (migration.fromVersion() == version) {
if (found != null) {
throw new IllegalArgumentException("Duplicate migration from version " + version);
}
found = migration;
}
}
return found;
}
private static ExperimentDocument withPluginData(
ExperimentDocument input,
Map<String, Map<String, ExperimentDocument.PluginSection>> pluginData,
List<String> migrations) {
ArrayList<String> notes = new ArrayList<>(input.provenance().migrationNotes());
notes.addAll(migrations);
ExperimentDocument.Provenance provenance =
new ExperimentDocument.Provenance(
input.provenance().creatorDisplayName(),
input.provenance().verifiedAccount(),
input.provenance().createdAt(),
input.provenance().modifiedAt(),
input.provenance().softwareVersion(),
"",
notes);
return new ExperimentDocument(
input.schema(),
input.format(),
input.formatVersion(),
input.experiment(),
input.workflow(),
input.profiles(),
input.requiredPlugins(),
pluginData,
input.assets(),
input.outputs(),
provenance);
}
private static void validateContribution(ExperimentDocumentContribution contribution) {
Objects.requireNonNull(contribution, "document contribution");
ExperimentDocument.requireIdentifier(contribution.sectionId(), "section id");
if (contribution.schemaVersion() < 1) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
ExperimentDocument.requireNonBlank(contribution.algorithmVersion(), "algorithmVersion");
ExperimentDocument.requireNonBlank(contribution.name(), "contribution name");
ExperimentDocument.requireNonBlank(contribution.description(), "contribution description");
ExperimentDocument.requireNonBlank(contribution.schemaId(), "schemaId");
ExperimentDocument.requireNonBlank(contribution.schemaJson(), "schemaJson");
ExperimentDocument.requireSha256(contribution.schemaSha256(), "schemaSha256");
HashMap<Integer, ExperimentSectionMigration> migrations = new HashMap<>();
for (ExperimentSectionMigration migration : contribution.migrations()) {
Objects.requireNonNull(migration, "migration");
if (migration.fromVersion() < 1 || migration.toVersion() != migration.fromVersion() + 1) {
throw new IllegalArgumentException("Migrations must advance exactly one positive version");
}
if (migrations.put(migration.fromVersion(), migration) != null) {
throw new IllegalArgumentException(
"Duplicate migration from version " + migration.fromVersion());
}
}
}
private static String sectionPointer(String pluginId, String sectionId) {
return DocumentValueJson.pointer(DocumentValueJson.pointer("/pluginData", pluginId), sectionId);
}
private static DocumentDiagnostic diagnostic(
DocumentDiagnostic.Severity severity, String pointer, String code, String message) {
return new DocumentDiagnostic(severity, pointer, code, message);
}
private record RegisteredPlugin(
PluginDescriptor descriptor, Map<String, ExperimentDocumentContribution> sections) {
// Immutable installed plugin descriptor and section registry.
}
private record SectionKey(String pluginId, String sectionId) {
// Immutable namespaced section identity.
}
}