diff --git a/.github/scripts/smoke_imports.py b/.github/scripts/smoke_imports.py index 67a7e56..dbd856b 100644 --- a/.github/scripts/smoke_imports.py +++ b/.github/scripts/smoke_imports.py @@ -17,6 +17,7 @@ "variopt.study", "variopt.algorithms", "variopt.algorithms.population", + "variopt.algorithms.population.csa", "variopt.algorithms.local_search", "variopt.spaces.projections", ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 134f491..859cc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,13 @@ format. Stability guarantees for the public surface are documented in the ### Added +- Added `CSAOptimizer.configuration_manifest(...)` and the supported + `CSAConfigurationManifest`, `CSAComponentDescriptor`, and + `CSAConfigurationResolutionError` artifacts. The versioned manifest records + resolved optimizer-side CSA configuration as canonical JSON, provides a + content fingerprint, and requires explicit caller provenance for custom + components without treating that assertion as executable equivalence or a + complete run identity. - Synchronous `Study.run(...)` and `Study.optimize(...)` can now dispatch explicitly eligible SciPy and structured local-search kernels as bounded request-local episodes through `SequentialEvaluator` and `JoblibEvaluator`. diff --git a/docs/concepts/csa.md b/docs/concepts/csa.md index 5ebc36d..85a9353 100644 --- a/docs/concepts/csa.md +++ b/docs/concepts/csa.md @@ -161,5 +161,8 @@ comes from `derive_csa_defaults(...)` or an explicit override. - [Customize an Optimizer Profile](../guides/customize-optimizer-profile.md) — the task-oriented guide for overriding presets and profile slots. +- [Record CSA Configuration + Provenance](../guides/csa-configuration-provenance.md) — persist and compare + the resolved optimizer-side configuration. - [Presets and Contracts](../reference/presets-and-contracts.md) — the supported preset surface and slot catalog. diff --git a/docs/guides/csa-configuration-provenance.md b/docs/guides/csa-configuration-provenance.md new file mode 100644 index 0000000..f56148f --- /dev/null +++ b/docs/guides/csa-configuration-provenance.md @@ -0,0 +1,299 @@ +# Record CSA Configuration Provenance + +A CSA checkpoint records optimizer state. It does not record the configuration +that gives that state meaning. Use +[`CSAOptimizer.configuration_manifest()`][variopt.algorithms.population.CSAOptimizer.configuration_manifest] +to capture the fully resolved optimizer-side configuration separately. + +The resulting +[`CSAConfigurationManifest`][variopt.algorithms.population.CSAConfigurationManifest] +is useful for: + +- recording the configuration used for a run +- contributing a version-scoped optimizer-configuration component to a cache key +- rejecting a checkpoint restore under a different CSA configuration +- comparing resolved presets and overrides rather than raw constructor inputs + +It is not an optimizer snapshot, executable reconstruction recipe, or complete +experiment identity. + +## Create a Built-In Manifest + +Exact built-in spaces, samplers, metrics, policies, and operators need no extra +metadata: + +```python +from variopt import IntegerSpace +from variopt.algorithms.population import CSAOptimizer + +space = IntegerSpace(-20, 20) +optimizer = CSAOptimizer.from_space_defaults( + space=space, + bank_capacity=8, + random_state=11, +) + +manifest = optimizer.configuration_manifest() + +print(manifest.fingerprint) +print(manifest.canonical_json()) +``` + +The manifest represents the effective `resolved_profile`, not merely the +boundary-level `CSAProfile` passed to the constructor. Two constructor forms +that resolve to the same exact built-in configuration produce the same +canonical JSON and fingerprint. + +`random_state=11` records the initialization seed. `random_state=None` records +an explicit nondeterministic initialization mode. Manifest generation never +materializes or advances an RNG and never includes the current RNG state. + +## Persist and Parse the Manifest + +`canonical_json()` returns deterministic compact JSON under the Variopt +manifest contract: + +```python +import json +from pathlib import Path + +from variopt.algorithms.population import CSAConfigurationManifest + +manifest_path = Path("csa-configuration.json") +manifest_path.write_text( + manifest.canonical_json() + "\n", + encoding="utf-8", +) + +stored_manifest = CSAConfigurationManifest.from_dict( + json.loads(manifest_path.read_text(encoding="utf-8")), +) + +assert stored_manifest.fingerprint == manifest.fingerprint +``` + +`to_dict()` and `from_dict()` provide the structured equivalent. +`from_dict()` validates the manifest format, schema version, algorithm identity, +algorithm-configuration version, and JSON value contract. It does not +reconstruct an optimizer or executable component. + +The canonical JSON representation is Variopt's versioned contract. It uses +sorted object keys, compact separators, finite JSON numbers, and UTF-8 text, but +it is not an implementation of RFC 8785 or a promise that arbitrary JSON +libraries in other languages will produce identical bytes. + +## Describe Custom Components + +Variopt can project exact built-ins because it owns their semantic contracts. +For a custom component or a subclass of a built-in component, the caller must +provide a +[`CSAComponentDescriptor`][variopt.algorithms.population.CSAComponentDescriptor] +at every semantic occurrence. + +The following optimizer has a custom sampler and diversity metric: + +```python +import numpy as np +from typing_extensions import override + +from variopt import IntegerSpace +from variopt.algorithms.population import ( + CSAComponentDescriptor, + CSAConfigurationResolutionError, + CSAOptimizer, +) +from variopt.diversity import DiversityMetric +from variopt.sampling import CandidateSampler + + +class CenterBiasedSampler(CandidateSampler[int]): + @override + def sample(self, random_state: np.random.RandomState) -> int: + return int(random_state.randint(-5, 6)) + + +class AbsoluteDistance(DiversityMetric[int]): + @override + def distance(self, left: int, right: int) -> float: + return float(abs(left - right)) + + +space = IntegerSpace(-20, 20) +optimizer = CSAOptimizer.from_space_defaults( + space=space, + bank_capacity=8, + sampler=CenterBiasedSampler(), + diversity_metric=AbsoluteDistance(), + random_state=11, +) + +try: + optimizer.configuration_manifest( + custom_component_descriptors={ + ("obsolete_component",): CSAComponentDescriptor( + identifier="org.example.obsolete", + version=1, + configuration={}, + ), + }, + ) +except CSAConfigurationResolutionError as error: + print(error.missing_component_paths) + print(error.unused_component_paths) +``` + +The exception reports all missing and unused locations together. No partial +manifest is returned. Supply descriptors for the two actual custom occurrences: + +```python +component_descriptors = { + ("sampler",): CSAComponentDescriptor( + identifier="org.example.center-biased-sampler", + version=1, + configuration={ + "minimum_sample": -5, + "maximum_sample": 5, + }, + ), + ("diversity_metric",): CSAComponentDescriptor( + identifier="org.example.absolute-distance", + version=1, + configuration={}, + ), +} + +custom_manifest = optimizer.configuration_manifest( + custom_component_descriptors=component_descriptors, +) +``` + +A descriptor is a caller assertion. Variopt validates and fingerprints its +identifier, version, and JSON configuration, but it cannot verify that two +implementations with the same descriptor behave identically. Include every +execution-relevant custom setting and bump the descriptor version when its +semantics change. + +Custom identifiers must be stable, non-empty UTF-8 strings outside the reserved +`variopt` namespace. Descriptor configuration must be finite, acyclic, +JSON-safe data. + +## Understand Semantic Locations + +Descriptor keys are tuples of exact `str` and non-negative `int` segments. They +identify locations in resolved CSA configuration, not Python attribute paths, +module paths, object identities, or incidental serialized-dictionary keys. + +Common locations include: + +| Component occurrence | Semantic location | +| --- | --- | +| Optimizer search space | `("space",)` | +| Sampler | `("sampler",)` | +| Sampler-owned space | `("sampler", "space")` | +| Diversity metric | `("diversity_metric",)` | +| Metric-owned space | `("diversity_metric", "space")` | +| Resolved cutoff schedule | `("resolved_profile", "cutoff_schedule")` | +| Resolved update policy | `("resolved_profile", "update_policy")` | +| Niche policy inside the update policy | `("resolved_profile", "update_policy", "niche_quality_policy")` | +| First regular-family operator | `("resolved_profile", "perturbation_schedule", "regular_family", 0, "operator")` | +| Second operator in a mixture | `(..., "operator", "operators", 1)` | +| First adaptive-potential axis | `("resolved_profile", "score_model", "adaptive_potential", "axes", 0)` | +| Opaque reference candidate on that axis | `(..., "axes", 0, "reference_candidate")` | + +Nested exact built-in spaces extend their owning location as follows: + +| Space structure | Child suffix | +| --- | --- | +| Array element space | `("element_space",)` | +| Tuple child | `("child_spaces", child_index)` | +| Record field space | `("fields", field_index, "space")` | +| Space-bound operator | `("space",)` | + +Record fields use their ordered index rather than the field name in semantic +locations. The field name remains part of the represented record-space value. + +An exact built-in parent is traversed recursively. A custom parent consumes one +descriptor at its own location and its internals are not inspected. Supplying a +descriptor for one of that custom parent's hypothetical descendants is therefore +an unused-descriptor error. Descriptors supplied for exact built-ins or unknown +locations are also reported as unused. + +Component-local spaces are represented by value at each occurrence. Reusing one +space object and constructing equivalent independent space objects therefore +produce the same manifest; Python aliasing is not semantic identity. + +Semantic locations are deterministic within an algorithm-configuration version. +If a future version changes the represented configuration ontology, it must +change the corresponding version axis rather than silently reinterpreting old +locations. + +## Interpret the Fingerprint + +The fingerprint is SHA-256 over the complete canonical manifest, including: + +- manifest format and schema version +- algorithm identifier and algorithm-configuration version +- exact built-in component identifiers, versions, and configuration +- custom descriptor identifiers, versions, and asserted configuration +- resolved optimizer configuration, including the initialization seed mode + +Matching fingerprints mean that the represented manifest data are identical +under those version axes. They do not prove: + +- behavioral equivalence of custom executable code +- equality of objectives, problems, datasets, or data splits +- equality of evaluators, kernels, execution models, or worker topology +- equality of dependency versions, platform, environment, or hardware +- equality of runtime optimizer state or in-flight work +- full reproducibility of an optimization run + +For full experiment provenance, store the manifest alongside caller-owned +problem/data/objective identity, evaluator and execution settings, environment +and dependency metadata, and the checkpoint or terminal result as applicable. + +## Guard a Checkpoint Restore + +Persist the configuration manifest beside the JSON-safe CSA checkpoint. Given +the `optimizer`, `manifest_path`, and a separately loaded `checkpoint_data`: + +```python +import json + +from variopt.algorithms.population import CSAConfigurationManifest + +stored_manifest = CSAConfigurationManifest.from_dict( + json.loads(manifest_path.read_text(encoding="utf-8")), +) +current_manifest = optimizer.configuration_manifest() + +if current_manifest.fingerprint != stored_manifest.fingerprint: + raise RuntimeError("CSA configuration does not match the checkpoint") + +restored_state = optimizer.state_from_dict(checkpoint_data) +``` + +For custom components, pass the same descriptor mapping when creating +`current_manifest`. This guard prevents restoring under a different represented +CSA configuration. It does not compare the objective, data, evaluator, +environment, or dependency provenance; validate those dimensions separately +before claiming exact continuation. + +See [Checkpointing](../reference/checkpointing.md) for safe-boundary state +serialization and the remaining runtime-state exclusions. + +## Version Axes + +The manifest separates several reasons for incompatibility: + +- `schema_version` changes when the manifest wire shape or parsing contract + changes. +- `algorithm.configuration_version` changes when represented CSA configuration + semantics or semantic locations change. +- built-in component versions change when a represented built-in component's + configuration semantics change independently. +- custom component versions are caller-owned and must change when the caller's + asserted semantics change. + +All version axes participate in the fingerprint. An unsupported manifest or +algorithm-configuration version is rejected by `from_dict()` rather than being +silently interpreted under current semantics. diff --git a/docs/guides/index.md b/docs/guides/index.md index b445bb5..26a8e41 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -11,5 +11,6 @@ choice about optimizers, evaluators, or configuration patterns. - [Choose an Evaluator](choose-an-evaluator.md) - [Canonical Usage Patterns](canonical-usage-patterns.md) - [Customize an Optimizer Profile](customize-optimizer-profile.md) +- [Record CSA Configuration Provenance](csa-configuration-provenance.md) - [Local Optimization Methods](local-optimization-methods.md) - [Evaluator-Owned Local-Search Episodes](request-local-episodes.md) diff --git a/docs/reference/api.md b/docs/reference/api.md index 21412e7..c53443b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -10,6 +10,7 @@ The supported public facade modules are: - `variopt.study` - `variopt.artifacts` - `variopt.algorithms.population` +- `variopt.algorithms.population.csa` - `variopt.algorithms.local_search` Deeper submodules may be importable, but they are not automatically stable @@ -72,3 +73,9 @@ supported type-hint/runtime state artifacts because the GA-family optimizer methods return and accept them directly. Lifecycle helpers under `variopt.algorithms.population.generational_ga` remain implementation details. + +The same population facade exposes `CSAConfigurationManifest`, +`CSAComponentDescriptor`, and `CSAConfigurationResolutionError` for recording +resolved CSA optimizer configuration. See +[Record CSA Configuration Provenance](../guides/csa-configuration-provenance.md) +for the represented scope and fingerprint contract. diff --git a/docs/reference/api/csa.md b/docs/reference/api/csa.md index 97a9422..e469d55 100644 --- a/docs/reference/api/csa.md +++ b/docs/reference/api/csa.md @@ -1,7 +1,9 @@ # `variopt.algorithms.population.csa` -Advanced CSA policy and schedule types used when overriding individual -`CSAProfile` slots. For the task-oriented overview see -[Customize an Optimizer Profile](../../guides/customize-optimizer-profile.md). +Advanced CSA policy, schedule, and configuration-provenance types. For +task-oriented guidance see +[Customize an Optimizer Profile](../../guides/customize-optimizer-profile.md) +and +[Record CSA Configuration Provenance](../../guides/csa-configuration-provenance.md). ::: variopt.algorithms.population.csa diff --git a/docs/reference/api/population.md b/docs/reference/api/population.md index 91970df..acc03b8 100644 --- a/docs/reference/api/population.md +++ b/docs/reference/api/population.md @@ -1,3 +1,6 @@ # `variopt.algorithms.population` +Population optimizer entry points, public manual-loop state artifacts, and CSA +configuration-provenance values. + ::: variopt.algorithms.population diff --git a/docs/reference/checkpointing.md b/docs/reference/checkpointing.md index d03d607..bd315ec 100644 --- a/docs/reference/checkpointing.md +++ b/docs/reference/checkpointing.md @@ -18,6 +18,14 @@ surface is the explicit `to_dict()` / `from_dict()` checkpoint contract; Python `pickle` round trips are runtime compatibility conveniences only and are not a cross-version or crash-recovery checkpoint format. +A checkpoint does not contain the optimizer configuration that gives its state +meaning. Persist a +[`CSAConfigurationManifest`][variopt.algorithms.population.CSAConfigurationManifest] +beside the checkpoint and compare it before restoring state. See +[Record CSA Configuration +Provenance](../guides/csa-configuration-provenance.md#guard-a-checkpoint-restore) +for the guard pattern and for provenance dimensions that remain caller-owned. + ## Usage ```python diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 1ef80dc..619b509 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -5,6 +5,31 @@ to the reference page for the underlying symbol. For the narrative explanation of how these pieces fit together, see [Optimization Model](../concepts/optimization-model.md). +## CSAComponentDescriptor + +A caller-owned, versioned assertion describing one custom component occurrence +in resolved CSA configuration. Variopt fingerprints the identifier, version, +and JSON configuration but does not verify executable equivalence. See +[`CSAComponentDescriptor`][variopt.algorithms.population.CSAComponentDescriptor]. + +## CSAConfigurationManifest + +An immutable, versioned projection of resolved optimizer-side CSA +configuration. Its canonical JSON and fingerprint exclude runtime checkpoint +state, problem/data/objective identity, evaluator settings, and environment +provenance. See +[`CSAConfigurationManifest`][variopt.algorithms.population.CSAConfigurationManifest] +and [Record CSA Configuration +Provenance](../guides/csa-configuration-provenance.md). + +## CSAConfigurationResolutionError + +The aggregate failure raised when a manifest projection needs custom component +descriptors that were not supplied or receives descriptors that no semantic +location consumed. It exposes deterministic `missing_component_paths` and +`unused_component_paths`. See +[`CSAConfigurationResolutionError`][variopt.algorithms.population.CSAConfigurationResolutionError]. + ## CandidateRefinement Execution-side provenance for a candidate transformed before evaluation. diff --git a/docs/reference/stability.md b/docs/reference/stability.md index 015ab3e..180bfd7 100644 --- a/docs/reference/stability.md +++ b/docs/reference/stability.md @@ -18,6 +18,7 @@ importable modules. It consists of: - `variopt.study` - `variopt.artifacts` - `variopt.algorithms.population` +- `variopt.algorithms.population.csa` - `variopt.algorithms.local_search` Every name exported through the `__all__` of one of these modules is part of @@ -48,6 +49,19 @@ narrower advanced contract aimed at users who already work against `CSAProfile`. See [Customize an Optimizer Profile](../guides/customize-optimizer-profile.md). +### CSA configuration provenance + +`CSAConfigurationManifest`, `CSAComponentDescriptor`, and +`CSAConfigurationResolutionError` are supported through both +`variopt.algorithms.population` and `variopt.algorithms.population.csa`. +Manifest schema versions, algorithm-configuration versions, built-in component +identifiers and versions, semantic custom-component locations, canonical JSON, +and fingerprint interpretation are part of this versioned contract. Adding or +removing represented fields, or changing their interpretation or semantic +location grammar, requires the corresponding version axis to change. See +[Record CSA Configuration +Provenance](../guides/csa-configuration-provenance.md). + ### What is *not* supported - Deep submodule paths not listed above, even when importable. diff --git a/mkdocs.yml b/mkdocs.yml index 34fc656..e79c739 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,7 @@ nav: - Choose an Evaluator: guides/choose-an-evaluator.md - Canonical Usage Patterns: guides/canonical-usage-patterns.md - Customize an Optimizer Profile: guides/customize-optimizer-profile.md + - Record CSA Configuration Provenance: guides/csa-configuration-provenance.md - Local Optimization Methods: guides/local-optimization-methods.md - Evaluator-Owned Local Search: guides/request-local-episodes.md - Concepts: diff --git a/src/variopt/algorithms/population/__init__.py b/src/variopt/algorithms/population/__init__.py index 8d179d3..35f3852 100644 --- a/src/variopt/algorithms/population/__init__.py +++ b/src/variopt/algorithms/population/__init__.py @@ -6,7 +6,13 @@ """ from .clearing_ga import ClearingGAProfile, ClearingGeneticAlgorithmOptimizer -from .csa import CSAOptimizer, CSAProfile +from .csa import ( + CSAComponentDescriptor, + CSAConfigurationManifest, + CSAConfigurationResolutionError, + CSAOptimizer, + CSAProfile, +) from .de import DEProfile, DifferentialEvolutionOptimizer from .ga import GAProfile, GeneticAlgorithmOptimizer from .generational_ga.state import ( @@ -26,6 +32,9 @@ ) __all__ = [ + "CSAComponentDescriptor", + "CSAConfigurationManifest", + "CSAConfigurationResolutionError", "CSAOptimizer", "CSAProfile", "ClearingGAProfile", diff --git a/src/variopt/algorithms/population/csa/__init__.py b/src/variopt/algorithms/population/csa/__init__.py index 1925e95..71f4670 100644 --- a/src/variopt/algorithms/population/csa/__init__.py +++ b/src/variopt/algorithms/population/csa/__init__.py @@ -1,4 +1,4 @@ -"""CSA-lite optimizer components.""" +"""Supported CSA optimizer components and provenance values.""" from .banking.bank import Bank from .banking.clustering import CSAClusteringPolicy @@ -7,6 +7,11 @@ from .defaults import CSADefaultComponents, derive_csa_defaults from .generation.perturbation import CSAPerturbationSchedule, CSAPerturbationSpec from .generation.proposal import CSAProposalPolicy +from .manifest import ( + CSAComponentDescriptor, + CSAConfigurationManifest, + CSAConfigurationResolutionError, +) from .operators import ( BoundedMutation, DifferentialEvolutionVariation, @@ -40,6 +45,9 @@ "CSABankUpdatePolicy", "CSABiasedPotential", "CSAClusteringPolicy", + "CSAComponentDescriptor", + "CSAConfigurationManifest", + "CSAConfigurationResolutionError", "CSACutoffObservation", "CSACutoffSchedule", "CSADefaultComponents", diff --git a/src/variopt/algorithms/population/csa/manifest/__init__.py b/src/variopt/algorithms/population/csa/manifest/__init__.py index 6af2d76..97b05b7 100644 --- a/src/variopt/algorithms/population/csa/manifest/__init__.py +++ b/src/variopt/algorithms/population/csa/manifest/__init__.py @@ -1,8 +1,13 @@ """Canonical CSA configuration-manifest values.""" -from .model import CSAComponentDescriptor, CSAConfigurationManifest +from .model import ( + CSAComponentDescriptor, + CSAConfigurationManifest, + CSAConfigurationResolutionError, +) __all__ = [ "CSAComponentDescriptor", "CSAConfigurationManifest", + "CSAConfigurationResolutionError", ] diff --git a/src/variopt/algorithms/population/csa/manifest/components.py b/src/variopt/algorithms/population/csa/manifest/components.py new file mode 100644 index 0000000..fe18a14 --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/components.py @@ -0,0 +1,219 @@ +"""Exact built-in component projection for CSA configuration manifests.""" + +from typing import TypeVar + +from .....diversity import DiversityMetric, StructuredSpaceDiversityMetric +from .....json_types import JSONValue +from .....operators import VariationOperator +from .....sampling import CandidateSampler, SearchSpaceSampler +from .....spaces import SearchSpace +from ...permutation.operators import InversionMutation, OrderCrossover, SwapMutation +from ..operators import ( + BoundedMutation, + DifferentialEvolutionVariation, + MixtureVariation, + RandomResetMutation, + UniformCrossover, +) +from .nodes import builtin_component_node +from .resolution import CSAComponentDescriptorResolver, CSAComponentPath +from .spaces import project_space + +BoundaryT = TypeVar("BoundaryT") +CandidateT = TypeVar("CandidateT") + + +def project_sampler( + sampler: CandidateSampler[CandidateT] | None, + *, + optimizer_space: SearchSpace[BoundaryT, CandidateT], + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project the effective CSA boundary sampler.""" + if sampler is None: + return builtin_component_node( + identifier="variopt.sampler.search-space", + configuration={ + "space": project_space( + optimizer_space, + path=(*path, "space"), + resolver=resolver, + ), + }, + ) + + if isinstance(sampler, SearchSpaceSampler): + if type(sampler) is not SearchSpaceSampler: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.sampler.search-space", + configuration={ + "space": project_space( + sampler.space, + path=(*path, "space"), + resolver=resolver, + ), + }, + ) + + return resolver.resolve_custom_component(path) + + +def project_diversity_metric( + diversity_metric: DiversityMetric[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in diversity metric.""" + if isinstance(diversity_metric, StructuredSpaceDiversityMetric): + if type(diversity_metric) is not StructuredSpaceDiversityMetric: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.diversity.structured-space", + configuration={ + "space": project_space( + diversity_metric.space, + path=(*path, "space"), + resolver=resolver, + ), + }, + ) + + return resolver.resolve_custom_component(path) + + +def project_variation_operator( + operator: VariationOperator[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in variation operator recursively.""" + if isinstance(operator, UniformCrossover): + if type(operator) is not UniformCrossover: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.uniform-crossover", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_exchange_fraction": operator.max_exchange_fraction, + }, + ) + + if isinstance(operator, RandomResetMutation): + if type(operator) is not RandomResetMutation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.random-reset-mutation", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_exchange_fraction": operator.max_exchange_fraction, + }, + ) + + if isinstance(operator, BoundedMutation): + if type(operator) is not BoundedMutation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.bounded-mutation", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_perturbation_fraction": operator.max_perturbation_fraction, + }, + ) + + if isinstance(operator, DifferentialEvolutionVariation): + if type(operator) is not DifferentialEvolutionVariation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.differential-evolution", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "mutation_range": list(operator.mutation_range), + "recombination_probability": operator.recombination_probability, + "n_cross": operator.n_cross, + }, + ) + + if isinstance(operator, MixtureVariation): + if type(operator) is not MixtureVariation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.mixture", + configuration={ + "operators": [ + project_variation_operator( + child_operator, + path=(*path, "operators", index), + resolver=resolver, + ) + for index, child_operator in enumerate(operator.operators) + ], + "weights": list(operator.weights), + }, + ) + + if isinstance(operator, OrderCrossover): + if type(operator) is not OrderCrossover: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.order-crossover", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_segment_fraction": operator.max_segment_fraction, + }, + ) + + if isinstance(operator, SwapMutation): + if type(operator) is not SwapMutation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.swap-mutation", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_swap_fraction": operator.max_swap_fraction, + }, + ) + + if isinstance(operator, InversionMutation): + if type(operator) is not InversionMutation: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.operator.inversion-mutation", + configuration={ + "space": project_space( + operator.space, + path=(*path, "space"), + resolver=resolver, + ), + "max_inversion_fraction": operator.max_inversion_fraction, + }, + ) + + return resolver.resolve_custom_component(path) diff --git a/src/variopt/algorithms/population/csa/manifest/model.py b/src/variopt/algorithms/population/csa/manifest/model.py index 602368a..5d0cff8 100644 --- a/src/variopt/algorithms/population/csa/manifest/model.py +++ b/src/variopt/algorithms/population/csa/manifest/model.py @@ -24,6 +24,54 @@ VARIOPT_COMPONENT_IDENTIFIER_PREFIX = "variopt." +class CSAConfigurationResolutionError(ValueError): + """Report unresolved semantic locations in one manifest projection. + + The error is raised by ``CSAOptimizer.configuration_manifest()`` after + collecting every missing and unused custom-component location. + + Parameters + ---------- + missing_component_paths : tuple[tuple[str | int, ...], ...] + Deterministically ordered locations that require caller descriptors. + unused_component_paths : tuple[tuple[str | int, ...], ...] + Deterministically ordered caller locations that were not consumed. + """ + + missing_component_paths: tuple[tuple[str | int, ...], ...] + unused_component_paths: tuple[tuple[str | int, ...], ...] + + def __init__( + self, + *, + missing_component_paths: tuple[tuple[str | int, ...], ...], + unused_component_paths: tuple[tuple[str | int, ...], ...], + ) -> None: + canonical_missing_paths = _canonical_component_paths( + missing_component_paths, + ) + canonical_unused_paths = _canonical_component_paths( + unused_component_paths, + ) + self.missing_component_paths = canonical_missing_paths + self.unused_component_paths = canonical_unused_paths + + details: list[str] = [] + if canonical_missing_paths: + details.append( + "missing component paths " + + _component_paths_json(canonical_missing_paths), + ) + if canonical_unused_paths: + details.append( + "unused component paths " + + _component_paths_json(canonical_unused_paths), + ) + super().__init__( + "CSA configuration manifest resolution failed: " + "; ".join(details), + ) + + @dataclass(frozen=True, slots=True, init=False) class CSAComponentDescriptor: """Stable caller-owned description of one custom CSA component. @@ -74,7 +122,13 @@ def __init__( @property def configuration(self) -> JSONDict: - """Return a fresh JSON-safe copy of the asserted configuration.""" + """Return a fresh copy of the asserted custom configuration. + + Returns + ------- + JSONDict + Mutable JSON-safe data detached from the immutable descriptor. + """ return thaw_json_object(self._configuration) def to_dict(self) -> JSONDict: @@ -189,32 +243,73 @@ def __init__( @property def format_identifier(self) -> str: - """Return the stable manifest format identifier.""" + """Return the stable manifest format identifier. + + Returns + ------- + str + Identifier for the CSA configuration-manifest wire format. + """ return CSA_CONFIGURATION_MANIFEST_FORMAT @property def schema_version(self) -> int: - """Return the supported manifest wire-schema version.""" + """Return the supported manifest wire-schema version. + + Returns + ------- + int + Positive version of the manifest field and JSON-shape contract. + """ return CSA_CONFIGURATION_MANIFEST_SCHEMA_VERSION @property def algorithm_identifier(self) -> str: - """Return the stable CSA algorithm identifier.""" + """Return the stable represented-algorithm identifier. + + Returns + ------- + str + Identifier for the CSA configuration semantics. + """ return CSA_ALGORITHM_IDENTIFIER @property def algorithm_configuration_version(self) -> int: - """Return the supported CSA configuration-semantics version.""" + """Return the supported CSA configuration-semantics version. + + Returns + ------- + int + Positive version of represented fields and semantic locations. + """ return CSA_ALGORITHM_CONFIGURATION_VERSION @property def configuration(self) -> JSONDict: - """Return a fresh JSON-safe copy of the resolved configuration.""" + """Return a fresh copy of the resolved optimizer configuration. + + Returns + ------- + JSONDict + Mutable JSON-safe data detached from the immutable manifest. + """ return thaw_json_object(self._configuration) @property def fingerprint(self) -> str: - """Return the version-scoped SHA-256 content fingerprint.""" + """Return the version-scoped SHA-256 content fingerprint. + + Returns + ------- + str + ``sha256:``-prefixed digest of the complete canonical manifest. + + Notes + ----- + Equality establishes only identical represented manifest data. It does + not establish custom executable equivalence or complete run identity. + """ digest = sha256(self.canonical_json().encode("utf-8")).hexdigest() return f"sha256:{digest}" @@ -226,6 +321,10 @@ def canonical_json(self) -> str: str UTF-8-compatible JSON text with sorted object keys and compact separators. + + Notes + ----- + This is Variopt's versioned canonicalization contract, not RFC 8785. """ return json.dumps( self.to_dict(), @@ -274,6 +373,11 @@ def from_dict(cls, data: Mapping[str, JSONValue]) -> Self: types are invalid. ValueError If a format identifier, version, or configuration value is invalid. + + Notes + ----- + Parsing validates manifest data but does not reconstruct an optimizer or + executable component. """ _require_exact_fields( data, @@ -364,6 +468,37 @@ def _validate_positive_version(version: int, *, field_name: str) -> None: raise ValueError(msg) +def _component_paths_json( + paths: tuple[tuple[str | int, ...], ...], +) -> str: + return json.dumps( + paths, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def _canonical_component_paths( + paths: tuple[tuple[str | int, ...], ...], +) -> tuple[tuple[str | int, ...], ...]: + return tuple(sorted(set(paths), key=_component_path_sort_key)) + + +def _component_path_sort_key( + path: tuple[str | int, ...], +) -> tuple[tuple[int, str, int], ...]: + key_segments: list[tuple[int, str, int]] = [] + for segment in path: + if type(segment) is str: + key_segments.append((0, segment, 0)) + elif type(segment) is int: + key_segments.append((1, "", segment)) + else: + msg = "component path segments must be exact strings or integers" + raise TypeError(msg) + return tuple(key_segments) + + def _require_exact_fields( data: Mapping[str, JSONValue], *, diff --git a/src/variopt/algorithms/population/csa/manifest/nodes.py b/src/variopt/algorithms/population/csa/manifest/nodes.py new file mode 100644 index 0000000..0c334b0 --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/nodes.py @@ -0,0 +1,21 @@ +"""Structural built-in nodes for CSA configuration manifests.""" + +from collections.abc import Mapping + +from .....json_types import JSONDict, JSONValue + +CSA_BUILTIN_COMPONENT_VERSION = 1 + + +def builtin_component_node( + *, + identifier: str, + configuration: Mapping[str, JSONValue], +) -> JSONDict: + """Return one structurally built-in manifest component node.""" + return { + "kind": "builtin", + "identifier": identifier, + "version": CSA_BUILTIN_COMPONENT_VERSION, + "configuration": dict(configuration), + } diff --git a/src/variopt/algorithms/population/csa/manifest/profile.py b/src/variopt/algorithms/population/csa/manifest/profile.py new file mode 100644 index 0000000..63ade15 --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/profile.py @@ -0,0 +1,489 @@ +"""Projection of canonical resolved CSA profile values.""" + +from typing import TypeVar + +from .....json_types import JSONDict, JSONValue +from .....spaces.serialization import space_candidate_to_dict +from .....spaces.structured import is_space_candidate_value +from ..banking.clustering import CSAClusteringPolicy +from ..banking.growth import CSABankGrowthPolicy +from ..banking.update import CSABankUpdatePolicy, CSANicheQualityPolicy +from ..generation.perturbation import CSAPerturbationSchedule, CSAPerturbationSpec +from ..generation.proposal import CSAProposalPolicy +from ..profile import CSAResolvedProfile +from ..progression.cutoff.policy import ( + CSACutoffSchedule, + CSALocalRouteCutoffSchedule, +) +from ..progression.refresh import CSARefreshPolicy +from ..scoring.acceptance import CSAAcceptancePolicy +from ..scoring.model import ( + CSAAdaptivePotential, + CSAAdaptivePotentialAxis, + CSABiasedPotential, + CSAScoreModel, +) +from .components import project_variation_operator +from .nodes import builtin_component_node +from .resolution import CSAComponentDescriptorResolver, CSAComponentPath + +CandidateT = TypeVar("CandidateT") + + +def project_resolved_profile( + profile: CSAResolvedProfile[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONDict: + """Project every canonical optimizer-facing CSA profile field.""" + return { + "perturbation_schedule": project_perturbation_schedule( + profile.perturbation_schedule, + path=(*path, "perturbation_schedule"), + resolver=resolver, + ), + "proposal_policy": project_proposal_policy( + profile.proposal_policy, + path=(*path, "proposal_policy"), + resolver=resolver, + ), + "seed_count": profile.seed_count, + "initial_new_bank_cut": profile.initial_new_bank_cut, + "random_seed_mode": profile.random_seed_mode, + "weighted_partner_selection": profile.weighted_partner_selection, + "max_bank_capacity": profile.max_bank_capacity, + "cutoff_schedule": project_cutoff_schedule( + profile.cutoff_schedule, + path=(*path, "cutoff_schedule"), + resolver=resolver, + ), + "acceptance_policy": project_acceptance_policy( + profile.acceptance_policy, + path=(*path, "acceptance_policy"), + resolver=resolver, + ), + "clustering_policy": project_clustering_policy( + profile.clustering_policy, + path=(*path, "clustering_policy"), + resolver=resolver, + ), + "growth_policy": project_growth_policy( + profile.growth_policy, + path=(*path, "growth_policy"), + resolver=resolver, + ), + "refresh_policy": project_refresh_policy( + profile.refresh_policy, + path=(*path, "refresh_policy"), + resolver=resolver, + ), + "restart_lite": profile.restart_lite, + "cycle_limit": profile.cycle_limit, + "update_policy": project_update_policy( + profile.update_policy, + path=(*path, "update_policy"), + resolver=resolver, + ), + "score_model": project_score_model( + profile.score_model, + path=(*path, "score_model"), + resolver=resolver, + ), + } + + +def project_perturbation_schedule( + schedule: CSAPerturbationSchedule[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in perturbation schedule.""" + if type(schedule) is not CSAPerturbationSchedule: + return resolver.resolve_custom_component(path) + + return builtin_component_node( + identifier="variopt.csa.perturbation-schedule", + configuration={ + "regular_family": project_perturbation_family( + schedule.regular_family, + path=(*path, "regular_family"), + resolver=resolver, + ), + "initial_family": project_perturbation_family( + schedule.initial_family, + path=(*path, "initial_family"), + resolver=resolver, + ), + "mutation_family": project_perturbation_family( + schedule.mutation_family, + path=(*path, "mutation_family"), + resolver=resolver, + ), + "shuffle_children": schedule.shuffle_children, + }, + ) + + +def project_perturbation_family( + family: tuple[CSAPerturbationSpec[CandidateT], ...], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> list[JSONValue]: + """Project an ordered CSA operator family.""" + return [ + project_perturbation_spec( + spec, + path=(*path, index), + resolver=resolver, + ) + for index, spec in enumerate(family) + ] + + +def project_perturbation_spec( + spec: CSAPerturbationSpec[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in perturbation family member.""" + if type(spec) is not CSAPerturbationSpec: + return resolver.resolve_custom_component(path) + + return builtin_component_node( + identifier="variopt.csa.perturbation-spec", + configuration={ + "operator": project_variation_operator( + spec.operator, + path=(*path, "operator"), + resolver=resolver, + ), + "count": spec.count, + }, + ) + + +def project_proposal_policy( + policy: CSAProposalPolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in adaptive proposal policy.""" + if type(policy) is not CSAProposalPolicy: + return resolver.resolve_custom_component(path) + + return builtin_component_node( + identifier="variopt.csa.proposal-policy", + configuration={ + "enabled": policy.enabled, + "family_bias_strength": policy.family_bias_strength, + "leaf_bias_strength": policy.leaf_bias_strength, + "local_displacement_leaf_bias_strength": ( + policy.local_displacement_leaf_bias_strength + ), + "adaptation_decay": policy.adaptation_decay, + "minimum_family_weight": policy.minimum_family_weight, + "minimum_leaf_weight": policy.minimum_leaf_weight, + "numeric_covariance_strength": policy.numeric_covariance_strength, + "numeric_covariance_min_observations": ( + policy.numeric_covariance_min_observations + ), + "numeric_covariance_ridge": policy.numeric_covariance_ridge, + "local_search_base_budget": policy.local_search_base_budget, + "local_search_max_budget": policy.local_search_max_budget, + "local_search_disable_failure_streak": ( + policy.local_search_disable_failure_streak + ), + "local_search_failure_cooldown_updates": ( + policy.local_search_failure_cooldown_updates + ), + }, + ) + + +def project_cutoff_schedule( + schedule: CSACutoffSchedule, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in cutoff schedule.""" + if isinstance(schedule, CSALocalRouteCutoffSchedule): + if type(schedule) is not CSALocalRouteCutoffSchedule: + return resolver.resolve_custom_component(path) + configuration = cutoff_schedule_configuration(schedule) + configuration["target_local_route_fraction"] = ( + schedule.target_local_route_fraction + ) + configuration["response"] = schedule.response + return builtin_component_node( + identifier="variopt.csa.cutoff-schedule.local-route", + configuration=configuration, + ) + + if type(schedule) is not CSACutoffSchedule: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.cutoff-schedule", + configuration=cutoff_schedule_configuration(schedule), + ) + + +def cutoff_schedule_configuration(schedule: CSACutoffSchedule) -> JSONDict: + """Return the explicitly represented base cutoff configuration.""" + return { + "initial_distance_cutoff": schedule.initial_distance_cutoff, + "minimum_distance_cutoff": schedule.minimum_distance_cutoff, + "initial_distance_divisor": schedule.initial_distance_divisor, + "minimum_distance_divisor": schedule.minimum_distance_divisor, + "reduction_method": schedule.reduction_method, + "reduction_factor": schedule.reduction_factor, + "stagnation_update_limit": schedule.stagnation_update_limit, + "cycle_increment_requires_minimum_cutoff": ( + schedule.cycle_increment_requires_minimum_cutoff + ), + "recover_steps": schedule.recover_steps, + "recover_mode": schedule.recover_mode, + } + + +def project_acceptance_policy( + policy: CSAAcceptancePolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in acceptance policy.""" + if type(policy) is not CSAAcceptancePolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.acceptance-policy", + configuration={ + "initial_temperature": policy.initial_temperature, + "reduction_factor": policy.reduction_factor, + "minimum_temperature": policy.minimum_temperature, + "boltzmann_constant": policy.boltzmann_constant, + "recover": policy.recover, + }, + ) + + +def project_clustering_policy( + policy: CSAClusteringPolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in clustering policy.""" + if type(policy) is not CSAClusteringPolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.clustering-policy", + configuration={ + "enabled": policy.enabled, + "cluster_cutoff_ratio": policy.cluster_cutoff_ratio, + "cluster_distance_ratio": policy.cluster_distance_ratio, + "update_mode": policy.update_mode, + }, + ) + + +def project_growth_policy( + policy: CSABankGrowthPolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in bank-growth policy.""" + if type(policy) is not CSABankGrowthPolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.bank-growth-policy", + configuration={ + "enabled": policy.enabled, + "maximum_capacity": policy.maximum_capacity, + "initial_energy_gap_limit": policy.initial_energy_gap_limit, + "energy_gap_update_mode": policy.energy_gap_update_mode, + "energy_gap_update_factor": policy.energy_gap_update_factor, + "maximum_growth_per_generation": policy.maximum_growth_per_generation, + "require_distance_cutoff": policy.require_distance_cutoff, + }, + ) + + +def project_refresh_policy( + policy: CSARefreshPolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in refresh policy.""" + if type(policy) is not CSARefreshPolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.refresh-policy", + configuration={ + "mode": policy.mode, + "preserve_fraction": policy.preserve_fraction, + "newcomer_first_round": policy.newcomer_first_round, + }, + ) + + +def project_update_policy( + policy: CSABankUpdatePolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in bank-update policy.""" + if type(policy) is not CSABankUpdatePolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.bank-update-policy", + configuration={ + "minimum_significant_score_gap_ratio": ( + policy.minimum_significant_score_gap_ratio + ), + "local_update_mode": policy.local_update_mode, + "far_update_mode": policy.far_update_mode, + "crowding_penalty_ratio": policy.crowding_penalty_ratio, + "niche_quality_policy": project_niche_quality_policy( + policy.niche_quality_policy, + path=(*path, "niche_quality_policy"), + resolver=resolver, + ), + }, + ) + + +def project_niche_quality_policy( + policy: CSANicheQualityPolicy, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in niche-quality policy.""" + if type(policy) is not CSANicheQualityPolicy: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.niche-quality-policy", + configuration={ + "mode": policy.mode, + "ratio": policy.ratio, + }, + ) + + +def project_score_model( + model: CSAScoreModel[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in score model.""" + if type(model) is not CSAScoreModel: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.score-model", + configuration={ + "biased_potential": ( + None + if model.biased_potential is None + else project_biased_potential( + model.biased_potential, + path=(*path, "biased_potential"), + resolver=resolver, + ) + ), + "adaptive_potential": ( + None + if model.adaptive_potential is None + else project_adaptive_potential( + model.adaptive_potential, + path=(*path, "adaptive_potential"), + resolver=resolver, + ) + ), + }, + ) + + +def project_biased_potential( + potential: CSABiasedPotential, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in biased potential.""" + if type(potential) is not CSABiasedPotential: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.biased-potential", + configuration={ + "maximum_bias": potential.maximum_bias, + "sigma": potential.sigma, + "sigma_reference": potential.sigma_reference, + }, + ) + + +def project_adaptive_potential( + potential: CSAAdaptivePotential[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in adaptive potential.""" + if type(potential) is not CSAAdaptivePotential: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.csa.adaptive-potential", + configuration={ + "axes": [ + project_adaptive_potential_axis( + axis, + path=(*path, "axes", index), + resolver=resolver, + ) + for index, axis in enumerate(potential.axes) + ], + "increment": potential.increment, + "overflow_energy": potential.overflow_energy, + }, + ) + + +def project_adaptive_potential_axis( + axis: CSAAdaptivePotentialAxis[CandidateT], + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in adaptive-potential axis.""" + if type(axis) is not CSAAdaptivePotentialAxis: + return resolver.resolve_custom_component(path) + reference_candidate = axis.reference_candidate + if is_space_candidate_value(reference_candidate): + projected_reference_candidate: JSONValue = { + "kind": "structured-candidate", + "value": space_candidate_to_dict(reference_candidate), + } + else: + projected_reference_candidate = resolver.resolve_custom_component( + (*path, "reference_candidate"), + ) + + return builtin_component_node( + identifier="variopt.csa.adaptive-potential-axis", + configuration={ + "reference_candidate": projected_reference_candidate, + "minimum_distance": axis.minimum_distance, + "maximum_distance": axis.maximum_distance, + "bin_count": axis.bin_count, + }, + ) diff --git a/src/variopt/algorithms/population/csa/manifest/projection.py b/src/variopt/algorithms/population/csa/manifest/projection.py new file mode 100644 index 0000000..1d7fb56 --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/projection.py @@ -0,0 +1,78 @@ +"""Root projection from resolved CSA optimizer facts into a manifest.""" + +from collections.abc import Mapping +from typing import TypeVar + +from .....diversity import DiversityMetric +from .....json_types import JSONDict +from .....randomness import RandomSeed +from .....sampling import CandidateSampler +from .....spaces import SearchSpace +from ..profile import CSAResolvedProfile +from .components import project_diversity_metric, project_sampler +from .model import CSAComponentDescriptor, CSAConfigurationManifest +from .profile import project_resolved_profile +from .resolution import CSAComponentDescriptorResolver, CSAComponentPath +from .spaces import project_space + +BoundaryT = TypeVar("BoundaryT") +CandidateT = TypeVar("CandidateT") + + +def project_csa_configuration( + *, + space: SearchSpace[BoundaryT, CandidateT], + diversity_metric: DiversityMetric[CandidateT], + bank_capacity: int, + resolved_profile: CSAResolvedProfile[CandidateT], + sampler: CandidateSampler[CandidateT] | None, + random_state: RandomSeed, + custom_component_descriptors: ( + Mapping[CSAComponentPath, CSAComponentDescriptor] | None + ), +) -> CSAConfigurationManifest: + """Project canonical optimizer-side CSA configuration into a manifest.""" + resolver = CSAComponentDescriptorResolver(custom_component_descriptors) + configuration: JSONDict = { + "bank_capacity": bank_capacity, + "space": project_space( + space, + path=("space",), + resolver=resolver, + ), + "sampler": project_sampler( + sampler, + optimizer_space=space, + path=("sampler",), + resolver=resolver, + ), + "diversity_metric": project_diversity_metric( + diversity_metric, + path=("diversity_metric",), + resolver=resolver, + ), + "random_initialization": project_random_initialization(random_state), + "resolved_profile": project_resolved_profile( + resolved_profile, + path=("resolved_profile",), + resolver=resolver, + ), + } + resolver.require_complete() + return CSAConfigurationManifest(configuration=configuration) + + +def project_random_initialization(random_state: RandomSeed) -> JSONDict: + """Project a public random seed without materializing runtime RNG state.""" + if random_state is None: + return {"mode": "nondeterministic"} + if type(random_state) is not int: + msg = "random_state must be an int or None" + raise TypeError(msg) + if random_state < 0 or random_state >= 2**32: + msg = "random_state must lie in the NumPy uint32 seed range" + raise ValueError(msg) + return { + "mode": "seeded", + "seed": random_state, + } diff --git a/src/variopt/algorithms/population/csa/manifest/resolution.py b/src/variopt/algorithms/population/csa/manifest/resolution.py new file mode 100644 index 0000000..77c25db --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/resolution.py @@ -0,0 +1,87 @@ +"""Semantic custom-component resolution for CSA configuration manifests.""" + +from collections.abc import Mapping +from typing import TypeAlias + +from .....json_types import JSONDict +from .canonical import validate_utf8_string +from .model import CSAComponentDescriptor, CSAConfigurationResolutionError + +CSAComponentPathSegment: TypeAlias = str | int +CSAComponentPath: TypeAlias = tuple[CSAComponentPathSegment, ...] + + +class CSAComponentDescriptorResolver: + """Consume caller descriptors at validated semantic component locations.""" + + __slots__ = ("_consumed_paths", "_descriptors", "_missing_paths") + + _consumed_paths: set[CSAComponentPath] + _descriptors: dict[CSAComponentPath, CSAComponentDescriptor] + _missing_paths: set[CSAComponentPath] + + def __init__( + self, + descriptors: Mapping[CSAComponentPath, CSAComponentDescriptor] | None, + ) -> None: + descriptor_items = () if descriptors is None else descriptors.items() + normalized_descriptors: dict[CSAComponentPath, CSAComponentDescriptor] = {} + for path, descriptor in descriptor_items: + validate_component_path(path) + if type(descriptor) is not CSAComponentDescriptor: + msg = "custom component descriptors must use CSAComponentDescriptor" + raise TypeError(msg) + normalized_descriptors[path] = descriptor + + self._descriptors = normalized_descriptors + self._consumed_paths = set() + self._missing_paths = set() + + def resolve_custom_component( + self, + path: CSAComponentPath, + ) -> JSONDict | None: + """Return one custom descriptor payload or record its absence.""" + descriptor = self._descriptors.get(path) + if descriptor is None: + self._missing_paths.add(path) + return None + + self._consumed_paths.add(path) + return descriptor.to_dict() + + def require_complete(self) -> None: + """Raise when any required or supplied semantic location is unresolved.""" + unused_paths = set(self._descriptors).difference(self._consumed_paths) + if not self._missing_paths and not unused_paths: + return + + raise CSAConfigurationResolutionError( + missing_component_paths=tuple(self._missing_paths), + unused_component_paths=tuple(unused_paths), + ) + + +def validate_component_path(path: CSAComponentPath) -> None: + """Validate one caller-supplied semantic component path.""" + if type(path) is not tuple: + msg = "component paths must be exact built-in tuples" + raise TypeError(msg) + if len(path) == 0: + msg = "component paths must not be empty" + raise ValueError(msg) + + for segment in path: + if type(segment) is str: + if segment == "": + msg = "component path string segments must not be empty" + raise ValueError(msg) + validate_utf8_string(segment, field_name="component path string segment") + continue + if type(segment) is int: + if segment < 0: + msg = "component path integer segments must be non-negative" + raise ValueError(msg) + continue + msg = "component path segments must be exact strings or integers" + raise TypeError(msg) diff --git a/src/variopt/algorithms/population/csa/manifest/spaces.py b/src/variopt/algorithms/population/csa/manifest/spaces.py new file mode 100644 index 0000000..1dde456 --- /dev/null +++ b/src/variopt/algorithms/population/csa/manifest/spaces.py @@ -0,0 +1,145 @@ +"""Recursive exact-type projection of CSA search spaces.""" + +from typing import TypeVar + +from .....json_types import JSONDict, JSONValue +from .....spaces import ( + ArraySpace, + CategoricalSpace, + IntegerSpace, + PermutationSpace, + RealSpace, + RecordSpace, + SearchSpace, + SpaceScalarValue, + TupleSpace, +) +from .....spaces.composites.adapters import CompositeChildSpace +from .nodes import builtin_component_node +from .resolution import CSAComponentDescriptorResolver, CSAComponentPath + +BoundaryT = TypeVar("BoundaryT") +CandidateT = TypeVar("CandidateT") + + +def project_space( + space: SearchSpace[BoundaryT, CandidateT] | CompositeChildSpace, + *, + path: CSAComponentPath, + resolver: CSAComponentDescriptorResolver, +) -> JSONValue: + """Project one exact built-in space or consume its custom descriptor.""" + if isinstance(space, RealSpace): + if type(space) is not RealSpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.real", + configuration={ + "low": space.low, + "high": space.high, + "scale": space.scale, + }, + ) + + if isinstance(space, IntegerSpace): + if type(space) is not IntegerSpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.integer", + configuration={ + "low": space.low, + "high": space.high, + "scale": space.scale, + }, + ) + + if isinstance(space, CategoricalSpace): + if type(space) is not CategoricalSpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.categorical", + configuration={ + "choices": [project_space_scalar(choice) for choice in space.choices], + }, + ) + + if isinstance(space, PermutationSpace): + if type(space) is not PermutationSpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.permutation", + configuration={"size": space.size}, + ) + + if isinstance(space, ArraySpace): + if type(space) is not ArraySpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.array", + configuration={ + "element_space": project_space( + space.element_space, + path=(*path, "element_space"), + resolver=resolver, + ), + "length": space.length, + }, + ) + + if isinstance(space, TupleSpace): + if type(space) is not TupleSpace: + return resolver.resolve_custom_component(path) + return builtin_component_node( + identifier="variopt.space.tuple", + configuration={ + "child_spaces": [ + project_space( + child_space, + path=(*path, "child_spaces", index), + resolver=resolver, + ) + for index, child_space in enumerate(space.child_spaces) + ], + }, + ) + + if isinstance(space, RecordSpace): + if type(space) is not RecordSpace: + return resolver.resolve_custom_component(path) + fields: list[JSONValue] = [] + for index, (name, child_space) in enumerate(space.fields): + fields.append( + { + "name": name, + "space": project_space( + child_space, + path=(*path, "fields", index, "space"), + resolver=resolver, + ), + }, + ) + return builtin_component_node( + identifier="variopt.space.record", + configuration={"fields": fields}, + ) + + return resolver.resolve_custom_component(path) + + +def project_space_scalar(value: SpaceScalarValue) -> JSONDict: + """Project one categorical scalar with exact runtime-type identity.""" + if type(value) is bool: + return {"type": "boolean", "value": value} + if type(value) is int: + return {"type": "integer", "value": value} + if type(value) is float: + return {"type": "float", "value": value} + if type(value) is str: + return {"type": "string", "value": value} + if type(value) is bytes: + return {"type": "bytes", "hex": value.hex()} + if type(value) is bytearray: + return {"type": "bytearray", "hex": value.hex()} + + msg = "categorical choices must use canonical scalar types" + raise TypeError(msg) diff --git a/src/variopt/algorithms/population/csa/optimizer.py b/src/variopt/algorithms/population/csa/optimizer.py index 24c77e4..cd1828a 100644 --- a/src/variopt/algorithms/population/csa/optimizer.py +++ b/src/variopt/algorithms/population/csa/optimizer.py @@ -85,6 +85,8 @@ bind_proposal_provenance, ) from .generation.state import GenerationRuntimeState +from .manifest import CSAComponentDescriptor, CSAConfigurationManifest +from .manifest.projection import project_csa_configuration from .profile import CSAProfile, CSAResolvedProfile from .progression.cutoff.state import CSACutoffState from .progression.stage import CSAStageState @@ -286,6 +288,54 @@ def from_space_defaults( random_state=random_state, ) + def configuration_manifest( + self, + *, + custom_component_descriptors: ( + Mapping[tuple[str | int, ...], CSAComponentDescriptor] | None + ) = None, + ) -> CSAConfigurationManifest: + """Return the canonical manifest for this resolved CSA configuration. + + Parameters + ---------- + custom_component_descriptors : Mapping[tuple[str | int, ...], CSAComponentDescriptor] | None, default=None + Caller-owned descriptions for custom objects keyed by documented + semantic configuration locations. Exact built-in components do not + accept descriptors. + + Returns + ------- + CSAConfigurationManifest + Immutable manifest of optimizer-side execution configuration. + + Raises + ------ + CSAConfigurationResolutionError + If required custom descriptors are missing or supplied descriptors + are not consumed. + TypeError + If descriptor paths, descriptor values, or the random seed have + invalid runtime types. + ValueError + If a descriptor path contains an invalid segment or an integer + random seed lies outside the NumPy ``uint32`` seed range. + + Notes + ----- + The manifest excludes runtime state, RNG snapshots, the optimization + problem, evaluators, kernels, and dependency versions. + """ + return project_csa_configuration( + space=self.space, + diversity_metric=self.diversity_metric, + bank_capacity=self.bank_capacity, + resolved_profile=self.resolved_profile, + sampler=self.sampler, + random_state=self.random_state, + custom_component_descriptors=custom_component_descriptors, + ) + @staticmethod def max_family_arity( family: Sequence[CSAPerturbationSpec[CandidateT]], diff --git a/tests/csa/test_csa_configuration_manifest.py b/tests/csa/test_csa_configuration_manifest.py index d5b6643..e3e99fa 100644 --- a/tests/csa/test_csa_configuration_manifest.py +++ b/tests/csa/test_csa_configuration_manifest.py @@ -10,6 +10,7 @@ from variopt.algorithms.population.csa.manifest import ( CSAComponentDescriptor, CSAConfigurationManifest, + CSAConfigurationResolutionError, ) from variopt.json_types import JSONDict, JSONValue, require_json_mapping @@ -37,6 +38,40 @@ class UnsupportedInteger(int): """Non-JSON integer subclass used to probe exact boundary types.""" +class CSAConfigurationResolutionErrorTests: + """Exercise deterministic unresolved-path reporting.""" + + def test_canonicalizes_and_deduplicates_reported_paths(self) -> None: + error = CSAConfigurationResolutionError( + missing_component_paths=( + ("profile", 10), + ("profile", 2), + ("profile", 2), + ("diversity_metric",), + ), + unused_component_paths=( + ("space", "fields", 1), + ("space", "fields", 0), + ), + ) + + assert error.missing_component_paths == ( + ("diversity_metric",), + ("profile", 2), + ("profile", 10), + ) + assert error.unused_component_paths == ( + ("space", "fields", 0), + ("space", "fields", 1), + ) + assert str(error) == ( + "CSA configuration manifest resolution failed: " + 'missing component paths [["diversity_metric"],["profile",2],' + '["profile",10]]; unused component paths ' + '[["space","fields",0],["space","fields",1]]' + ) + + class CSAComponentDescriptorTests: """Exercise custom component descriptor invariants.""" @@ -64,6 +99,7 @@ def test_manifest_subpackage_exports_only_value_artifacts(self) -> None: assert frozenset(manifest_package.__all__) == { "CSAComponentDescriptor", "CSAConfigurationManifest", + "CSAConfigurationResolutionError", } def test_rejects_builtin_provenance_and_reserved_identifiers(self) -> None: diff --git a/tests/csa/test_csa_configuration_projection.py b/tests/csa/test_csa_configuration_projection.py new file mode 100644 index 0000000..29ee29c --- /dev/null +++ b/tests/csa/test_csa_configuration_projection.py @@ -0,0 +1,1388 @@ +"""Tests for resolved CSA optimizer configuration projection.""" + +from collections.abc import Sequence +from dataclasses import fields, replace + +import numpy as np +import pytest +from typing_extensions import override + +from variopt.algorithms.population.csa import ( + BoundedMutation, + CSAAcceptancePolicy, + CSAAdaptivePotential, + CSAAdaptivePotentialAxis, + CSABankGrowthPolicy, + CSABankUpdatePolicy, + CSABiasedPotential, + CSAClusteringPolicy, + CSACutoffSchedule, + CSALocalRouteCutoffSchedule, + CSANicheQualityPolicy, + CSAOptimizer, + CSAPerturbationSchedule, + CSAPerturbationSpec, + CSAProfile, + CSAProposalPolicy, + CSARefreshPolicy, + CSAScoreModel, + DifferentialEvolutionVariation, + MixtureVariation, + RandomResetMutation, + UniformCrossover, + derive_csa_defaults, +) +from variopt.algorithms.population.csa.manifest import ( + CSAComponentDescriptor, + CSAConfigurationResolutionError, +) +from variopt.algorithms.population.csa.profile import CSAResolvedProfile +from variopt.algorithms.population.permutation import ( + InversionMutation, + OrderCrossover, + SwapMutation, +) +from variopt.diversity import DiversityMetric, StructuredSpaceDiversityMetric +from variopt.json_types import require_json_list, require_json_mapping +from variopt.operators import VariationOperator +from variopt.sampling import CandidateSampler, SearchSpaceSampler +from variopt.spaces import ( + ArraySpace, + CategoricalSpace, + IntegerSpace, + PermutationSpace, + RealSpace, + RecordSpace, + SearchSpace, + TupleSpace, +) + + +def component_descriptor( + identifier: str = "org.example.component", +) -> CSAComponentDescriptor: + """Return one reusable caller-owned custom component descriptor.""" + return CSAComponentDescriptor( + identifier=identifier, + version=1, + configuration={"mode": "test"}, + ) + + +def integer_optimizer( + *, + profile: CSAProfile[int] | None = None, + random_state: int | None = 7, +) -> CSAOptimizer[int, int]: + """Return one exact built-in integer CSA optimizer.""" + return CSAOptimizer.from_space_defaults( + space=IntegerSpace(-10, 10), + bank_capacity=4, + profile=profile, + random_state=random_state, + ) + + +class CustomSpace(SearchSpace[int, int]): + """Opaque integer search space used to exercise custom projection.""" + + @override + def normalize(self, raw_candidate: int) -> int: + return raw_candidate + + @override + def validate(self, candidate: int) -> None: + _ = candidate + + @override + def sample(self, random_state: np.random.RandomState) -> int: + _ = random_state + return 0 + + +class CustomSampler(CandidateSampler[int]): + """Opaque sampler used to exercise custom projection.""" + + @override + def sample(self, random_state: np.random.RandomState) -> int: + _ = random_state + return 0 + + +class CustomMetric(DiversityMetric[int]): + """Opaque metric used to exercise custom projection.""" + + @override + def distance(self, left: int, right: int) -> float: + return float(abs(left - right)) + + +class CustomMutation(VariationOperator[int]): + """Opaque mutation used to exercise custom projection.""" + + @property + @override + def arity(self) -> int: + return 1 + + @override + def apply( + self, + parents: Sequence[int], + random_state: np.random.RandomState, + ) -> int: + _ = random_state + return parents[0] + + +class IntegerSpaceSubclass(IntegerSpace): + """Integer-space subclass that must enter the custom descriptor path.""" + + +class CustomPerturbationSchedule(CSAPerturbationSchedule[int]): + """Perturbation-schedule subclass treated as one custom component.""" + + +class CustomCutoffSchedule(CSACutoffSchedule): + """Cutoff-schedule subclass treated as one custom component.""" + + +class OpaqueInteger(int): + """Integer subclass requiring an opaque configured-value descriptor.""" + + +class DescriptorSubclass(CSAComponentDescriptor): + """Descriptor subclass rejected by the exact manifest boundary.""" + + +class PathString(str): + """String subclass rejected as a semantic path segment.""" + + +class PathTuple(tuple[str | int, ...]): + """Tuple subclass rejected as a semantic component path.""" + + +class CSAConfigurationProjectionTests: + """Exercise exact built-in projection and semantic identity.""" + + def test_projects_nested_builtin_configuration_without_descriptors(self) -> None: + space = RecordSpace( + count=IntegerSpace(1, 5, scale="log"), + ratio=RealSpace(0.1, 2.0, scale="log"), + category=CategoricalSpace( + ( + True, + 2, + 1.5, + "x", + b"\x00", + bytearray(b"\x01"), + ), + ), + pair=TupleSpace(IntegerSpace(0, 2), RealSpace(-1.0, 1.0)), + ) + optimizer = CSAOptimizer.from_space_defaults( + space=space, + bank_capacity=4, + random_state=13, + ) + + manifest = optimizer.configuration_manifest() + configuration = manifest.configuration + projected_space = require_json_mapping( + configuration["space"], + field_name="configuration.space", + ) + projected_space_configuration = require_json_mapping( + projected_space["configuration"], + field_name="configuration.space.configuration", + ) + projected_fields = require_json_list( + projected_space_configuration["fields"], + field_name="configuration.space.configuration.fields", + ) + categorical_field = require_json_mapping( + projected_fields[2], + field_name="configuration.space.configuration.fields[2]", + ) + categorical_space = require_json_mapping( + categorical_field["space"], + field_name="configuration.space.configuration.fields[2].space", + ) + categorical_configuration = require_json_mapping( + categorical_space["configuration"], + field_name=( + "configuration.space.configuration.fields[2].space.configuration" + ), + ) + + assert projected_space["identifier"] == "variopt.space.record" + assert categorical_configuration["choices"] == [ + {"type": "boolean", "value": True}, + {"type": "integer", "value": 2}, + {"type": "float", "value": 1.5}, + {"type": "string", "value": "x"}, + {"type": "bytes", "hex": "00"}, + {"type": "bytearray", "hex": "01"}, + ] + assert configuration["random_initialization"] == { + "mode": "seeded", + "seed": 13, + } + + array_optimizer = CSAOptimizer.from_space_defaults( + space=ArraySpace(IntegerSpace(-2, 2), length=3), + bank_capacity=4, + random_state=13, + ) + assert ( + "variopt.space.array" + in array_optimizer.configuration_manifest().canonical_json() + ) + + def test_permutation_defaults_project_exact_builtin_operators(self) -> None: + optimizer = CSAOptimizer.from_space_defaults( + space=PermutationSpace(size=6), + bank_capacity=4, + random_state=3, + ) + + manifest = optimizer.configuration_manifest() + + assert "variopt.operator.order-crossover" in manifest.canonical_json() + assert "variopt.operator.inversion-mutation" in manifest.canonical_json() + assert "variopt.operator.swap-mutation" in manifest.canonical_json() + + def test_equivalent_resolved_profiles_ignore_boundary_preset_shape(self) -> None: + default_optimizer = integer_optimizer() + resolved = default_optimizer.resolved_profile + explicit_profile = CSAProfile( + perturbation_schedule=resolved.perturbation_schedule, + proposal_policy=resolved.proposal_policy, + seed_count=resolved.seed_count, + initial_new_bank_cut=resolved.initial_new_bank_cut, + random_seed_mode=resolved.random_seed_mode, + weighted_partner_selection=resolved.weighted_partner_selection, + max_bank_capacity=resolved.max_bank_capacity, + cutoff_schedule=resolved.cutoff_schedule, + acceptance_policy=resolved.acceptance_policy, + clustering_policy=resolved.clustering_policy, + growth_policy=resolved.growth_policy, + refresh_policy=resolved.refresh_policy, + restart_lite=resolved.restart_lite, + cycle_limit=resolved.cycle_limit, + update_policy=resolved.update_policy, + score_model=resolved.score_model, + ) + explicit_optimizer = integer_optimizer(profile=explicit_profile) + + assert ( + explicit_optimizer.configuration_manifest() + == default_optimizer.configuration_manifest() + ) + + def test_none_sampler_normalizes_to_the_space_sampler_semantics(self) -> None: + space = IntegerSpace(-10, 10) + defaults = derive_csa_defaults(space) + profile = CSAProfile( + perturbation_schedule=defaults.perturbation_schedule, + ) + implicit_sampler = CSAOptimizer( + space=space, + diversity_metric=defaults.diversity_metric, + bank_capacity=4, + profile=profile, + sampler=None, + random_state=5, + ) + explicit_sampler = replace( + implicit_sampler, + sampler=SearchSpaceSampler(space=space), + ) + + assert ( + implicit_sampler.configuration_manifest() + == explicit_sampler.configuration_manifest() + ) + + def test_object_aliasing_does_not_affect_manifest_content(self) -> None: + shared_space = IntegerSpace(-10, 10) + aliased = CSAOptimizer.from_space_defaults( + space=shared_space, + bank_capacity=4, + random_state=5, + ) + + independent_root_space = IntegerSpace(-10, 10) + independent_schedule = CSAPerturbationSchedule( + regular_family=( + CSAPerturbationSpec( + UniformCrossover(space=IntegerSpace(-10, 10)), + count=2, + ), + ), + initial_family=( + CSAPerturbationSpec( + UniformCrossover(space=IntegerSpace(-10, 10)), + count=2, + ), + ), + mutation_family=( + CSAPerturbationSpec( + BoundedMutation(space=IntegerSpace(-10, 10)), + count=2, + ), + CSAPerturbationSpec( + RandomResetMutation(space=IntegerSpace(-10, 10)), + count=1, + ), + ), + ) + independent = CSAOptimizer( + space=independent_root_space, + diversity_metric=StructuredSpaceDiversityMetric( + space=independent_root_space, + ), + bank_capacity=4, + profile=CSAProfile( + perturbation_schedule=independent_schedule, + ), + sampler=SearchSpaceSampler(space=IntegerSpace(-10, 10)), + random_state=5, + ) + + assert aliased.configuration_manifest() == independent.configuration_manifest() + + def test_every_resolved_profile_axis_affects_fingerprint(self) -> None: + base_optimizer = integer_optimizer() + base_profile = base_optimizer.profile + schedule = base_optimizer.resolved_profile.perturbation_schedule + modified_profiles: tuple[CSAProfile[int], ...] = ( + replace( + base_profile, + perturbation_schedule=replace(schedule, shuffle_children=False), + ), + replace(base_profile, proposal_policy=CSAProposalPolicy(enabled=True)), + replace(base_profile, seed_count=6), + replace(base_profile, initial_new_bank_cut=2), + replace(base_profile, random_seed_mode=1), + replace(base_profile, weighted_partner_selection=True), + replace(base_profile, max_bank_capacity=12), + replace( + base_profile, + cutoff_schedule=CSACutoffSchedule(reduction_factor=0.9), + ), + replace( + base_profile, + acceptance_policy=CSAAcceptancePolicy(initial_temperature=1.0), + ), + replace( + base_profile, + clustering_policy=CSAClusteringPolicy(enabled=True), + ), + replace( + base_profile, + growth_policy=CSABankGrowthPolicy(energy_gap_update_factor=2.0), + ), + replace( + base_profile, + refresh_policy=CSARefreshPolicy(mode="adaptive_refresh"), + ), + replace(base_profile, restart_lite=True), + replace(base_profile, cycle_limit=11), + replace( + base_profile, + update_policy=CSABankUpdatePolicy( + minimum_significant_score_gap_ratio=0.1, + ), + ), + replace( + base_profile, + score_model=CSAScoreModel( + biased_potential=CSABiasedPotential(maximum_bias=12.0), + ), + ), + ) + base_fingerprint = base_optimizer.configuration_manifest().fingerprint + + modified_fingerprints = { + integer_optimizer(profile=profile).configuration_manifest().fingerprint + for profile in modified_profiles + } + + assert base_fingerprint not in modified_fingerprints + assert len(modified_fingerprints) == 16 + + def test_nested_policy_knobs_independently_affect_fingerprint(self) -> None: + baseline = integer_optimizer() + base_profile = baseline.profile + variants: tuple[CSAProfile[int], ...] = ( + replace( + base_profile, + proposal_policy=CSAProposalPolicy(numeric_covariance_ridge=2e-6), + ), + replace( + base_profile, + cutoff_schedule=CSACutoffSchedule( + recover_steps=1, + recover_mode="score_gap_increase", + ), + ), + replace( + base_profile, + cutoff_schedule=CSALocalRouteCutoffSchedule( + target_local_route_fraction=0.3, + ), + ), + replace( + base_profile, + acceptance_policy=CSAAcceptancePolicy( + boltzmann_constant=0.002, + ), + ), + replace( + base_profile, + clustering_policy=CSAClusteringPolicy( + update_mode="current_cluster", + ), + ), + replace( + base_profile, + growth_policy=CSABankGrowthPolicy( + maximum_growth_per_generation=10, + ), + ), + replace( + base_profile, + refresh_policy=CSARefreshPolicy(newcomer_first_round=False), + ), + replace( + base_profile, + update_policy=CSABankUpdatePolicy(crowding_penalty_ratio=0.5), + ), + replace( + base_profile, + update_policy=CSABankUpdatePolicy( + niche_quality_policy=CSANicheQualityPolicy( + mode="mean", + ratio=0.2, + ), + ), + ), + replace( + base_profile, + score_model=CSAScoreModel( + biased_potential=CSABiasedPotential(sigma=0.2), + ), + ), + ) + baseline_fingerprint = baseline.configuration_manifest().fingerprint + + variant_fingerprints = { + integer_optimizer(profile=variant).configuration_manifest().fingerprint + for variant in variants + } + + assert baseline_fingerprint not in variant_fingerprints + assert len(variant_fingerprints) == len(variants) + + def test_operator_order_count_bank_capacity_and_seed_affect_fingerprint( + self, + ) -> None: + space = IntegerSpace(-10, 10) + first_schedule = CSAPerturbationSchedule( + mutation_family=( + CSAPerturbationSpec(BoundedMutation(space=space), count=1), + CSAPerturbationSpec(RandomResetMutation(space=space), count=2), + ), + ) + reordered_schedule = CSAPerturbationSchedule( + mutation_family=tuple(reversed(first_schedule.mutation_family)), + ) + recounted_schedule = replace( + first_schedule, + mutation_family=( + replace(first_schedule.mutation_family[0], count=3), + first_schedule.mutation_family[1], + ), + ) + + def manifest_fingerprint( + *, + schedule: CSAPerturbationSchedule[int], + bank_capacity: int = 4, + random_state: int | None = 7, + ) -> str: + optimizer = CSAOptimizer( + space=space, + diversity_metric=StructuredSpaceDiversityMetric(space=space), + bank_capacity=bank_capacity, + profile=CSAProfile(perturbation_schedule=schedule), + sampler=SearchSpaceSampler(space=space), + random_state=random_state, + ) + return optimizer.configuration_manifest().fingerprint + + fingerprints = { + manifest_fingerprint(schedule=first_schedule), + manifest_fingerprint(schedule=reordered_schedule), + manifest_fingerprint(schedule=recounted_schedule), + manifest_fingerprint(schedule=first_schedule, bank_capacity=5), + manifest_fingerprint(schedule=first_schedule, random_state=None), + } + + assert len(fingerprints) == 5 + + def test_ordered_record_fields_and_categorical_binary_types_affect_identity( + self, + ) -> None: + ordered = CSAOptimizer.from_space_defaults( + space=RecordSpace( + first=IntegerSpace(0, 2), + second=RealSpace(0.0, 1.0), + ), + bank_capacity=4, + random_state=1, + ) + reordered = CSAOptimizer.from_space_defaults( + space=RecordSpace( + second=RealSpace(0.0, 1.0), + first=IntegerSpace(0, 2), + ), + bank_capacity=4, + random_state=1, + ) + bytes_space = CSAOptimizer.from_space_defaults( + space=CategoricalSpace((b"x",)), + bank_capacity=4, + random_state=1, + ) + bytearray_space = CSAOptimizer.from_space_defaults( + space=CategoricalSpace((bytearray(b"x"),)), + bank_capacity=4, + random_state=1, + ) + + assert ( + ordered.configuration_manifest().fingerprint + != reordered.configuration_manifest().fingerprint + ) + assert ( + bytes_space.configuration_manifest().fingerprint + != bytearray_space.configuration_manifest().fingerprint + ) + + def test_nondeterministic_initialization_is_explicit_and_stable(self) -> None: + optimizer = integer_optimizer(random_state=None) + + first_manifest = optimizer.configuration_manifest() + second_manifest = optimizer.configuration_manifest() + + assert first_manifest == second_manifest + assert first_manifest.configuration["random_initialization"] == { + "mode": "nondeterministic", + } + + def test_projects_local_route_adaptation_and_nested_score_models(self) -> None: + space = IntegerSpace(-10, 10) + regular_operator = MixtureVariation( + ( + UniformCrossover(space=space, max_exchange_fraction=0.4), + DifferentialEvolutionVariation( + space=space, + mutation_range=(0.4, 0.8), + recombination_probability=0.6, + n_cross=1, + ), + ), + weights=(1.0, 2.0), + ) + profile = CSAProfile( + perturbation_schedule=CSAPerturbationSchedule( + regular_family=(CSAPerturbationSpec(regular_operator),), + mutation_family=(CSAPerturbationSpec(BoundedMutation(space=space)),), + ), + cutoff_schedule=CSALocalRouteCutoffSchedule( + target_local_route_fraction=0.3, + response=3.0, + ), + update_policy=CSABankUpdatePolicy( + niche_quality_policy=CSANicheQualityPolicy( + mode="mean", + ratio=0.25, + ), + ), + score_model=CSAScoreModel( + biased_potential=CSABiasedPotential( + maximum_bias=50.0, + sigma=0.2, + sigma_reference="constant", + ), + adaptive_potential=CSAAdaptivePotential( + axes=( + CSAAdaptivePotentialAxis( + reference_candidate=0, + minimum_distance=0.0, + maximum_distance=1.0, + bin_count=5, + ), + ), + increment=0.2, + overflow_energy=200.0, + ), + ), + ) + optimizer = CSAOptimizer( + space=space, + diversity_metric=StructuredSpaceDiversityMetric(space=space), + bank_capacity=4, + profile=profile, + sampler=SearchSpaceSampler(space=space), + random_state=5, + ) + + canonical_json = optimizer.configuration_manifest().canonical_json() + + assert "variopt.operator.mixture" in canonical_json + assert "variopt.operator.differential-evolution" in canonical_json + assert "variopt.csa.cutoff-schedule.local-route" in canonical_json + assert "variopt.csa.niche-quality-policy" in canonical_json + assert "variopt.csa.adaptive-potential-axis" in canonical_json + + @pytest.mark.parametrize("random_state", [True, -1, 2**32]) + def test_rejects_random_initialization_that_execution_cannot_materialize( + self, + random_state: int, + ) -> None: + optimizer = integer_optimizer(random_state=random_state) + + with pytest.raises((TypeError, ValueError)): + optimizer.configuration_manifest() + + def test_accepts_the_maximum_numpy_uint32_seed(self) -> None: + manifest = integer_optimizer( + random_state=2**32 - 1, + ).configuration_manifest() + + assert manifest.configuration["random_initialization"] == { + "mode": "seeded", + "seed": 2**32 - 1, + } + + def test_each_builtin_space_topology_axis_affects_fingerprint(self) -> None: + real_bound_left = CSAOptimizer.from_space_defaults( + space=RealSpace(0.0, 1.0), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + real_bound_right = CSAOptimizer.from_space_defaults( + space=RealSpace(0.0, 2.0), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + real_linear = CSAOptimizer.from_space_defaults( + space=RealSpace(1.0, 2.0, scale="linear"), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + real_log = CSAOptimizer.from_space_defaults( + space=RealSpace(1.0, 2.0, scale="log"), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + category_forward = CSAOptimizer.from_space_defaults( + space=CategoricalSpace(("a", "b")), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + category_reverse = CSAOptimizer.from_space_defaults( + space=CategoricalSpace(("b", "a")), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + array_short = CSAOptimizer.from_space_defaults( + space=ArraySpace(IntegerSpace(0, 2), length=2), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + array_long = CSAOptimizer.from_space_defaults( + space=ArraySpace(IntegerSpace(0, 2), length=3), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + tuple_forward = CSAOptimizer.from_space_defaults( + space=TupleSpace(IntegerSpace(0, 2), RealSpace(0.0, 1.0)), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + tuple_reverse = CSAOptimizer.from_space_defaults( + space=TupleSpace(RealSpace(0.0, 1.0), IntegerSpace(0, 2)), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + permutation_short = CSAOptimizer.from_space_defaults( + space=PermutationSpace(4), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + permutation_long = CSAOptimizer.from_space_defaults( + space=PermutationSpace(5), + bank_capacity=4, + random_state=1, + ).configuration_manifest() + + assert real_bound_left.fingerprint != real_bound_right.fingerprint + assert real_linear.fingerprint != real_log.fingerprint + assert category_forward.fingerprint != category_reverse.fingerprint + assert array_short.fingerprint != array_long.fingerprint + assert tuple_forward.fingerprint != tuple_reverse.fingerprint + assert permutation_short.fingerprint != permutation_long.fingerprint + + def test_manifest_projection_does_not_advance_or_replace_engine_state(self) -> None: + optimizer = integer_optimizer(random_state=19) + state_before = optimizer.create_initial_state() + + _ = optimizer.configuration_manifest() + state_after = optimizer.create_initial_state() + + assert state_after == state_before + + +class CSAConfigurationCustomResolutionTests: + """Exercise custom occurrence resolution and aggregate diagnostics.""" + + def test_collects_missing_and_unused_paths_before_failing(self) -> None: + optimizer = CSAOptimizer( + space=CustomSpace(), + diversity_metric=CustomMetric(), + bank_capacity=4, + profile=CSAProfile( + perturbation_schedule=CSAPerturbationSchedule( + mutation_family=(CSAPerturbationSpec(CustomMutation()),), + ), + ), + sampler=CustomSampler(), + random_state=0, + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest( + custom_component_descriptors={ + ("unused",): component_descriptor("org.example.unused"), + }, + ) + + assert exc_info.value.missing_component_paths == ( + ("diversity_metric",), + ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 0, + "operator", + ), + ("sampler",), + ("space",), + ) + assert exc_info.value.unused_component_paths == (("unused",),) + + def test_custom_configuration_succeeds_when_every_occurrence_is_described( + self, + ) -> None: + optimizer = CSAOptimizer( + space=CustomSpace(), + diversity_metric=CustomMetric(), + bank_capacity=4, + profile=CSAProfile( + perturbation_schedule=CSAPerturbationSchedule( + mutation_family=(CSAPerturbationSpec(CustomMutation()),), + ), + ), + sampler=CustomSampler(), + random_state=0, + ) + descriptor = component_descriptor() + + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + ("space",): descriptor, + ("sampler",): descriptor, + ("diversity_metric",): descriptor, + ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 0, + "operator", + ): descriptor, + }, + ) + + assert manifest.configuration["space"] == descriptor.to_dict() + assert "org.example.component" in manifest.canonical_json() + + def test_custom_descriptor_version_changes_projected_fingerprint(self) -> None: + optimizer = CSAOptimizer( + space=CustomSpace(), + diversity_metric=CustomMetric(), + bank_capacity=4, + profile=CSAProfile( + perturbation_schedule=CSAPerturbationSchedule( + mutation_family=(CSAPerturbationSpec(CustomMutation()),), + ), + ), + sampler=CustomSampler(), + random_state=0, + ) + paths = ( + ("space",), + ("sampler",), + ("diversity_metric",), + ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 0, + "operator", + ), + ) + version_one = CSAComponentDescriptor( + identifier="org.example.component", + version=1, + configuration={}, + ) + version_two = CSAComponentDescriptor( + identifier="org.example.component", + version=2, + configuration={}, + ) + + first_manifest = optimizer.configuration_manifest( + custom_component_descriptors={path: version_one for path in paths}, + ) + second_manifest = optimizer.configuration_manifest( + custom_component_descriptors={path: version_two for path in paths}, + ) + + assert first_manifest.fingerprint != second_manifest.fingerprint + + def test_custom_parent_consumes_only_its_own_semantic_location(self) -> None: + space = IntegerSpace(-10, 10) + schedule = CustomPerturbationSchedule( + mutation_family=(CSAPerturbationSpec(BoundedMutation(space=space)),), + ) + optimizer = CSAOptimizer( + space=space, + diversity_metric=StructuredSpaceDiversityMetric(space=space), + bank_capacity=4, + profile=CSAProfile(perturbation_schedule=schedule), + random_state=0, + ) + schedule_path = ("resolved_profile", "perturbation_schedule") + nested_operator_path = ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 0, + "operator", + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest( + custom_component_descriptors={ + nested_operator_path: component_descriptor(), + }, + ) + + assert exc_info.value.missing_component_paths == (schedule_path,) + assert exc_info.value.unused_component_paths == (nested_operator_path,) + + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + schedule_path: component_descriptor("org.example.schedule"), + }, + ) + assert "org.example.schedule" in manifest.canonical_json() + + def test_custom_policy_subclass_does_not_leak_builtin_fields(self) -> None: + profile = CSAProfile[int]( + cutoff_schedule=CustomCutoffSchedule(), + ) + optimizer = integer_optimizer(profile=profile) + path = ("resolved_profile", "cutoff_schedule") + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest() + + assert exc_info.value.missing_component_paths == (path,) + + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + path: component_descriptor("org.example.cutoff"), + }, + ) + assert "org.example.cutoff" in manifest.canonical_json() + + def test_nested_mixture_reports_the_exact_missing_child_operator(self) -> None: + space = IntegerSpace(-10, 10) + mixture = MixtureVariation[int]( + ( + BoundedMutation(space=space), + CustomMutation(), + ), + ) + profile = CSAProfile( + perturbation_schedule=CSAPerturbationSchedule( + regular_family=(CSAPerturbationSpec(mixture),), + ), + ) + optimizer = CSAOptimizer( + space=space, + diversity_metric=StructuredSpaceDiversityMetric(space=space), + bank_capacity=4, + profile=profile, + random_state=0, + ) + path = ( + "resolved_profile", + "perturbation_schedule", + "regular_family", + 0, + "operator", + "operators", + 1, + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest() + + assert exc_info.value.missing_component_paths == (path,) + + def test_space_subclass_requires_a_descriptor_at_each_semantic_occurrence( + self, + ) -> None: + space = IntegerSpaceSubclass(-10, 10) + optimizer = CSAOptimizer.from_space_defaults( + space=space, + bank_capacity=4, + random_state=0, + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest() + + assert set(exc_info.value.missing_component_paths) == { + ("space",), + ("sampler", "space"), + ("diversity_metric", "space"), + ( + "resolved_profile", + "perturbation_schedule", + "regular_family", + 0, + "operator", + "space", + ), + ( + "resolved_profile", + "perturbation_schedule", + "initial_family", + 0, + "operator", + "space", + ), + ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 0, + "operator", + "space", + ), + ( + "resolved_profile", + "perturbation_schedule", + "mutation_family", + 1, + "operator", + "space", + ), + } + + descriptor = component_descriptor("org.example.integer-space") + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + path: descriptor for path in exc_info.value.missing_component_paths + }, + ) + + assert manifest.configuration["space"] == descriptor.to_dict() + + def test_record_field_names_do_not_leak_into_component_path_segments(self) -> None: + space = RecordSpace(**{"": IntegerSpaceSubclass(0, 3)}) + optimizer = CSAOptimizer.from_space_defaults( + space=space, + bank_capacity=4, + random_state=0, + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest() + + assert ("space", "fields", 0, "space") in ( + exc_info.value.missing_component_paths + ) + assert all("" not in path for path in exc_info.value.missing_component_paths) + + descriptor = component_descriptor("org.example.record-child") + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + path: descriptor for path in exc_info.value.missing_component_paths + }, + ) + assert "org.example.record-child" in manifest.canonical_json() + + def test_descriptor_for_exact_builtin_or_unknown_path_is_unused(self) -> None: + descriptor = component_descriptor() + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + ("space",): descriptor, + ("unknown",): descriptor, + }, + ) + + assert exc_info.value.missing_component_paths == () + assert exc_info.value.unused_component_paths == ( + ("space",), + ("unknown",), + ) + + def test_mixed_component_paths_sort_integer_segments_numerically(self) -> None: + descriptor = component_descriptor() + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + ("unknown", 10**100): descriptor, + ("unknown", 2): descriptor, + }, + ) + + assert exc_info.value.unused_component_paths == ( + ("unknown", 2), + ("unknown", 10**100), + ) + + def test_opaque_adaptive_reference_requires_its_own_descriptor(self) -> None: + axis: CSAAdaptivePotentialAxis[int] = CSAAdaptivePotentialAxis( + reference_candidate=OpaqueInteger(0), + minimum_distance=0.0, + maximum_distance=1.0, + bin_count=2, + ) + adaptive_potential: CSAAdaptivePotential[int] = CSAAdaptivePotential( + axes=(axis,), + ) + score_model: CSAScoreModel[int] = CSAScoreModel( + adaptive_potential=adaptive_potential, + ) + profile: CSAProfile[int] = CSAProfile( + score_model=score_model, + ) + optimizer = integer_optimizer(profile=profile) + reference_path = ( + "resolved_profile", + "score_model", + "adaptive_potential", + "axes", + 0, + "reference_candidate", + ) + + with pytest.raises(CSAConfigurationResolutionError) as exc_info: + optimizer.configuration_manifest() + + assert exc_info.value.missing_component_paths == (reference_path,) + + manifest = optimizer.configuration_manifest( + custom_component_descriptors={ + reference_path: component_descriptor("org.example.reference"), + }, + ) + assert "org.example.reference" in manifest.canonical_json() + + @pytest.mark.parametrize( + "path", + [ + (), + ("",), + (-1,), + ], + ) + def test_rejects_invalid_path_values( + self, + path: tuple[str | int, ...], + ) -> None: + with pytest.raises(ValueError): + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + path: component_descriptor(), + }, + ) + + def test_rejects_bool_and_scalar_subclass_path_segments(self) -> None: + for path in ((True,), (PathString("space"),)): + with pytest.raises(TypeError, match="exact strings or integers"): + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + path: component_descriptor(), + }, + ) + + def test_rejects_path_strings_that_are_not_valid_utf8(self) -> None: + with pytest.raises(ValueError, match="valid UTF-8 text"): + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + ("\ud800",): component_descriptor(), + }, + ) + + def test_rejects_path_and_descriptor_subclasses(self) -> None: + with pytest.raises(TypeError, match="exact built-in tuples"): + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + PathTuple(("space",)): component_descriptor(), + }, + ) + + with pytest.raises(TypeError, match="must use CSAComponentDescriptor"): + integer_optimizer().configuration_manifest( + custom_component_descriptors={ + ("space",): DescriptorSubclass( + identifier="org.example.subclass", + version=1, + configuration={}, + ), + }, + ) + + +class CSAConfigurationProjectionCoverageTests: + """Lock every manually projected built-in dataclass field.""" + + def test_space_and_component_dataclass_fields_are_explicitly_accounted_for( + self, + ) -> None: + # Compatibility slots and compiled caches are deliberately listed here + # even though projection excludes them from execution configuration. + assert {field.name for field in fields(RealSpace)} == {"low", "high", "scale"} + assert {field.name for field in fields(IntegerSpace)} == { + "low", + "high", + "scale", + } + assert {field.name for field in fields(CategoricalSpace)} == {"choices"} + assert {field.name for field in fields(PermutationSpace)} == { + "size", + "_index_space", + } + assert {field.name for field in fields(ArraySpace)} == { + "element_space", + "length", + } + assert {field.name for field in fields(SearchSpaceSampler)} == { + "__orig_class__", + "space", + } + assert {field.name for field in fields(StructuredSpaceDiversityMetric)} == { + "__orig_class__", + "space", + "geometry", + "part_values_geometry", + "validated_part_values_geometry", + "compiled_geometry_plan", + } + assert {field.name for field in fields(UniformCrossover)} == { + "__orig_class__", + "space", + "structured_space", + "max_exchange_fraction", + } + assert {field.name for field in fields(RandomResetMutation)} == { + "__orig_class__", + "space", + "structured_space", + "max_exchange_fraction", + } + assert {field.name for field in fields(BoundedMutation)} == { + "__orig_class__", + "space", + "structured_space", + "max_perturbation_fraction", + } + assert {field.name for field in fields(DifferentialEvolutionVariation)} == { + "__orig_class__", + "space", + "structured_space", + "mutation_range", + "recombination_probability", + "n_cross", + } + assert {field.name for field in fields(MixtureVariation)} == { + "__orig_class__", + "operators", + "weights", + } + assert {field.name for field in fields(OrderCrossover)} == { + "space", + "max_segment_fraction", + } + assert {field.name for field in fields(SwapMutation)} == { + "space", + "max_swap_fraction", + } + assert {field.name for field in fields(InversionMutation)} == { + "space", + "max_inversion_fraction", + } + + def test_resolved_profile_dataclass_fields_are_explicitly_accounted_for( + self, + ) -> None: + assert {field.name for field in fields(CSAResolvedProfile)} == { + "__orig_class__", + "perturbation_schedule", + "proposal_policy", + "seed_count", + "initial_new_bank_cut", + "random_seed_mode", + "weighted_partner_selection", + "max_bank_capacity", + "cutoff_schedule", + "acceptance_policy", + "clustering_policy", + "growth_policy", + "refresh_policy", + "restart_lite", + "cycle_limit", + "update_policy", + "score_model", + } + assert {field.name for field in fields(CSAPerturbationSchedule)} == { + "__orig_class__", + "regular_family", + "initial_family", + "mutation_family", + "shuffle_children", + } + assert {field.name for field in fields(CSAPerturbationSpec)} == { + "__orig_class__", + "operator", + "count", + } + assert {field.name for field in fields(CSAProposalPolicy)} == { + "enabled", + "family_bias_strength", + "leaf_bias_strength", + "local_displacement_leaf_bias_strength", + "adaptation_decay", + "minimum_family_weight", + "minimum_leaf_weight", + "numeric_covariance_strength", + "numeric_covariance_min_observations", + "numeric_covariance_ridge", + "local_search_base_budget", + "local_search_max_budget", + "local_search_disable_failure_streak", + "local_search_failure_cooldown_updates", + } + assert {field.name for field in fields(CSACutoffSchedule)} == { + "initial_distance_cutoff", + "minimum_distance_cutoff", + "initial_distance_divisor", + "minimum_distance_divisor", + "reduction_method", + "reduction_factor", + "stagnation_update_limit", + "cycle_increment_requires_minimum_cutoff", + "recover_steps", + "recover_mode", + } + assert {field.name for field in fields(CSALocalRouteCutoffSchedule)} == { + "initial_distance_cutoff", + "minimum_distance_cutoff", + "initial_distance_divisor", + "minimum_distance_divisor", + "reduction_method", + "reduction_factor", + "stagnation_update_limit", + "cycle_increment_requires_minimum_cutoff", + "recover_steps", + "recover_mode", + "target_local_route_fraction", + "response", + } + assert {field.name for field in fields(CSAAcceptancePolicy)} == { + "initial_temperature", + "reduction_factor", + "minimum_temperature", + "boltzmann_constant", + "recover", + } + assert {field.name for field in fields(CSAClusteringPolicy)} == { + "enabled", + "cluster_cutoff_ratio", + "cluster_distance_ratio", + "update_mode", + } + assert {field.name for field in fields(CSABankGrowthPolicy)} == { + "enabled", + "maximum_capacity", + "initial_energy_gap_limit", + "energy_gap_update_mode", + "energy_gap_update_factor", + "maximum_growth_per_generation", + "require_distance_cutoff", + } + assert {field.name for field in fields(CSARefreshPolicy)} == { + "mode", + "preserve_fraction", + "newcomer_first_round", + } + assert {field.name for field in fields(CSANicheQualityPolicy)} == { + "mode", + "ratio", + } + assert {field.name for field in fields(CSABankUpdatePolicy)} == { + "minimum_significant_score_gap_ratio", + "local_update_mode", + "far_update_mode", + "crowding_penalty_ratio", + "niche_quality_policy", + } + assert {field.name for field in fields(CSABiasedPotential)} == { + "maximum_bias", + "sigma", + "sigma_reference", + } + assert {field.name for field in fields(CSAAdaptivePotentialAxis)} == { + "__orig_class__", + "reference_candidate", + "minimum_distance", + "maximum_distance", + "bin_count", + } + assert {field.name for field in fields(CSAAdaptivePotential)} == { + "__orig_class__", + "axes", + "increment", + "overflow_energy", + } + assert {field.name for field in fields(CSAScoreModel)} == { + "__orig_class__", + "biased_potential", + "adaptive_potential", + } diff --git a/tests/population/test_population_exports.py b/tests/population/test_population_exports.py index c6b082d..d3ac068 100644 --- a/tests/population/test_population_exports.py +++ b/tests/population/test_population_exports.py @@ -4,6 +4,7 @@ import variopt.algorithms.population as population_algorithms import variopt.algorithms.population.clearing_ga as clearing_ga_algorithms import variopt.algorithms.population.csa as csa_algorithms +import variopt.algorithms.population.csa.manifest as csa_manifest import variopt.algorithms.population.csa.progression.cutoff as csa_cutoff import variopt.algorithms.population.de as de_algorithms import variopt.algorithms.population.ga as ga_algorithms @@ -14,10 +15,13 @@ import variopt.algorithms.population.species_ga as species_ga_algorithms EXPECTED_POPULATION_ALL = ( - "ClearingGAProfile", - "ClearingGeneticAlgorithmOptimizer", + "CSAComponentDescriptor", + "CSAConfigurationManifest", + "CSAConfigurationResolutionError", "CSAOptimizer", "CSAProfile", + "ClearingGAProfile", + "ClearingGeneticAlgorithmOptimizer", "DEProfile", "DifferentialEvolutionOptimizer", "GAProfile", @@ -41,20 +45,23 @@ "CSAAcceptancePolicy", "CSAAdaptivePotential", "CSAAdaptivePotentialAxis", + "CSABankGrowthPolicy", + "CSABankUpdatePolicy", + "CSABiasedPotential", + "CSAClusteringPolicy", + "CSAComponentDescriptor", + "CSAConfigurationManifest", + "CSAConfigurationResolutionError", "CSACutoffObservation", "CSACutoffSchedule", - "CSALocalRouteCutoffSchedule", "CSADefaultComponents", - "CSAClusteringPolicy", - "CSABankUpdatePolicy", - "CSABiasedPotential", - "CSABankGrowthPolicy", + "CSALocalRouteCutoffSchedule", "CSANicheQualityPolicy", "CSAOptimizer", - "CSAProfile", - "CSAProposalPolicy", "CSAPerturbationSchedule", "CSAPerturbationSpec", + "CSAProfile", + "CSAProposalPolicy", "CSARefreshPolicy", "CSAScoreModel", "DifferentialEvolutionVariation", @@ -95,6 +102,18 @@ def test_population_facade_reexports_population_family_entry_points(self) -> Non assert frozenset(population_algorithms.__all__) == frozenset( EXPECTED_POPULATION_ALL ) + assert ( + population_algorithms.CSAComponentDescriptor + is csa_manifest.CSAComponentDescriptor + ) + assert ( + population_algorithms.CSAConfigurationManifest + is csa_manifest.CSAConfigurationManifest + ) + assert ( + population_algorithms.CSAConfigurationResolutionError + is csa_manifest.CSAConfigurationResolutionError + ) assert population_algorithms.CSAOptimizer is csa_algorithms.CSAOptimizer assert population_algorithms.CSAProfile is csa_algorithms.CSAProfile assert population_algorithms.DEProfile is de_algorithms.DEProfile @@ -208,10 +227,21 @@ def test_root_algorithms_facade_remains_convenience_reexport(self) -> None: class CSAFacadeExportTests: - """Lock the advanced CSA policy facade surface.""" + """Lock the advanced CSA facade surface.""" - def test_csa_facade_reexports_cutoff_contracts(self) -> None: + def test_csa_facade_reexports_supported_contracts(self) -> None: assert frozenset(csa_algorithms.__all__) == frozenset(EXPECTED_CSA_ALL) + assert ( + csa_algorithms.CSAComponentDescriptor is csa_manifest.CSAComponentDescriptor + ) + assert ( + csa_algorithms.CSAConfigurationManifest + is csa_manifest.CSAConfigurationManifest + ) + assert ( + csa_algorithms.CSAConfigurationResolutionError + is csa_manifest.CSAConfigurationResolutionError + ) assert csa_algorithms.CSACutoffObservation is csa_cutoff.CSACutoffObservation assert csa_algorithms.CSACutoffSchedule is csa_cutoff.CSACutoffSchedule assert ( diff --git a/tests/release_support.py b/tests/release_support.py index b63d6ed..262df20 100644 --- a/tests/release_support.py +++ b/tests/release_support.py @@ -12,6 +12,7 @@ "variopt.study", "variopt.algorithms", "variopt.algorithms.population", + "variopt.algorithms.population.csa", "variopt.algorithms.local_search", "variopt.spaces.projections", ) @@ -26,6 +27,9 @@ ("variopt.evaluators", "MpiEvaluator"), ("variopt.evaluators", "MpiExecutorFactory"), ("variopt.algorithms.population", "CSAOptimizer"), + ("variopt.algorithms.population", "CSAComponentDescriptor"), + ("variopt.algorithms.population", "CSAConfigurationManifest"), + ("variopt.algorithms.population", "CSAConfigurationResolutionError"), ("variopt.algorithms.population", "DifferentialEvolutionOptimizer"), ("variopt.algorithms.local_search", "StructuredHillClimbKernel"), ("variopt.algorithms.local_search", "ScipyMinimizeKernel"),