reqm 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.
- reqm/__init__.py +6 -0
- reqm/overrides_ext.py +165 -0
- reqm/quant.py +120 -0
- reqm/quant_manager.py +264 -0
- reqm-0.1.0.dist-info/METADATA +256 -0
- reqm-0.1.0.dist-info/RECORD +8 -0
- reqm-0.1.0.dist-info/WHEEL +4 -0
- reqm-0.1.0.dist-info/licenses/LICENSE +21 -0
reqm/__init__.py
ADDED
reqm/overrides_ext.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
overrides_ext.py — Extended override utilities for reqm.
|
|
3
|
+
|
|
4
|
+
A thin extension of the `overrides` library that adds `allow_any_override`,
|
|
5
|
+
a marker for abstract methods whose signature subclasses are permitted to narrow.
|
|
6
|
+
|
|
7
|
+
The problem this solves:
|
|
8
|
+
Quant defines ``__call__(self, **kwargs)`` as abstract so subclasses can
|
|
9
|
+
narrow it to any specific signature (e.g. ``__call__(self, text: str)``).
|
|
10
|
+
The vanilla ``@override`` would reject this as a signature mismatch.
|
|
11
|
+
``@allow_any_override`` on the base method tells our ``@override`` to skip
|
|
12
|
+
signature checking for that particular method — while keeping it enforced
|
|
13
|
+
for all other overrides.
|
|
14
|
+
|
|
15
|
+
Public API:
|
|
16
|
+
allow_any_override — marker decorator for base methods
|
|
17
|
+
override — drop-in for @override, respects @allow_any_override
|
|
18
|
+
final — re-export from overrides library (unchanged)
|
|
19
|
+
EnforceOverrides — re-export from overrides library (unchanged)
|
|
20
|
+
|
|
21
|
+
Usage::
|
|
22
|
+
|
|
23
|
+
from reqm.overrides_ext import allow_any_override, override, EnforceOverrides
|
|
24
|
+
|
|
25
|
+
class Base(EnforceOverrides):
|
|
26
|
+
@abstractmethod
|
|
27
|
+
@allow_any_override
|
|
28
|
+
def __call__(self, **kwargs) -> Any: ... # any signature OK
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def name(self) -> str: ... # signature enforced
|
|
32
|
+
|
|
33
|
+
class Child(Base):
|
|
34
|
+
@override
|
|
35
|
+
def __call__(self, text: str) -> str: ... # different sig — allowed
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def name(self) -> str: ... # same sig — required
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import sys
|
|
42
|
+
import typing as ty
|
|
43
|
+
|
|
44
|
+
from overrides import EnforceOverrides, final
|
|
45
|
+
from overrides.overrides import _get_base_classes, _overrides
|
|
46
|
+
|
|
47
|
+
F = ty.TypeVar("F", bound=ty.Callable[..., ty.Any])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _should_enforce_signature(method: ty.Callable) -> bool:
|
|
51
|
+
"""Decide whether signature checking applies for this override.
|
|
52
|
+
|
|
53
|
+
Returns False if the method itself carries ``@allow_any_override``, or if
|
|
54
|
+
every parent definition of the same method carries it. Returns True as
|
|
55
|
+
soon as any parent definition is found *without* the marker.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
TypeError: If no parent class defines a method with the same name.
|
|
59
|
+
"""
|
|
60
|
+
if getattr(method, "__allow_any_override__", False):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
global_vars = getattr(method, "__globals__", None)
|
|
64
|
+
if global_vars is None:
|
|
65
|
+
global_vars = vars(sys.modules[method.__module__])
|
|
66
|
+
|
|
67
|
+
found = False
|
|
68
|
+
for super_class in _get_base_classes(sys._getframe(3), global_vars):
|
|
69
|
+
parent_method = getattr(super_class, method.__name__, None)
|
|
70
|
+
if parent_method is None:
|
|
71
|
+
continue
|
|
72
|
+
found = True
|
|
73
|
+
if not getattr(parent_method, "__allow_any_override__", False):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
if not found:
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"'{method.__qualname__}' does not override any method in its "
|
|
79
|
+
f"base classes. Remove @override or check the method name."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def allow_any_override(method: F) -> F:
|
|
86
|
+
"""Mark an abstract method as permitting override with any signature.
|
|
87
|
+
|
|
88
|
+
Place this on base class methods where subclasses are expected to narrow
|
|
89
|
+
the signature (e.g. ``**kwargs`` → specific parameters). Without this
|
|
90
|
+
marker, ``@override`` would reject the signature change as an LSP
|
|
91
|
+
violation.
|
|
92
|
+
|
|
93
|
+
This is a marker only — it sets ``__allow_any_override__ = True`` on the
|
|
94
|
+
method. The check is performed by the ``@override`` decorator in this
|
|
95
|
+
module at class-creation time.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
method: The abstract method to mark.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The same method, with ``__allow_any_override__ = True`` set.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
Mark a base method::
|
|
105
|
+
|
|
106
|
+
class Base(EnforceOverrides):
|
|
107
|
+
@abstractmethod
|
|
108
|
+
@allow_any_override
|
|
109
|
+
def __call__(self, **kwargs) -> Any: ...
|
|
110
|
+
|
|
111
|
+
Subclass freely narrows the signature::
|
|
112
|
+
|
|
113
|
+
class Child(Base):
|
|
114
|
+
@override
|
|
115
|
+
def __call__(self, text: str, max_tokens: int = 512) -> str:
|
|
116
|
+
...
|
|
117
|
+
"""
|
|
118
|
+
method.__allow_any_override__ = True # type: ignore[attr-defined]
|
|
119
|
+
return method
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def override(method: F) -> F:
|
|
123
|
+
"""Override decorator that respects ``@allow_any_override`` on parent methods.
|
|
124
|
+
|
|
125
|
+
Drop-in replacement for ``@override`` from the ``overrides`` library.
|
|
126
|
+
Behaviour:
|
|
127
|
+
- If the parent method has ``@allow_any_override``: installs the override
|
|
128
|
+
without signature validation.
|
|
129
|
+
- Otherwise: delegates to vanilla ``_overrides`` with full signature
|
|
130
|
+
checking.
|
|
131
|
+
|
|
132
|
+
Resolution happens at class-body execution time via the ``overrides``
|
|
133
|
+
library's ``_get_base_classes`` frame introspection.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
method: The overriding method.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The method, marked with ``__override__ = True``.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
TypeError: If ``method`` does not override any parent method.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
Narrowing a signature (allowed because parent has @allow_any_override)::
|
|
146
|
+
|
|
147
|
+
class Summarizer(Quant):
|
|
148
|
+
@override
|
|
149
|
+
def __call__(self, text: str, max_tokens: int = 512) -> str:
|
|
150
|
+
...
|
|
151
|
+
|
|
152
|
+
Normal override with signature enforcement::
|
|
153
|
+
|
|
154
|
+
class Summarizer(Quant):
|
|
155
|
+
@override
|
|
156
|
+
def dummy_inputs(self) -> list[dict[str, Any]]:
|
|
157
|
+
return [{"text": "hello"}]
|
|
158
|
+
"""
|
|
159
|
+
method.__override__ = True # type: ignore[attr-defined]
|
|
160
|
+
if _should_enforce_signature(method):
|
|
161
|
+
_overrides(method, check_signature=True, check_at_runtime=False)
|
|
162
|
+
return method
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["allow_any_override", "override", "final", "EnforceOverrides"]
|
reqm/quant.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quant.py — The Quant abstract base class.
|
|
3
|
+
|
|
4
|
+
A Quant is the unit reqm builds and manages: a callable with constructor args
|
|
5
|
+
defined in config, and a dummy_inputs method that makes it auditable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import abc
|
|
9
|
+
|
|
10
|
+
from reqm.overrides_ext import EnforceOverrides, allow_any_override
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Quant(abc.ABC, EnforceOverrides):
|
|
14
|
+
"""Abstract base class for all reqm Quants.
|
|
15
|
+
|
|
16
|
+
A Quant is a callable unit managed by reqm. It is:
|
|
17
|
+
|
|
18
|
+
- **Callable** — invoked directly with its inputs via ``__call__``
|
|
19
|
+
- **Config-driven** — constructor arguments defined in YAML, not hardcoded
|
|
20
|
+
- **Auditable** — ``dummy_inputs()`` provides example inputs that reqm uses
|
|
21
|
+
to verify the Quant actually runs at build time, not silently at inference
|
|
22
|
+
|
|
23
|
+
Subclasses must implement:
|
|
24
|
+
|
|
25
|
+
- ``__call__(**kwargs)`` — narrowed to the specific input signature
|
|
26
|
+
- ``dummy_inputs()`` — returns a list of input dicts, each ``**``-expandable
|
|
27
|
+
into ``__call__``
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
Define a Quant::
|
|
31
|
+
|
|
32
|
+
from reqm import Quant
|
|
33
|
+
from reqm.overrides_ext import override
|
|
34
|
+
|
|
35
|
+
class Summarizer(Quant):
|
|
36
|
+
def __init__(self, model_name: str, max_tokens: int):
|
|
37
|
+
self.model = load_model(model_name)
|
|
38
|
+
self.max_tokens = max_tokens
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def __call__(self, text: str) -> str:
|
|
42
|
+
return self.model.summarize(text, max_tokens=self.max_tokens)
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def dummy_inputs(self) -> list[dict[str, Any]]:
|
|
46
|
+
return [
|
|
47
|
+
{"text": "The quick brown fox."},
|
|
48
|
+
{"text": "Short."},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
reqm calls ``dummy_inputs`` at build time::
|
|
52
|
+
|
|
53
|
+
for inputs in quant.dummy_inputs():
|
|
54
|
+
quant(**inputs) # fails fast here if something is broken
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abc.abstractmethod
|
|
58
|
+
@allow_any_override
|
|
59
|
+
def __call__(self, **kwargs) -> object:
|
|
60
|
+
"""Call the Quant with the given inputs.
|
|
61
|
+
|
|
62
|
+
Subclasses must override this with their specific input signature.
|
|
63
|
+
The base signature is ``**kwargs`` to allow any narrowing — see
|
|
64
|
+
``allow_any_override`` for why this is intentional.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
**kwargs: Inputs to the Quant. The actual parameters are defined
|
|
68
|
+
by the subclass.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The Quant's output. Type is defined by the subclass.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
Subclass narrowing the signature::
|
|
75
|
+
|
|
76
|
+
class Greeter(Quant):
|
|
77
|
+
@override
|
|
78
|
+
def __call__(self, name: str) -> str:
|
|
79
|
+
return f"Hello, {name}!"
|
|
80
|
+
|
|
81
|
+
Calling the Quant::
|
|
82
|
+
|
|
83
|
+
greeter = reqm.get("greeter/friendly")
|
|
84
|
+
result = greeter(name="world")
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
@abc.abstractmethod
|
|
89
|
+
def dummy_inputs(self) -> list[dict[str, object]]:
|
|
90
|
+
"""Return a list of example input dicts for build-time sanity checking.
|
|
91
|
+
|
|
92
|
+
Each dict maps argument names to example values. reqm ``**``-expands
|
|
93
|
+
each dict into ``__call__`` when building the Quant. If any call fails,
|
|
94
|
+
the error is raised immediately with context — fail fast, not in prod.
|
|
95
|
+
|
|
96
|
+
Multiple dicts are encouraged: cover the happy path and edge cases.
|
|
97
|
+
These also serve as living documentation of valid call signatures.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A non-empty list of dicts, each ``**``-expandable into ``__call__``.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
NotImplementedError: If not implemented by the subclass.
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
Single input set::
|
|
107
|
+
|
|
108
|
+
def dummy_inputs(self) -> list[dict[str, Any]]:
|
|
109
|
+
return [{"text": "The quick brown fox."}]
|
|
110
|
+
|
|
111
|
+
Multiple input sets (recommended — covers more call patterns)::
|
|
112
|
+
|
|
113
|
+
def dummy_inputs(self) -> list[dict[str, Any]]:
|
|
114
|
+
return [
|
|
115
|
+
{"text": "Short text."},
|
|
116
|
+
{"text": "A longer piece of text to summarize properly."},
|
|
117
|
+
{"text": "Edge case: single word."},
|
|
118
|
+
]
|
|
119
|
+
"""
|
|
120
|
+
...
|
reqm/quant_manager.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quant_manager.py — Directory-based config management for reqm.
|
|
3
|
+
|
|
4
|
+
QuantManager takes an importable Python module (a directory with ``__init__.py``
|
|
5
|
+
and YAML config files) and treats its filesystem root as a Hydra config search
|
|
6
|
+
path. It provides methods to list, validate, load, and instantiate configs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import types
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from hydra import compose, initialize_config_dir
|
|
15
|
+
from hydra.core.global_hydra import GlobalHydra
|
|
16
|
+
from hydra.utils import instantiate
|
|
17
|
+
from omegaconf import DictConfig, OmegaConf
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigValidationError(Exception):
|
|
21
|
+
"""Raised when a config file fails validation.
|
|
22
|
+
|
|
23
|
+
Includes the config path and a description of what failed, so the user
|
|
24
|
+
knows exactly which file to fix and what to change.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
message: Human-readable description of the validation failure.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
Raised when ``# @package _global_`` is missing::
|
|
31
|
+
|
|
32
|
+
raise ConfigValidationError(
|
|
33
|
+
"Config 'model_a' at /path/to/model_a.yaml is missing the "
|
|
34
|
+
"required '# @package _global_' header. Add it as the first "
|
|
35
|
+
"line of the file."
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QuantManager:
|
|
41
|
+
"""Directory-based config manager built on Hydra.
|
|
42
|
+
|
|
43
|
+
Takes an importable Python config module and uses its directory as the
|
|
44
|
+
Hydra config root. Provides a uniform API to list, validate, load, and
|
|
45
|
+
build objects from YAML configs.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config_module: An imported Python module whose directory contains
|
|
49
|
+
YAML config files. Must be a real module with a ``__path__``
|
|
50
|
+
or ``__file__`` attribute.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
TypeError: If *config_module* is not a Python module.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
Create a QuantManager from a config module::
|
|
57
|
+
|
|
58
|
+
import my_configs
|
|
59
|
+
from reqm import QuantManager
|
|
60
|
+
|
|
61
|
+
QM = QuantManager(my_configs)
|
|
62
|
+
QM.list_configs() # ["model_a", "sub/model_b"]
|
|
63
|
+
cfg = QM.get_config("model_a")
|
|
64
|
+
obj = QM.build("model_a")
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config_module: types.ModuleType) -> None:
|
|
68
|
+
if not isinstance(config_module, types.ModuleType):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"Expected an imported Python module, got "
|
|
71
|
+
f"{type(config_module).__name__}. Pass the module object "
|
|
72
|
+
f"itself (e.g. `import my_configs; QuantManager(my_configs)`)."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if hasattr(config_module, "__path__"):
|
|
76
|
+
self._config_dir = Path(config_module.__path__[0]).resolve()
|
|
77
|
+
elif hasattr(config_module, "__file__") and config_module.__file__ is not None:
|
|
78
|
+
self._config_dir = Path(config_module.__file__).resolve().parent
|
|
79
|
+
else:
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"Module {config_module.__name__!r} has no __path__ or __file__ "
|
|
82
|
+
f"attribute. Cannot determine config directory."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _resolve_config_path(self, config_name: str) -> Path:
|
|
86
|
+
"""Return the absolute path to a config's YAML file, or raise."""
|
|
87
|
+
yaml_path = self._config_dir / f"{config_name}.yaml"
|
|
88
|
+
if not yaml_path.is_file():
|
|
89
|
+
raise FileNotFoundError(
|
|
90
|
+
f"Config '{config_name}' not found in config module "
|
|
91
|
+
f"at {self._config_dir}. "
|
|
92
|
+
f"Available configs: {self.list_configs()}"
|
|
93
|
+
)
|
|
94
|
+
return yaml_path
|
|
95
|
+
|
|
96
|
+
def list_configs(self) -> list[str]:
|
|
97
|
+
"""List all YAML config names in the config module.
|
|
98
|
+
|
|
99
|
+
Recursively walks the config module directory and returns config
|
|
100
|
+
names (relative paths without the ``.yaml`` extension), sorted
|
|
101
|
+
alphabetically.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Sorted list of config name strings.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> QM = QuantManager(my_configs)
|
|
108
|
+
>>> QM.list_configs()
|
|
109
|
+
['model_a', 'model_b', 'serving/prod', 'serving/staging']
|
|
110
|
+
"""
|
|
111
|
+
return sorted(
|
|
112
|
+
str(p.relative_to(self._config_dir).with_suffix("")).replace("\\", "/")
|
|
113
|
+
for p in self._config_dir.rglob("*.yaml")
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def validate(self, config_name: str | None = None) -> None:
|
|
117
|
+
"""Validate that configs have the required ``# @package _global_`` header.
|
|
118
|
+
|
|
119
|
+
If *config_name* is provided, validates only that config. If ``None``,
|
|
120
|
+
validates every YAML file in the config module.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
config_name: Optional config name to validate. If ``None``,
|
|
124
|
+
validates all configs.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ConfigValidationError: If a config is missing
|
|
128
|
+
``# @package _global_``.
|
|
129
|
+
FileNotFoundError: If *config_name* does not correspond to a
|
|
130
|
+
YAML file in the config module.
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
Validate a single config::
|
|
134
|
+
|
|
135
|
+
QM.validate("model_a") # raises if invalid
|
|
136
|
+
|
|
137
|
+
Validate all configs at once::
|
|
138
|
+
|
|
139
|
+
QM.validate() # checks every YAML in the module
|
|
140
|
+
"""
|
|
141
|
+
names = [config_name] if config_name is not None else self.list_configs()
|
|
142
|
+
for name in names:
|
|
143
|
+
yaml_path = self._resolve_config_path(name)
|
|
144
|
+
content = yaml_path.read_text(encoding="utf-8")
|
|
145
|
+
if "# @package _global_" not in content:
|
|
146
|
+
raise ConfigValidationError(
|
|
147
|
+
f"Config '{name}' at {yaml_path} is missing the required "
|
|
148
|
+
f"'# @package _global_' header. Add it as the first line "
|
|
149
|
+
f"of the file."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def get_config(
|
|
153
|
+
self,
|
|
154
|
+
config_name: str,
|
|
155
|
+
*,
|
|
156
|
+
config_overrides: dict | DictConfig | None = None,
|
|
157
|
+
param_overrides: list[str] | None = None,
|
|
158
|
+
) -> DictConfig:
|
|
159
|
+
"""Load and fully resolve a config by name.
|
|
160
|
+
|
|
161
|
+
Composes the config via Hydra, applies overrides, resolves all
|
|
162
|
+
interpolations, and returns the result as an OmegaConf ``DictConfig``.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
config_name: Config name (relative path, no ``.yaml`` extension).
|
|
166
|
+
config_overrides: Optional dict or ``DictConfig`` merged on top
|
|
167
|
+
of the composed config.
|
|
168
|
+
param_overrides: Optional list of Hydra CLI-style override strings
|
|
169
|
+
(e.g. ``["key=val", "nested.key=123"]``).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Fully resolved ``DictConfig``.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
FileNotFoundError: If *config_name* does not exist.
|
|
176
|
+
omegaconf.errors.MissingMandatoryValue: If an interpolation
|
|
177
|
+
cannot be resolved.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
>>> cfg = QM.get_config("model_a")
|
|
181
|
+
>>> cfg = QM.get_config("model_a", param_overrides=["lr=0.01"])
|
|
182
|
+
>>> cfg = QM.get_config("model_a", config_overrides={"lr": 0.01})
|
|
183
|
+
"""
|
|
184
|
+
self._resolve_config_path(config_name)
|
|
185
|
+
|
|
186
|
+
GlobalHydra.instance().clear()
|
|
187
|
+
with initialize_config_dir(config_dir=str(self._config_dir), version_base=None):
|
|
188
|
+
cfg = compose(config_name=config_name, overrides=param_overrides or [])
|
|
189
|
+
if config_overrides is not None:
|
|
190
|
+
OmegaConf.set_struct(cfg, False)
|
|
191
|
+
cfg = OmegaConf.merge(cfg, config_overrides)
|
|
192
|
+
OmegaConf.resolve(cfg)
|
|
193
|
+
return cfg
|
|
194
|
+
|
|
195
|
+
def get_raw_config(
|
|
196
|
+
self,
|
|
197
|
+
config_name: str,
|
|
198
|
+
*,
|
|
199
|
+
config_overrides: dict | DictConfig | None = None,
|
|
200
|
+
param_overrides: list[str] | None = None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Load, resolve, and return a config as a YAML string.
|
|
203
|
+
|
|
204
|
+
Equivalent to calling :meth:`get_config` and serializing the result
|
|
205
|
+
via ``OmegaConf.to_yaml``.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
config_name: Config name (relative path, no ``.yaml`` extension).
|
|
209
|
+
config_overrides: Optional dict or ``DictConfig`` merged on top.
|
|
210
|
+
param_overrides: Optional Hydra CLI-style override strings.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The resolved config serialized as a YAML string.
|
|
214
|
+
|
|
215
|
+
Examples:
|
|
216
|
+
>>> yaml_str = QM.get_raw_config("model_a")
|
|
217
|
+
>>> print(yaml_str)
|
|
218
|
+
_target_: my_module.ModelA
|
|
219
|
+
lr: 0.001
|
|
220
|
+
"""
|
|
221
|
+
cfg = self.get_config(
|
|
222
|
+
config_name,
|
|
223
|
+
config_overrides=config_overrides,
|
|
224
|
+
param_overrides=param_overrides,
|
|
225
|
+
)
|
|
226
|
+
return OmegaConf.to_yaml(cfg)
|
|
227
|
+
|
|
228
|
+
def build(
|
|
229
|
+
self,
|
|
230
|
+
config_name: str,
|
|
231
|
+
*,
|
|
232
|
+
config_overrides: dict | DictConfig | None = None,
|
|
233
|
+
param_overrides: list[str] | None = None,
|
|
234
|
+
) -> object:
|
|
235
|
+
"""Build an object from a config via ``hydra.utils.instantiate``.
|
|
236
|
+
|
|
237
|
+
Loads the config with :meth:`get_config`, then passes it to Hydra's
|
|
238
|
+
recursive instantiation. Returns whatever ``_target_`` points to.
|
|
239
|
+
|
|
240
|
+
This is a generic instantiator — it does **not** require the result
|
|
241
|
+
to be a :class:`~reqm.quant.Quant` subclass.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
config_name: Config name (relative path, no ``.yaml`` extension).
|
|
245
|
+
config_overrides: Optional dict or ``DictConfig`` merged on top.
|
|
246
|
+
param_overrides: Optional Hydra CLI-style override strings.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The instantiated object.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
FileNotFoundError: If *config_name* does not exist.
|
|
253
|
+
hydra.errors.InstantiationException: If instantiation fails.
|
|
254
|
+
|
|
255
|
+
Examples:
|
|
256
|
+
>>> obj = QM.build("model_a")
|
|
257
|
+
>>> obj = QM.build("model_a", param_overrides=["lr=0.01"])
|
|
258
|
+
"""
|
|
259
|
+
cfg = self.get_config(
|
|
260
|
+
config_name,
|
|
261
|
+
config_overrides=config_overrides,
|
|
262
|
+
param_overrides=param_overrides,
|
|
263
|
+
)
|
|
264
|
+
return instantiate(cfg)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reqm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ridiculously Easy Quant Manager — config-based aliased object factory with enforced interfaces
|
|
5
|
+
Project-URL: Homepage, https://github.com/jkvc/reqm
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: hydra-core>=1.3
|
|
10
|
+
Requires-Dist: overrides>=7.7.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# reqm
|
|
14
|
+
|
|
15
|
+
**R**idiculously **E**asy **Q**uant **M**anager
|
|
16
|
+
|
|
17
|
+
Directory-based config management and object factory built on [Hydra](https://hydra.cc).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The problem
|
|
22
|
+
|
|
23
|
+
Hydra is excellent for config-driven instantiation. But using it as a general object factory requires ceremony:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# You have to do all of this just to instantiate one object
|
|
27
|
+
with hydra.initialize(config_path="conf"):
|
|
28
|
+
cfg = hydra.compose(config_name="my_model")
|
|
29
|
+
model = hydra.utils.instantiate(cfg.model)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This ceremony means Hydra stays in the lab. It's awkward in a notebook, verbose in a service, and doesn't belong in production call sites.
|
|
33
|
+
|
|
34
|
+
`reqm` gives you Hydra's power — config-driven instantiation, composable overrides, recursive object graphs — with none of the ceremony:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from reqm import QuantManager
|
|
38
|
+
import my_configs
|
|
39
|
+
|
|
40
|
+
QM = QuantManager(my_configs)
|
|
41
|
+
model = QM.build("summarizer_prod")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Same call in a notebook, a FastAPI endpoint, a test, or a batch job.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Core concept: the Quant
|
|
49
|
+
|
|
50
|
+
A **Quant** is the unit reqm builds and manages. It is:
|
|
51
|
+
|
|
52
|
+
- **Callable** — invoked directly with its inputs
|
|
53
|
+
- **Config-driven** — constructor arguments defined in YAML, no hardcoding
|
|
54
|
+
- **Auditable** — implements `dummy_inputs()`, example inputs the factory uses to verify it actually runs
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from reqm import Quant
|
|
58
|
+
from reqm.overrides_ext import override
|
|
59
|
+
|
|
60
|
+
class Summarizer(Quant):
|
|
61
|
+
def __init__(self, model_name: str, max_tokens: int):
|
|
62
|
+
self.model = load_model(model_name)
|
|
63
|
+
self.max_tokens = max_tokens
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
def dummy_inputs(self) -> list[dict]:
|
|
67
|
+
return [{"text": "The quick brown fox jumps over the lazy dog."}]
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def __call__(self, text: str) -> str:
|
|
71
|
+
return self.model.summarize(text, max_tokens=self.max_tokens)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The `dummy_inputs` contract is what separates a Quant from a plain ABC. reqm can call each Quant with its own dummy inputs at build time — if it fails, it fails early and loudly, not silently in production.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Config modules and QuantManager
|
|
79
|
+
|
|
80
|
+
A **config module** is any importable Python package containing YAML files:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
my_configs/
|
|
84
|
+
├── __init__.py
|
|
85
|
+
├── summarizer_prod.yaml
|
|
86
|
+
├── summarizer_fast.yaml
|
|
87
|
+
└── serving/
|
|
88
|
+
└── prod.yaml
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each YAML config declares what to build:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
# @package _global_
|
|
95
|
+
_target_: myproject.models.Summarizer
|
|
96
|
+
model_name: gpt-4o
|
|
97
|
+
max_tokens: 512
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`QuantManager` takes the config module and gives you a uniform API. Construct it once in the `__init__.py` next to your configs directory, then import `QM` everywhere:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# myproject/__init__.py (next to configs/)
|
|
104
|
+
import myproject.configs as configs
|
|
105
|
+
from reqm import QuantManager
|
|
106
|
+
|
|
107
|
+
QM = QuantManager(configs)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# Any call site — notebook, script, service, test
|
|
112
|
+
from myproject import QM
|
|
113
|
+
|
|
114
|
+
QM.list_configs() # ["serving/prod", "summarizer_fast", "summarizer_prod"]
|
|
115
|
+
QM.validate() # check all configs have # @package _global_
|
|
116
|
+
cfg = QM.get_config("summarizer_prod") # resolved OmegaConf DictConfig
|
|
117
|
+
obj = QM.build("summarizer_prod") # instantiated object
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Configs can compose other configs via Hydra defaults lists:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
# @package _global_
|
|
124
|
+
defaults:
|
|
125
|
+
- /base_model@child
|
|
126
|
+
- _self_
|
|
127
|
+
_target_: myproject.models.Ensemble
|
|
128
|
+
weight: 0.6
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## The uniform call site
|
|
134
|
+
|
|
135
|
+
The core value proposition: ONE script, swap the config name, get different experimental results. No code changes, no if/else chains, no factory functions.
|
|
136
|
+
|
|
137
|
+
Construct `QM` once in the `__init__.py` right next to your configs directory:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# myproject/__init__.py (lives next to configs/)
|
|
141
|
+
import myproject.configs as configs
|
|
142
|
+
from reqm import QuantManager
|
|
143
|
+
|
|
144
|
+
QM = QuantManager(configs)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Then every script just imports it:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import sys
|
|
151
|
+
from myproject import QM
|
|
152
|
+
|
|
153
|
+
model = QM.build(sys.argv[1]) # <-- only this string changes
|
|
154
|
+
result = model(text="Hello world")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
python evaluate.py summarizer_prod
|
|
159
|
+
python evaluate.py summarizer_fast
|
|
160
|
+
python evaluate.py summarizer_experiment_v3
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Runnable example
|
|
166
|
+
|
|
167
|
+
The repo includes a complete example project at `examples/estimators/` that demonstrates Quant subclasses, non-Quant configurable dependencies (Filters), Hydra config composition, and multiple scripts sharing a single `QM` instance defined in `examples/estimators/__init__.py`:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Evaluate a single estimator config
|
|
171
|
+
uv run python -m examples.estimators.scripts.evaluate mean_simple
|
|
172
|
+
|
|
173
|
+
# Inspect the fully resolved config YAML
|
|
174
|
+
uv run python -m examples.estimators.scripts.inspect_config ensemble/mean_median
|
|
175
|
+
|
|
176
|
+
# Compare multiple configs side by side
|
|
177
|
+
uv run python -m examples.estimators.scripts.compare mean_simple mean_outlier median_simple
|
|
178
|
+
|
|
179
|
+
# Validate all configs
|
|
180
|
+
uv run python -m examples.estimators.scripts.validate_configs
|
|
181
|
+
|
|
182
|
+
# Sweep all configs and rank by performance
|
|
183
|
+
uv run python -m examples.estimators.scripts.sweep
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## PyTorch integration
|
|
189
|
+
|
|
190
|
+
reqm does not depend on PyTorch, but its primary use case is config-driven model experimentation with `nn.Module`. The repo includes a `TorchQuant` bridge class that resolves the `__call__` vs `forward()` conflict — subclass it instead of plain `Quant` when your model is an `nn.Module`:
|
|
191
|
+
|
|
192
|
+
Don't subclass TorchQuant directly for every model — create a **domain base class** that locks the `forward` signature for your use case:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from myproject.torch_quant import TorchQuant # copy from examples/
|
|
196
|
+
|
|
197
|
+
# Domain base — locks forward(self, x: Tensor) -> Tensor for all regressors
|
|
198
|
+
class Regressor(TorchQuant):
|
|
199
|
+
in_features: int
|
|
200
|
+
|
|
201
|
+
@override
|
|
202
|
+
@abc.abstractmethod
|
|
203
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor: ...
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
def dummy_inputs(self) -> list[dict]:
|
|
207
|
+
return [{"x": torch.randn(4, self.in_features)}]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Concrete model — must match the Regressor signature
|
|
211
|
+
class LinearRegressor(Regressor):
|
|
212
|
+
def __init__(self, in_features: int):
|
|
213
|
+
super().__init__()
|
|
214
|
+
self.in_features = in_features
|
|
215
|
+
self.linear = nn.Linear(in_features, 1)
|
|
216
|
+
|
|
217
|
+
@override
|
|
218
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
219
|
+
return self.linear(x)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`TorchQuant.forward` uses `@allow_any_override` so domain bases can narrow freely. Once the domain base locks the signature (without `@allow_any_override`), all concrete models must match — enforced at class definition time, not runtime.
|
|
223
|
+
|
|
224
|
+
See `docs/torch_integration.md` for the full explanation, and `examples/torch_models/` for a runnable example:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
uv run python -m examples.torch_models.scripts.evaluate linear_simple
|
|
228
|
+
uv run python -m examples.torch_models.scripts.evaluate mlp_small
|
|
229
|
+
uv run python -m examples.torch_models.scripts.audit
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Why not just Hydra?
|
|
235
|
+
|
|
236
|
+
Hydra is framework-first. It expects to own your program's entry point. `reqm` is library-first — it has no opinion about your application structure and works wherever Python runs.
|
|
237
|
+
|
|
238
|
+
| | Hydra | reqm |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| Object instantiation | yes | yes |
|
|
241
|
+
| Config composition | yes | yes (via Hydra) |
|
|
242
|
+
| Auditability (`dummy_inputs`) | no | yes |
|
|
243
|
+
| Works in notebooks | limited | yes |
|
|
244
|
+
| CLI ceremony required | yes | no |
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Name
|
|
249
|
+
|
|
250
|
+
`reqm` is also a nod to [Rue Esquermoise](https://en.wikipedia.org/wiki/Rue_Esquermoise), one of the oldest streets in Lille, France, dating to the 13th century. Its etymology traces to the Flemish *eskelm* — "frontier." A fitting name for a library that sits at the frontier between research and production.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Status
|
|
255
|
+
|
|
256
|
+
Core API implemented: `Quant`, `QuantManager`, config composition, and override support all work. See `examples/estimators/` for a complete working project.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
reqm/__init__.py,sha256=UJmu6qNyiYLzKhaumRQ70NKxVZaHOi3RYDrx6mwy_Kk,202
|
|
2
|
+
reqm/overrides_ext.py,sha256=0_QWmf9r0eonsyjK3c6VaY3m6r_YpuNbbi6851278Zg,5671
|
|
3
|
+
reqm/quant.py,sha256=ZdvfHl-PLdKp2EvageR5GtU39jC_aOfnr2qWmOlktKw,4179
|
|
4
|
+
reqm/quant_manager.py,sha256=ng39ksSXRZPsrwop-DY-HSFqvLSSHmmvWNeDnsxcnIE,9633
|
|
5
|
+
reqm-0.1.0.dist-info/METADATA,sha256=059nNsulphQIQUN_K-XieLgI2IkjleBf7xC-ejU10co,8031
|
|
6
|
+
reqm-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
reqm-0.1.0.dist-info/licenses/LICENSE,sha256=HXnpKXLs5Z7H4V7nPUlUXb0L4_HQ_Q66xvm5xn45rOY,1061
|
|
8
|
+
reqm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jkvc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|