adaptcompile 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adaptcompile/__init__.py +30 -0
- adaptcompile/_validation.py +46 -0
- adaptcompile/dataset.py +160 -0
- adaptcompile/episode.py +110 -0
- adaptcompile/errors.py +13 -0
- adaptcompile/family.py +103 -0
- adaptcompile/geometry.py +111 -0
- adaptcompile/model.py +93 -0
- adaptcompile/program.py +121 -0
- adaptcompile/py.typed +1 -0
- adaptcompile/result.py +115 -0
- adaptcompile/serialization.py +101 -0
- adaptcompile/study.py +207 -0
- adaptcompile-0.1.0.dist-info/METADATA +129 -0
- adaptcompile-0.1.0.dist-info/RECORD +18 -0
- adaptcompile-0.1.0.dist-info/WHEEL +5 -0
- adaptcompile-0.1.0.dist-info/licenses/LICENSE +21 -0
- adaptcompile-0.1.0.dist-info/top_level.txt +1 -0
adaptcompile/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Public API for adaptcompile."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _distribution_version
|
|
4
|
+
|
|
5
|
+
from .dataset import AdaptationDataset
|
|
6
|
+
from .episode import LearningEpisode
|
|
7
|
+
from .errors import AdaptCompileError, SerializationError, ValidationError
|
|
8
|
+
from .family import ProgramFamily
|
|
9
|
+
from .geometry import AdaptationGeometry
|
|
10
|
+
from .model import ModelContext
|
|
11
|
+
from .program import ProgramSpec
|
|
12
|
+
from .result import AdaptationResult
|
|
13
|
+
from .study import AdaptationStudy
|
|
14
|
+
|
|
15
|
+
__version__ = _distribution_version("adaptcompile")
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AdaptCompileError",
|
|
19
|
+
"AdaptationDataset",
|
|
20
|
+
"AdaptationGeometry",
|
|
21
|
+
"AdaptationResult",
|
|
22
|
+
"AdaptationStudy",
|
|
23
|
+
"LearningEpisode",
|
|
24
|
+
"ModelContext",
|
|
25
|
+
"ProgramFamily",
|
|
26
|
+
"ProgramSpec",
|
|
27
|
+
"SerializationError",
|
|
28
|
+
"ValidationError",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Internal validation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from numbers import Real
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import ValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def nonempty_name(value: Any, *, field: str) -> str:
|
|
15
|
+
if not isinstance(value, str) or not value.strip():
|
|
16
|
+
raise ValidationError(f"{field} must be a non-empty string")
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def string_keyed_mapping(value: Any, *, field: str) -> Mapping[str, Any]:
|
|
21
|
+
if not isinstance(value, Mapping):
|
|
22
|
+
raise ValidationError(f"{field} must be a mapping")
|
|
23
|
+
copied: dict[str, Any] = {}
|
|
24
|
+
for key, item in value.items():
|
|
25
|
+
nonempty_name(key, field=f"{field} key")
|
|
26
|
+
copied[key] = item
|
|
27
|
+
return MappingProxyType(copied)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def score(value: Any, *, field: str) -> float:
|
|
31
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
32
|
+
raise ValidationError(f"{field} must be a real number")
|
|
33
|
+
converted = float(value)
|
|
34
|
+
if not math.isfinite(converted):
|
|
35
|
+
raise ValidationError(f"{field} must be finite")
|
|
36
|
+
return converted
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def score_mapping(value: Any, *, field: str) -> Mapping[str, float]:
|
|
40
|
+
mapping = string_keyed_mapping(value, field=field)
|
|
41
|
+
return MappingProxyType(
|
|
42
|
+
{
|
|
43
|
+
name: score(item, field=f"{field}[{name!r}]")
|
|
44
|
+
for name, item in mapping.items()
|
|
45
|
+
}
|
|
46
|
+
)
|
adaptcompile/dataset.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Corpus-level collections of observed adaptations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
|
|
7
|
+
|
|
8
|
+
from .episode import EpisodeIdentityKey, _episode_identity_key
|
|
9
|
+
from .errors import ValidationError
|
|
10
|
+
from .result import AdaptationResult
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pandas import DataFrame
|
|
14
|
+
|
|
15
|
+
ModelIdentityKey = tuple[str, str | None, str | None]
|
|
16
|
+
GroupKey = TypeVar("GroupKey", bound=Hashable)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AdaptationDataset(Sequence[AdaptationResult]):
|
|
20
|
+
"""An immutable corpus of observations spanning models, episodes, and programs.
|
|
21
|
+
|
|
22
|
+
Repeated ``(model, episode, program)`` observations are retained because seeds,
|
|
23
|
+
experiment runs, and measured outcomes may legitimately differ.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("_results",)
|
|
27
|
+
|
|
28
|
+
def __init__(self, results: Iterable[AdaptationResult]) -> None:
|
|
29
|
+
collected = tuple(results)
|
|
30
|
+
if not collected:
|
|
31
|
+
raise ValidationError("an AdaptationDataset requires at least one result")
|
|
32
|
+
if any(not isinstance(result, AdaptationResult) for result in collected):
|
|
33
|
+
raise ValidationError(
|
|
34
|
+
"all dataset entries must be AdaptationResult instances"
|
|
35
|
+
)
|
|
36
|
+
self._results = collected
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def results(self) -> tuple[AdaptationResult, ...]:
|
|
40
|
+
return self._results
|
|
41
|
+
|
|
42
|
+
@overload
|
|
43
|
+
def __getitem__(self, index: int) -> AdaptationResult: ...
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def __getitem__(self, index: slice) -> tuple[AdaptationResult, ...]: ...
|
|
47
|
+
|
|
48
|
+
def __getitem__(
|
|
49
|
+
self, index: int | slice
|
|
50
|
+
) -> AdaptationResult | tuple[AdaptationResult, ...]:
|
|
51
|
+
return self._results[index]
|
|
52
|
+
|
|
53
|
+
def __len__(self) -> int:
|
|
54
|
+
return len(self._results)
|
|
55
|
+
|
|
56
|
+
def __iter__(self) -> Iterator[AdaptationResult]:
|
|
57
|
+
return iter(self._results)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"AdaptationDataset(n_results={len(self)})"
|
|
61
|
+
|
|
62
|
+
def by_model(self) -> dict[ModelIdentityKey, AdaptationDataset]:
|
|
63
|
+
"""Group by ``ModelContext.identity_key``."""
|
|
64
|
+
return self._grouped(lambda result: result.model_context.identity_key)
|
|
65
|
+
|
|
66
|
+
def by_episode(self) -> dict[EpisodeIdentityKey, AdaptationDataset]:
|
|
67
|
+
"""Group by explicit ID or conservative serialized episode configuration."""
|
|
68
|
+
return self._grouped(lambda result: _episode_identity_key(result.episode))
|
|
69
|
+
|
|
70
|
+
def by_family(self) -> dict[str | None, AdaptationDataset]:
|
|
71
|
+
"""Group by conceptual fingerprint; ``None`` retains familyless programs."""
|
|
72
|
+
return self._grouped(
|
|
73
|
+
lambda result: (
|
|
74
|
+
result.program.family.fingerprint
|
|
75
|
+
if result.program.family is not None
|
|
76
|
+
else None
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def by_program(self) -> dict[str, AdaptationDataset]:
|
|
81
|
+
"""Group by concrete executable program fingerprint."""
|
|
82
|
+
return self._grouped(lambda result: result.program.fingerprint)
|
|
83
|
+
|
|
84
|
+
def filter(
|
|
85
|
+
self, predicate: Callable[[AdaptationResult], bool]
|
|
86
|
+
) -> AdaptationDataset:
|
|
87
|
+
"""Return observations matching *predicate*.
|
|
88
|
+
|
|
89
|
+
Like direct construction, an empty selection raises :class:`ValidationError`.
|
|
90
|
+
"""
|
|
91
|
+
return AdaptationDataset(result for result in self if predicate(result))
|
|
92
|
+
|
|
93
|
+
def to_records(self) -> list[dict[str, Any]]:
|
|
94
|
+
"""Return unambiguous flat observation records with nested run metadata."""
|
|
95
|
+
records: list[dict[str, Any]] = []
|
|
96
|
+
for result in self:
|
|
97
|
+
family = result.program.family
|
|
98
|
+
record: dict[str, Any] = {
|
|
99
|
+
"model_id": result.model_context.model_id,
|
|
100
|
+
"model_revision": result.model_context.revision,
|
|
101
|
+
"base_state_id": result.model_context.base_state_id,
|
|
102
|
+
"episode": result.episode.name,
|
|
103
|
+
"episode_id": result.episode.episode_id,
|
|
104
|
+
"program_family_fingerprint": (
|
|
105
|
+
family.fingerprint if family is not None else None
|
|
106
|
+
),
|
|
107
|
+
"program_family_name": family.name if family is not None else None,
|
|
108
|
+
"program_family_method": (
|
|
109
|
+
family.method if family is not None else None
|
|
110
|
+
),
|
|
111
|
+
"program_family_parameters": (
|
|
112
|
+
family.to_dict()["parameters"] if family is not None else None
|
|
113
|
+
),
|
|
114
|
+
"program_fingerprint": result.program.fingerprint,
|
|
115
|
+
"program_name": result.program.name,
|
|
116
|
+
"method": result.program.method,
|
|
117
|
+
"program_parameters": result.program.to_dict()["parameters"],
|
|
118
|
+
"metadata": result.to_dict()["metadata"],
|
|
119
|
+
}
|
|
120
|
+
record.update(
|
|
121
|
+
{
|
|
122
|
+
f"before_{name}": value
|
|
123
|
+
for name, value in result.before_geometry.items()
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
record.update(
|
|
127
|
+
{
|
|
128
|
+
f"after_{name}": value
|
|
129
|
+
for name, value in result.after_geometry.items()
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
record.update(
|
|
133
|
+
{
|
|
134
|
+
f"delta_{name}": value
|
|
135
|
+
for name, value in result.delta_geometry.items()
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
records.append(record)
|
|
139
|
+
return records
|
|
140
|
+
|
|
141
|
+
def to_dataframe(self) -> DataFrame:
|
|
142
|
+
"""Return a pandas DataFrame (requires the ``dataframe`` extra)."""
|
|
143
|
+
try:
|
|
144
|
+
from pandas import DataFrame
|
|
145
|
+
except ImportError as error: # pragma: no cover - depends on environment
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"to_dataframe() requires pandas; install adaptcompile[dataframe]"
|
|
148
|
+
) from error
|
|
149
|
+
return cast("DataFrame", DataFrame(self.to_records()))
|
|
150
|
+
|
|
151
|
+
def _grouped(
|
|
152
|
+
self, key: Callable[[AdaptationResult], GroupKey]
|
|
153
|
+
) -> dict[GroupKey, AdaptationDataset]:
|
|
154
|
+
grouped: dict[GroupKey, list[AdaptationResult]] = {}
|
|
155
|
+
for result in self:
|
|
156
|
+
grouped.setdefault(key(result), []).append(result)
|
|
157
|
+
return {
|
|
158
|
+
identity: AdaptationDataset(results)
|
|
159
|
+
for identity, results in grouped.items()
|
|
160
|
+
}
|
adaptcompile/episode.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Learning episode representation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from os import PathLike
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._validation import nonempty_name, string_keyed_mapping
|
|
11
|
+
from .errors import ValidationError
|
|
12
|
+
from .serialization import (
|
|
13
|
+
immutable_json_mapping,
|
|
14
|
+
json_dumps,
|
|
15
|
+
read_json,
|
|
16
|
+
to_json_safe,
|
|
17
|
+
write_json,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
EpisodeIdentityKey = tuple[str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class LearningEpisode:
|
|
25
|
+
"""A model-independent learning problem and its evaluation sets.
|
|
26
|
+
|
|
27
|
+
Dataset-like objects are held by reference and deliberately excluded from
|
|
28
|
+
serialization. The evaluation mapping and metadata are detached and read-only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
train: Any
|
|
33
|
+
evaluations: Mapping[str, Any]
|
|
34
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
35
|
+
episode_id: str | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
object.__setattr__(self, "name", nonempty_name(self.name, field="name"))
|
|
39
|
+
evaluations = string_keyed_mapping(self.evaluations, field="evaluations")
|
|
40
|
+
if not evaluations:
|
|
41
|
+
raise ValidationError(
|
|
42
|
+
"evaluations must contain at least one evaluation set"
|
|
43
|
+
)
|
|
44
|
+
object.__setattr__(self, "evaluations", evaluations)
|
|
45
|
+
object.__setattr__(
|
|
46
|
+
self,
|
|
47
|
+
"metadata",
|
|
48
|
+
immutable_json_mapping(self.metadata, location="metadata"),
|
|
49
|
+
)
|
|
50
|
+
if self.episode_id is not None:
|
|
51
|
+
object.__setattr__(
|
|
52
|
+
self,
|
|
53
|
+
"episode_id",
|
|
54
|
+
nonempty_name(self.episode_id, field="episode_id"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def evaluation_names(self) -> tuple[str, ...]:
|
|
59
|
+
"""Names of the declared evaluation sets, in construction order."""
|
|
60
|
+
return tuple(self.evaluations)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Serialize configuration only; dataset contents are intentionally omitted."""
|
|
64
|
+
return {
|
|
65
|
+
"name": self.name,
|
|
66
|
+
"episode_id": self.episode_id,
|
|
67
|
+
"evaluation_names": list(self.evaluation_names),
|
|
68
|
+
"metadata": to_json_safe(self.metadata, location="metadata"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: Mapping[str, Any]) -> LearningEpisode:
|
|
73
|
+
"""Restore configuration with ``None`` placeholders for dataset objects."""
|
|
74
|
+
try:
|
|
75
|
+
names = data["evaluation_names"]
|
|
76
|
+
evaluations = {name: None for name in names}
|
|
77
|
+
return cls(
|
|
78
|
+
name=data["name"],
|
|
79
|
+
train=None,
|
|
80
|
+
evaluations=evaluations,
|
|
81
|
+
metadata=data.get("metadata", {}),
|
|
82
|
+
episode_id=data.get("episode_id"),
|
|
83
|
+
)
|
|
84
|
+
except (KeyError, TypeError) as error:
|
|
85
|
+
raise ValidationError("malformed serialized LearningEpisode") from error
|
|
86
|
+
|
|
87
|
+
def to_json(
|
|
88
|
+
self,
|
|
89
|
+
destination: str | PathLike[str] | None = None,
|
|
90
|
+
*,
|
|
91
|
+
indent: int | None = 2,
|
|
92
|
+
) -> str:
|
|
93
|
+
return write_json(self.to_dict(), destination, indent=indent)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_json(cls, source: str | PathLike[str]) -> LearningEpisode:
|
|
97
|
+
return cls.from_dict(read_json(source))
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
return (
|
|
101
|
+
f"LearningEpisode(name={self.name!r}, train={type(self.train).__name__}, "
|
|
102
|
+
f"evaluations={list(self.evaluations)!r}, episode_id={self.episode_id!r})"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _episode_identity_key(episode: LearningEpisode) -> EpisodeIdentityKey:
|
|
107
|
+
"""Return the conservative, dataset-independent identity used by collections."""
|
|
108
|
+
if episode.episode_id is not None:
|
|
109
|
+
return ("episode_id", episode.episode_id)
|
|
110
|
+
return ("configuration", json_dumps(episode.to_dict(), indent=None))
|
adaptcompile/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Package-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AdaptCompileError(Exception):
|
|
5
|
+
"""Base exception for errors raised by adaptcompile."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(AdaptCompileError, ValueError):
|
|
9
|
+
"""Raised when an adaptcompile value is malformed or inconsistent."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SerializationError(AdaptCompileError, TypeError):
|
|
13
|
+
"""Raised when a record cannot be represented safely as JSON."""
|
adaptcompile/family.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Conceptual adaptation program families."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ._validation import nonempty_name
|
|
13
|
+
from .errors import ValidationError
|
|
14
|
+
from .serialization import (
|
|
15
|
+
immutable_json_mapping,
|
|
16
|
+
read_json,
|
|
17
|
+
to_json_safe,
|
|
18
|
+
write_json,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ProgramFamily:
|
|
24
|
+
"""A conceptual adaptation choice, before model-specific realization.
|
|
25
|
+
|
|
26
|
+
Structural equality includes every field. ``fingerprint`` identifies only the
|
|
27
|
+
conceptual semantics expressed by ``method`` and ``parameters``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
method: str
|
|
32
|
+
parameters: Mapping[str, Any]
|
|
33
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
object.__setattr__(self, "name", nonempty_name(self.name, field="name"))
|
|
37
|
+
object.__setattr__(self, "method", nonempty_name(self.method, field="method"))
|
|
38
|
+
object.__setattr__(
|
|
39
|
+
self,
|
|
40
|
+
"parameters",
|
|
41
|
+
immutable_json_mapping(self.parameters, location="parameters"),
|
|
42
|
+
)
|
|
43
|
+
object.__setattr__(
|
|
44
|
+
self,
|
|
45
|
+
"metadata",
|
|
46
|
+
immutable_json_mapping(self.metadata, location="metadata"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def fingerprint(self) -> str:
|
|
51
|
+
"""Stable SHA-256 identity derived from method and parameters only."""
|
|
52
|
+
conceptual = {
|
|
53
|
+
"method": self.method,
|
|
54
|
+
"parameters": to_json_safe(self.parameters, location="parameters"),
|
|
55
|
+
}
|
|
56
|
+
canonical = json.dumps(
|
|
57
|
+
conceptual,
|
|
58
|
+
sort_keys=True,
|
|
59
|
+
separators=(",", ":"),
|
|
60
|
+
allow_nan=False,
|
|
61
|
+
)
|
|
62
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"name": self.name,
|
|
67
|
+
"method": self.method,
|
|
68
|
+
"parameters": to_json_safe(self.parameters, location="parameters"),
|
|
69
|
+
"metadata": to_json_safe(self.metadata, location="metadata"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ProgramFamily:
|
|
74
|
+
try:
|
|
75
|
+
return cls(
|
|
76
|
+
name=data["name"],
|
|
77
|
+
method=data["method"],
|
|
78
|
+
parameters=data["parameters"],
|
|
79
|
+
metadata=data.get("metadata", {}),
|
|
80
|
+
)
|
|
81
|
+
except (KeyError, TypeError) as error:
|
|
82
|
+
raise ValidationError("malformed serialized ProgramFamily") from error
|
|
83
|
+
|
|
84
|
+
def to_json(
|
|
85
|
+
self,
|
|
86
|
+
destination: str | PathLike[str] | None = None,
|
|
87
|
+
*,
|
|
88
|
+
indent: int | None = 2,
|
|
89
|
+
) -> str:
|
|
90
|
+
return write_json(self.to_dict(), destination, indent=indent)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_json(cls, source: str | PathLike[str]) -> ProgramFamily:
|
|
94
|
+
return cls.from_dict(read_json(source))
|
|
95
|
+
|
|
96
|
+
def __hash__(self) -> int:
|
|
97
|
+
canonical = json.dumps(
|
|
98
|
+
to_json_safe(self.to_dict()),
|
|
99
|
+
sort_keys=True,
|
|
100
|
+
separators=(",", ":"),
|
|
101
|
+
allow_nan=False,
|
|
102
|
+
)
|
|
103
|
+
return hash(canonical)
|
adaptcompile/geometry.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Multidimensional behavioral geometry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
|
+
|
|
9
|
+
from ._validation import score_mapping
|
|
10
|
+
from .errors import ValidationError
|
|
11
|
+
from .serialization import read_json, write_json
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pandas import DataFrame
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AdaptationGeometry(Mapping[str, float]):
|
|
18
|
+
"""An immutable, arbitrary-dimensional mapping of behavioral measurements."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("_metrics",)
|
|
21
|
+
|
|
22
|
+
def __init__(self, metrics: Mapping[str, float]) -> None:
|
|
23
|
+
normalized = score_mapping(metrics, field="metrics")
|
|
24
|
+
if not normalized:
|
|
25
|
+
raise ValidationError("geometry must contain at least one metric")
|
|
26
|
+
self._metrics = normalized
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def metrics(self) -> Mapping[str, float]:
|
|
30
|
+
"""A read-only metric mapping."""
|
|
31
|
+
return self._metrics
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def metric_names(self) -> tuple[str, ...]:
|
|
35
|
+
return tuple(self._metrics)
|
|
36
|
+
|
|
37
|
+
def __getitem__(self, name: str) -> float:
|
|
38
|
+
try:
|
|
39
|
+
return self._metrics[name]
|
|
40
|
+
except KeyError as error:
|
|
41
|
+
raise KeyError(f"unknown geometry metric {name!r}") from error
|
|
42
|
+
|
|
43
|
+
def __iter__(self) -> Iterator[str]:
|
|
44
|
+
return iter(self._metrics)
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self._metrics)
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"AdaptationGeometry({dict(self._metrics)!r})"
|
|
51
|
+
|
|
52
|
+
def __eq__(self, other: object) -> bool:
|
|
53
|
+
if not isinstance(other, AdaptationGeometry):
|
|
54
|
+
return NotImplemented
|
|
55
|
+
return dict(self._metrics) == dict(other._metrics)
|
|
56
|
+
|
|
57
|
+
def __hash__(self) -> int:
|
|
58
|
+
return hash(frozenset(self._metrics.items()))
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, float]:
|
|
61
|
+
return dict(self._metrics)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: Mapping[str, Any]) -> AdaptationGeometry:
|
|
65
|
+
return cls(data)
|
|
66
|
+
|
|
67
|
+
def to_json(
|
|
68
|
+
self,
|
|
69
|
+
destination: str | PathLike[str] | None = None,
|
|
70
|
+
*,
|
|
71
|
+
indent: int | None = 2,
|
|
72
|
+
) -> str:
|
|
73
|
+
return write_json(self.to_dict(), destination, indent=indent)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, source: str | PathLike[str]) -> AdaptationGeometry:
|
|
77
|
+
return cls.from_dict(read_json(source))
|
|
78
|
+
|
|
79
|
+
def compare(self, other: AdaptationGeometry) -> AdaptationGeometry:
|
|
80
|
+
"""Return ``self - other`` after requiring identical metric dimensions."""
|
|
81
|
+
self._require_comparable(other)
|
|
82
|
+
return AdaptationGeometry(
|
|
83
|
+
{name: value - other[name] for name, value in self._metrics.items()}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def delta(self, baseline: AdaptationGeometry) -> AdaptationGeometry:
|
|
87
|
+
"""Alias for :meth:`compare`, naming *baseline* explicitly."""
|
|
88
|
+
return self.compare(baseline)
|
|
89
|
+
|
|
90
|
+
def to_dataframe(self) -> DataFrame:
|
|
91
|
+
"""Return a one-row pandas DataFrame (requires the ``dataframe`` extra)."""
|
|
92
|
+
try:
|
|
93
|
+
from pandas import DataFrame
|
|
94
|
+
except ImportError as error: # pragma: no cover - depends on environment
|
|
95
|
+
raise ImportError(
|
|
96
|
+
"to_dataframe() requires pandas; install adaptcompile[dataframe]"
|
|
97
|
+
) from error
|
|
98
|
+
return cast("DataFrame", DataFrame([self.to_dict()]))
|
|
99
|
+
|
|
100
|
+
def _require_comparable(self, other: AdaptationGeometry) -> None:
|
|
101
|
+
if not isinstance(other, AdaptationGeometry):
|
|
102
|
+
raise TypeError("other must be an AdaptationGeometry")
|
|
103
|
+
own = set(self._metrics)
|
|
104
|
+
theirs = set(other._metrics)
|
|
105
|
+
if own != theirs:
|
|
106
|
+
missing = sorted(own - theirs)
|
|
107
|
+
extra = sorted(theirs - own)
|
|
108
|
+
raise ValidationError(
|
|
109
|
+
"geometry metrics do not match "
|
|
110
|
+
f"(missing from other={missing}, extra in other={extra})"
|
|
111
|
+
)
|
adaptcompile/model.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Declarative model and base-state context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from os import PathLike
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._validation import nonempty_name
|
|
11
|
+
from .errors import ValidationError
|
|
12
|
+
from .serialization import immutable_json_mapping, read_json, to_json_safe, write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModelContext:
|
|
17
|
+
"""A declarative description of the pre-adaptation model/base state.
|
|
18
|
+
|
|
19
|
+
This record identifies a model state; it does not contain, load, or inspect
|
|
20
|
+
model weights and has no dependency on a model framework.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_id: str
|
|
24
|
+
revision: str | None = None
|
|
25
|
+
base_state_id: str | None = None
|
|
26
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
object.__setattr__(
|
|
30
|
+
self,
|
|
31
|
+
"model_id",
|
|
32
|
+
nonempty_name(self.model_id, field="model_id"),
|
|
33
|
+
)
|
|
34
|
+
if self.revision is not None:
|
|
35
|
+
object.__setattr__(
|
|
36
|
+
self,
|
|
37
|
+
"revision",
|
|
38
|
+
nonempty_name(self.revision, field="revision"),
|
|
39
|
+
)
|
|
40
|
+
if self.base_state_id is not None:
|
|
41
|
+
object.__setattr__(
|
|
42
|
+
self,
|
|
43
|
+
"base_state_id",
|
|
44
|
+
nonempty_name(self.base_state_id, field="base_state_id"),
|
|
45
|
+
)
|
|
46
|
+
object.__setattr__(
|
|
47
|
+
self,
|
|
48
|
+
"metadata",
|
|
49
|
+
immutable_json_mapping(self.metadata, location="metadata"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def identity_key(self) -> tuple[str, str | None, str | None]:
|
|
54
|
+
"""Identity used to hold model/base state fixed within a study."""
|
|
55
|
+
return (self.model_id, self.revision, self.base_state_id)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"model_id": self.model_id,
|
|
60
|
+
"revision": self.revision,
|
|
61
|
+
"base_state_id": self.base_state_id,
|
|
62
|
+
"metadata": to_json_safe(self.metadata, location="metadata"),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ModelContext:
|
|
67
|
+
try:
|
|
68
|
+
return cls(
|
|
69
|
+
model_id=data["model_id"],
|
|
70
|
+
revision=data.get("revision"),
|
|
71
|
+
base_state_id=data.get("base_state_id"),
|
|
72
|
+
metadata=data.get("metadata", {}),
|
|
73
|
+
)
|
|
74
|
+
except (KeyError, TypeError) as error:
|
|
75
|
+
raise ValidationError("malformed serialized ModelContext") from error
|
|
76
|
+
|
|
77
|
+
def to_json(
|
|
78
|
+
self,
|
|
79
|
+
destination: str | PathLike[str] | None = None,
|
|
80
|
+
*,
|
|
81
|
+
indent: int | None = 2,
|
|
82
|
+
) -> str:
|
|
83
|
+
return write_json(self.to_dict(), destination, indent=indent)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_json(cls, source: str | PathLike[str]) -> ModelContext:
|
|
87
|
+
return cls.from_dict(read_json(source))
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return (
|
|
91
|
+
f"ModelContext(model_id={self.model_id!r}, revision={self.revision!r}, "
|
|
92
|
+
f"base_state_id={self.base_state_id!r})"
|
|
93
|
+
)
|