Jarvis-HEP-Sampler 1.0.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.
@@ -0,0 +1,141 @@
1
+ """Jarvis-HEP-Sampler: the plug-in SDK for user-defined Jarvis-HEP samplers.
2
+
3
+ A plug-in is one ``.py`` file with a ``PLUGIN`` dict, an optional
4
+ ``OPTIONS_SCHEMA`` and a few functions (``prepare`` / ``draw`` / hooks).
5
+ Users create it with ``Jarvis sampler init``, load it once with
6
+ ``Jarvis sampler load``, and then write ``Sampling.Method: <Name>`` like any
7
+ built-in method.
8
+
9
+ This package owns the plug-in format: metadata rules, loading, the user
10
+ registry, random streams, output checks, the logging facade and the offline
11
+ test kit. Jarvis-HEP owns the runtime. Names are imported lazily so reading
12
+ the registry stays light.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib import import_module
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from jarvis_hep_sampler._version import __version__
21
+
22
+ _LAZY_ATTRS: dict[str, tuple[str, str]] = {
23
+ # metadata
24
+ "API_VERSION": ("jarvis_hep_sampler.metadata", "API_VERSION"),
25
+ "PluginMetadata": ("jarvis_hep_sampler.metadata", "PluginMetadata"),
26
+ "check_method_name": ("jarvis_hep_sampler.metadata", "check_method_name"),
27
+ # contexts and logging
28
+ "BlockContext": ("jarvis_hep_sampler.contexts", "BlockContext"),
29
+ "DrawContext": ("jarvis_hep_sampler.contexts", "DrawContext"),
30
+ "SamplerStats": ("jarvis_hep_sampler.contexts", "SamplerStats"),
31
+ "SetupContext": ("jarvis_hep_sampler.contexts", "SetupContext"),
32
+ "LogBackend": ("jarvis_hep_sampler.log", "LogBackend"),
33
+ "SamplerLog": ("jarvis_hep_sampler.log", "SamplerLog"),
34
+ # errors
35
+ "PluginConfigError": ("jarvis_hep_sampler.errors", "PluginConfigError"),
36
+ "PluginError": ("jarvis_hep_sampler.errors", "PluginError"),
37
+ "PluginLoadError": ("jarvis_hep_sampler.errors", "PluginLoadError"),
38
+ "PluginOutputError": ("jarvis_hep_sampler.errors", "PluginOutputError"),
39
+ "PluginResumeMismatch": ("jarvis_hep_sampler.errors", "PluginResumeMismatch"),
40
+ "RegistryError": ("jarvis_hep_sampler.errors", "RegistryError"),
41
+ # streams
42
+ "STREAM_PROTOCOL": ("jarvis_hep_sampler.streams", "STREAM_PROTOCOL"),
43
+ # options
44
+ "OptionIssue": ("jarvis_hep_sampler.options", "OptionIssue"),
45
+ "ReservedSettings": ("jarvis_hep_sampler.options", "ReservedSettings"),
46
+ "parse_reserved": ("jarvis_hep_sampler.options", "parse_reserved"),
47
+ "schema_issues": ("jarvis_hep_sampler.options", "schema_issues"),
48
+ "split_bounds": ("jarvis_hep_sampler.options", "split_bounds"),
49
+ # loading and running
50
+ "LoadedPlugin": ("jarvis_hep_sampler.loader", "LoadedPlugin"),
51
+ "load_plugin_file": ("jarvis_hep_sampler.loader", "load_plugin_file"),
52
+ "load_plugin_source": ("jarvis_hep_sampler.loader", "load_plugin_source"),
53
+ "IndependentSession": ("jarvis_hep_sampler.session", "IndependentSession"),
54
+ # registry and template
55
+ "LoadResult": ("jarvis_hep_sampler.registry", "LoadResult"),
56
+ "RegistryEntry": ("jarvis_hep_sampler.registry", "RegistryEntry"),
57
+ "SamplerRegistry": ("jarvis_hep_sampler.registry", "SamplerRegistry"),
58
+ "samplers_home": ("jarvis_hep_sampler.registry", "samplers_home"),
59
+ "render_template": ("jarvis_hep_sampler.template", "render_template"),
60
+ "write_template": ("jarvis_hep_sampler.template", "write_template"),
61
+ }
62
+
63
+ # Kept literal so linters and IDEs see the exports; a test pins it to _LAZY_ATTRS.
64
+ __all__ = [
65
+ "API_VERSION",
66
+ "BlockContext",
67
+ "DrawContext",
68
+ "IndependentSession",
69
+ "LoadResult",
70
+ "LoadedPlugin",
71
+ "LogBackend",
72
+ "OptionIssue",
73
+ "PluginConfigError",
74
+ "PluginError",
75
+ "PluginLoadError",
76
+ "PluginMetadata",
77
+ "PluginOutputError",
78
+ "PluginResumeMismatch",
79
+ "RegistryEntry",
80
+ "RegistryError",
81
+ "ReservedSettings",
82
+ "STREAM_PROTOCOL",
83
+ "SamplerLog",
84
+ "SamplerRegistry",
85
+ "SamplerStats",
86
+ "SetupContext",
87
+ "__version__",
88
+ "check_method_name",
89
+ "load_plugin_file",
90
+ "load_plugin_source",
91
+ "parse_reserved",
92
+ "render_template",
93
+ "samplers_home",
94
+ "schema_issues",
95
+ "split_bounds",
96
+ "write_template",
97
+ ]
98
+
99
+ if TYPE_CHECKING:
100
+ from jarvis_hep_sampler.contexts import BlockContext, DrawContext, SamplerStats, SetupContext
101
+ from jarvis_hep_sampler.errors import (
102
+ PluginConfigError,
103
+ PluginError,
104
+ PluginLoadError,
105
+ PluginOutputError,
106
+ PluginResumeMismatch,
107
+ RegistryError,
108
+ )
109
+ from jarvis_hep_sampler.loader import LoadedPlugin, load_plugin_file, load_plugin_source
110
+ from jarvis_hep_sampler.log import LogBackend, SamplerLog
111
+ from jarvis_hep_sampler.metadata import API_VERSION, PluginMetadata, check_method_name
112
+ from jarvis_hep_sampler.options import (
113
+ OptionIssue,
114
+ ReservedSettings,
115
+ parse_reserved,
116
+ schema_issues,
117
+ split_bounds,
118
+ )
119
+ from jarvis_hep_sampler.registry import (
120
+ LoadResult,
121
+ RegistryEntry,
122
+ SamplerRegistry,
123
+ samplers_home,
124
+ )
125
+ from jarvis_hep_sampler.session import IndependentSession
126
+ from jarvis_hep_sampler.streams import STREAM_PROTOCOL
127
+ from jarvis_hep_sampler.template import render_template, write_template
128
+
129
+
130
+ def __getattr__(name: str) -> Any:
131
+ target = _LAZY_ATTRS.get(name)
132
+ if target is None:
133
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
134
+ module_name, attr = target
135
+ value = getattr(import_module(module_name), attr)
136
+ globals()[name] = value
137
+ return value
138
+
139
+
140
+ def __dir__() -> list[str]:
141
+ return sorted(set(__all__) | set(globals()))
@@ -0,0 +1,29 @@
1
+ """Private subprocess worker used by testing.check_plugin; no public CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from jarvis_hep_sampler.loader import load_plugin_source
13
+ from jarvis_hep_sampler.session import IndependentSession
14
+
15
+
16
+ def main() -> None:
17
+ data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
18
+ plugin = load_plugin_source(base64.b64decode(data["source"]), origin=data["origin"])
19
+ session = IndependentSession(
20
+ plugin, seed=data["seed"], point_number=data["point_number"],
21
+ variable_names=data["variable_names"], options=data["options"],
22
+ )
23
+ session.prepare()
24
+ tail = np.array([u for _, u in session.points(data["start"])])
25
+ np.save(sys.argv[2], tail, allow_pickle=False)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,69 @@
1
+ """Read-only contexts passed to plug-in functions (technical design §4.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from jarvis_hep_sampler.log import SamplerLog
12
+ from jarvis_hep_sampler.streams import prepare_stream
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SetupContext:
17
+ """Argument of ``prepare(setup)`` and of the settings hook."""
18
+
19
+ seed: int
20
+ point_number: int
21
+ dimension: int
22
+ variable_names: tuple[str, ...]
23
+ options: Mapping[str, Any]
24
+ log: SamplerLog
25
+
26
+ def rng(self) -> np.random.Generator:
27
+ """A fresh generator on the stream reserved for ``prepare``."""
28
+ return prepare_stream(self.seed)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DrawContext:
33
+ """First argument of ``draw(context, rng, plan)``."""
34
+
35
+ candidate_index: int
36
+ point_number: int
37
+ dimension: int
38
+ variable_names: tuple[str, ...]
39
+ options: Mapping[str, Any]
40
+ log: SamplerLog
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BlockContext:
45
+ """First argument of ``draw_block(context, rng, plan)``.
46
+
47
+ The block covers candidate indices
48
+ ``[block_index * block_size, (block_index + 1) * block_size)``.
49
+ """
50
+
51
+ block_index: int
52
+ block_size: int
53
+ point_number: int
54
+ dimension: int
55
+ variable_names: tuple[str, ...]
56
+ options: Mapping[str, Any]
57
+ log: SamplerLog
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SamplerStats:
62
+ """Read-only counters passed to the summary, stop-reason and status hooks."""
63
+
64
+ candidates: int
65
+ accepted: int
66
+ point_number: int
67
+
68
+
69
+ __all__ = ["BlockContext", "DrawContext", "SamplerStats", "SetupContext"]
@@ -0,0 +1,81 @@
1
+ """Exceptions raised for sampler plug-ins (technical design §4.7).
2
+
3
+ Every error carries the context Jarvis-HEP prints in the sampler failure
4
+ record: the method, the plug-in source, the task-card path and, for output
5
+ errors, the candidate index. The plug-in's own exception, when there is one,
6
+ is chained as ``__cause__`` so its traceback reaches the log file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class PluginError(Exception):
15
+ """Base class for all plug-in errors."""
16
+
17
+ def __init__(
18
+ self,
19
+ message: str,
20
+ *,
21
+ method: str | None = None,
22
+ source: str | None = None,
23
+ yaml_path: str | None = None,
24
+ candidate_index: int | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.method = method
29
+ self.source = source
30
+ self.yaml_path = yaml_path
31
+ self.candidate_index = candidate_index
32
+
33
+ def context_rows(self) -> list[tuple[str, Any]]:
34
+ """Return the known context as ``(field, value)`` rows."""
35
+ rows: list[tuple[str, Any]] = []
36
+ if self.method:
37
+ rows.append(("method", self.method))
38
+ if self.candidate_index is not None:
39
+ rows.append(("candidate", self.candidate_index))
40
+ if self.yaml_path:
41
+ rows.append(("at", self.yaml_path))
42
+ if self.source:
43
+ rows.append(("source", self.source))
44
+ return rows
45
+
46
+ def __str__(self) -> str:
47
+ rows = self.context_rows()
48
+ if not rows:
49
+ return self.message
50
+ details = ", ".join(f"{key} {value}" for key, value in rows)
51
+ return f"{self.message} ({details})"
52
+
53
+
54
+ class PluginLoadError(PluginError):
55
+ """The plug-in file cannot be imported or does not follow the file convention."""
56
+
57
+
58
+ class PluginConfigError(PluginError):
59
+ """The options, ``validate_options`` or ``prepare`` rejected the configuration."""
60
+
61
+
62
+ class PluginOutputError(PluginError):
63
+ """``draw`` / ``draw_block`` raised, or returned a point that breaks the output contract."""
64
+
65
+
66
+ class PluginResumeMismatch(PluginError):
67
+ """A resumed scan does not match the identity recorded by the original run."""
68
+
69
+
70
+ class RegistryError(PluginError):
71
+ """The user sampler registry is unreadable or one of its entries is broken."""
72
+
73
+
74
+ __all__ = [
75
+ "PluginConfigError",
76
+ "PluginError",
77
+ "PluginLoadError",
78
+ "PluginOutputError",
79
+ "PluginResumeMismatch",
80
+ "RegistryError",
81
+ ]
@@ -0,0 +1,237 @@
1
+ """Load a plug-in file (technical design §4.1).
2
+
3
+ The file is read once, hashed, compiled and executed from the same bytes, so
4
+ the recorded SHA-256 always matches the code that runs. ``sys.path`` is not
5
+ touched; the module is registered under a private name derived from its path
6
+ and content.
7
+
8
+ Importing a plug-in runs user code. Only ``Jarvis sampler load`` / ``check``
9
+ and the run preflight do it; ``Jarvis validate`` / ``man`` read the registry.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import inspect
16
+ import sys
17
+ import types
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from jarvis_hep_sampler.errors import PluginError, PluginLoadError
24
+ from jarvis_hep_sampler.metadata import (
25
+ GENERATIONAL,
26
+ INDEPENDENT,
27
+ SUPPORTED_PROFILES,
28
+ PluginMetadata,
29
+ parse_metadata,
30
+ )
31
+ from jarvis_hep_sampler.options import check_options_schema
32
+
33
+ # name -> number of positional arguments the function must accept.
34
+ PROFILE_FUNCTIONS: Mapping[str, Mapping[str, int]] = {
35
+ INDEPENDENT: {"prepare": 1, "draw": 3, "draw_block": 3},
36
+ GENERATIONAL: {"init_state": 1, "ask": 3, "tell": 3},
37
+ }
38
+ OPTIONAL_HOOKS: Mapping[str, int] = {
39
+ "validate_options": 1,
40
+ "settings_rows": 2,
41
+ "summary_rows": 2,
42
+ "stop_reason": 2,
43
+ "status": 2,
44
+ }
45
+ _ARGUMENTS = {
46
+ "prepare": "(setup)",
47
+ "draw": "(context, rng, plan)",
48
+ "draw_block": "(context, rng, plan)",
49
+ "init_state": "(setup)",
50
+ "ask": "(state, context, rng)",
51
+ "tell": "(state, context, results)",
52
+ "validate_options": "(options)",
53
+ "settings_rows": "(setup, plan)",
54
+ "summary_rows": "(plan, stats)",
55
+ "stop_reason": "(plan, stats)",
56
+ "status": "(plan, stats)",
57
+ }
58
+
59
+
60
+ def sha256_bytes(payload: bytes) -> str:
61
+ return hashlib.sha256(payload).hexdigest()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class LoadedPlugin:
66
+ """An imported, structurally valid plug-in."""
67
+
68
+ metadata: PluginMetadata
69
+ options_schema: Mapping[str, Any] | None
70
+ functions: Mapping[str, Callable[..., Any]]
71
+ source_path: Path
72
+ source_sha256: str
73
+ source_bytes: bytes = field(repr=False)
74
+ module: types.ModuleType = field(repr=False)
75
+
76
+ @property
77
+ def method(self) -> str:
78
+ return self.metadata.method
79
+
80
+ @property
81
+ def hooks(self) -> tuple[str, ...]:
82
+ """Names of the functions the plug-in defines, sorted."""
83
+ return tuple(sorted(self.functions))
84
+
85
+ def function(self, name: str) -> Callable[..., Any] | None:
86
+ return self.functions.get(name)
87
+
88
+
89
+ def load_plugin_file(path: str | Path, *, reserved_names: Iterable[str] = ()) -> LoadedPlugin:
90
+ """Import the plug-in at *path* and check it against the file convention."""
91
+ source = Path(path).expanduser()
92
+ if not source.is_file():
93
+ raise PluginLoadError(f"plug-in file not found: {source}", source=str(source))
94
+ resolved = source.resolve()
95
+ try:
96
+ payload = resolved.read_bytes()
97
+ except OSError as exc:
98
+ raise PluginLoadError(f"cannot read {resolved}: {exc}", source=str(resolved)) from exc
99
+ return load_plugin_source(payload, origin=resolved, reserved_names=reserved_names)
100
+
101
+
102
+ def load_plugin_source(
103
+ payload: bytes, *, origin: str | Path, reserved_names: Iterable[str] = ()
104
+ ) -> LoadedPlugin:
105
+ """Import a plug-in from its source bytes; *origin* names it in tracebacks."""
106
+ origin_path = Path(origin)
107
+ origin_text = str(origin_path)
108
+ digest = sha256_bytes(payload)
109
+ module_name = (
110
+ f"_jarvis_sampler_plugin_{sha256_bytes(origin_text.encode('utf-8'))[:12]}_{digest[:12]}"
111
+ )
112
+ try:
113
+ code = compile(payload, origin_text, "exec", dont_inherit=True)
114
+ except SyntaxError as exc:
115
+ raise PluginLoadError(
116
+ f"cannot compile the plug-in: {exc.msg} (line {exc.lineno})", source=origin_text
117
+ ) from exc
118
+
119
+ module = types.ModuleType(module_name)
120
+ module.__file__ = origin_text
121
+ previous = sys.modules.get(module_name)
122
+ sys.modules[module_name] = module
123
+ try:
124
+ exec(code, module.__dict__)
125
+ plugin = _inspect_module(
126
+ module,
127
+ origin=origin_path,
128
+ digest=digest,
129
+ payload=payload,
130
+ reserved_names=reserved_names,
131
+ )
132
+ except PluginError:
133
+ _restore_module(module_name, previous)
134
+ raise
135
+ except Exception as exc:
136
+ _restore_module(module_name, previous)
137
+ raise PluginLoadError(
138
+ f"importing the plug-in failed: {type(exc).__name__}: {exc}", source=origin_text
139
+ ) from exc
140
+ except BaseException:
141
+ _restore_module(module_name, previous)
142
+ raise
143
+ return plugin
144
+
145
+
146
+ def _restore_module(name: str, previous: types.ModuleType | None) -> None:
147
+ if previous is None:
148
+ sys.modules.pop(name, None)
149
+ else:
150
+ sys.modules[name] = previous
151
+
152
+
153
+ def _inspect_module(
154
+ module: types.ModuleType,
155
+ *,
156
+ origin: Path,
157
+ digest: str,
158
+ payload: bytes,
159
+ reserved_names: Iterable[str],
160
+ ) -> LoadedPlugin:
161
+ namespace = module.__dict__
162
+ source = str(origin)
163
+ if "PLUGIN" not in namespace:
164
+ raise PluginLoadError("the plug-in defines no PLUGIN dict", source=source)
165
+ try:
166
+ metadata = parse_metadata(namespace["PLUGIN"], reserved_names=reserved_names)
167
+ except PluginLoadError as exc:
168
+ exc.source = exc.source or source
169
+ raise
170
+ method = metadata.method
171
+
172
+ def fail(message: str) -> PluginLoadError:
173
+ return PluginLoadError(message, method=method, source=source)
174
+
175
+ if metadata.profile not in SUPPORTED_PROFILES:
176
+ raise fail(
177
+ f"profile {metadata.profile!r} is reserved for a later Jarvis-HEP-Sampler; "
178
+ f"this version runs: {', '.join(SUPPORTED_PROFILES)}"
179
+ )
180
+
181
+ schema = namespace.get("OPTIONS_SCHEMA")
182
+ if schema is not None:
183
+ try:
184
+ schema = check_options_schema(schema)
185
+ except PluginLoadError as exc:
186
+ raise fail(exc.message) from exc
187
+
188
+ wanted = {**PROFILE_FUNCTIONS[metadata.profile], **OPTIONAL_HOOKS}
189
+ functions: dict[str, Callable[..., Any]] = {}
190
+ for name, arity in wanted.items():
191
+ if name not in namespace:
192
+ continue
193
+ candidate = namespace[name]
194
+ if not callable(candidate):
195
+ raise fail(f"{name} must be a function {_ARGUMENTS[name]}")
196
+ _check_arity(name, candidate, arity, fail)
197
+ functions[name] = candidate
198
+
199
+ if metadata.profile == INDEPENDENT:
200
+ if metadata.block_size is not None:
201
+ if "draw_block" not in functions:
202
+ raise fail("PLUGIN['block_size'] is set, so the plug-in must define draw_block()")
203
+ elif "draw" not in functions:
204
+ if "draw_block" in functions:
205
+ raise fail("draw_block() needs PLUGIN['block_size']; or define draw() instead")
206
+ raise fail("the independent profile needs a draw(context, rng, plan) function")
207
+
208
+ return LoadedPlugin(
209
+ metadata=metadata,
210
+ options_schema=schema,
211
+ functions=functions,
212
+ source_path=origin,
213
+ source_sha256=digest,
214
+ source_bytes=payload,
215
+ module=module,
216
+ )
217
+
218
+
219
+ def _check_arity(name: str, function: Callable[..., Any], arity: int, fail) -> None:
220
+ try:
221
+ signature = inspect.signature(function)
222
+ except (TypeError, ValueError):
223
+ return
224
+ try:
225
+ signature.bind(*([None] * arity))
226
+ except TypeError as exc:
227
+ raise fail(f"{name} must accept {_ARGUMENTS[name]}: {exc}") from exc
228
+
229
+
230
+ __all__ = [
231
+ "LoadedPlugin",
232
+ "OPTIONAL_HOOKS",
233
+ "PROFILE_FUNCTIONS",
234
+ "load_plugin_file",
235
+ "load_plugin_source",
236
+ "sha256_bytes",
237
+ ]
@@ -0,0 +1,97 @@
1
+ """Logging facade handed to plug-ins (technical design §4.5).
2
+
3
+ Plug-ins never touch a logger directly. They call the ``log`` object they get
4
+ in every context. Inside Jarvis-HEP the backend writes to
5
+ ``Jarvis-HEP.Sampler.<Method>`` in the Jarvis layout; offline (``check``,
6
+ :mod:`jarvis_hep_sampler.testing`) it writes to the standard ``logging``
7
+ logger ``jarvis_hep_sampler.<Method>``.
8
+
9
+ The runtime alone writes the lifecycle records (start, settings, ready,
10
+ checkpoint, failure, result, summary). A plug-in writes only step tables (S5),
11
+ adjustments (S7) and saved-file lines (S11), plus free-form messages.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Iterable, Sequence
18
+ from typing import Any, Protocol
19
+
20
+
21
+ class LogBackend(Protocol):
22
+ """Where :class:`SamplerLog` sends its records."""
23
+
24
+ def emit(self, level: int, message: str) -> None: ...
25
+
26
+ def emit_table(self, level: int, title: str, rows: Sequence[tuple[str, Any]]) -> None: ...
27
+
28
+
29
+ def format_table(title: str, rows: Iterable[tuple[str, Any]]) -> str:
30
+ """Render a two-column ``Field | Value`` table like Jarvis-HEP's sampler log."""
31
+ normalized = [(str(key), str(value)) for key, value in rows]
32
+ if not normalized:
33
+ return str(title)
34
+ key_width = max(len("Field"), *(len(key) for key, _ in normalized))
35
+ value_width = max(len("Value"), *(len(value) for _, value in normalized))
36
+ lines = [
37
+ str(title),
38
+ f"{'Field':<{key_width}} | {'Value':<{value_width}}",
39
+ f"{'-' * key_width} | {'-' * value_width}",
40
+ ]
41
+ lines.extend(f"{key:<{key_width}} | {value}" for key, value in normalized)
42
+ return "\n".join(lines)
43
+
44
+
45
+ class StdlibLogBackend:
46
+ """Offline backend: the standard ``logging`` logger ``jarvis_hep_sampler.<method>``."""
47
+
48
+ def __init__(self, method: str) -> None:
49
+ self.logger = logging.getLogger(f"jarvis_hep_sampler.{method}")
50
+
51
+ def emit(self, level: int, message: str) -> None:
52
+ self.logger.log(level, message)
53
+
54
+ def emit_table(self, level: int, title: str, rows: Sequence[tuple[str, Any]]) -> None:
55
+ self.logger.log(level, format_table(title, rows))
56
+
57
+
58
+ class SamplerLog:
59
+ """The ``log`` object a plug-in receives in ``SetupContext`` / ``DrawContext``."""
60
+
61
+ def __init__(self, method: str, backend: LogBackend | None = None) -> None:
62
+ self._method = str(method)
63
+ self._backend: LogBackend = backend if backend is not None else StdlibLogBackend(method)
64
+
65
+ @property
66
+ def method(self) -> str:
67
+ return self._method
68
+
69
+ def step(self, title: str, rows: Iterable[tuple[str, Any]] = ()) -> None:
70
+ """S5: one table per generation or stage, e.g. ``log.step("Chains ->", rows)``."""
71
+ self._backend.emit_table(
72
+ logging.INFO, str(title), [(str(key), value) for key, value in rows]
73
+ )
74
+
75
+ def adjust(self, name: str, old: Any, new: Any, why: str) -> None:
76
+ """S7: a setting that was changed, ignored or clamped."""
77
+ self._backend.emit(logging.WARNING, f"{name}: {old} -> {new} ({why})")
78
+
79
+ def saved(self, what: str, path: Any) -> None:
80
+ """S11: a result file the plug-in wrote."""
81
+ self._backend.emit(logging.WARNING, f"{what} saved to {path}")
82
+
83
+ def debug(self, message: str, *args: Any) -> None:
84
+ self._backend.emit(logging.DEBUG, _format(message, args))
85
+
86
+ def info(self, message: str, *args: Any) -> None:
87
+ self._backend.emit(logging.INFO, _format(message, args))
88
+
89
+ def warning(self, message: str, *args: Any) -> None:
90
+ self._backend.emit(logging.WARNING, _format(message, args))
91
+
92
+
93
+ def _format(message: str, args: tuple[Any, ...]) -> str:
94
+ return str(message) % args if args else str(message)
95
+
96
+
97
+ __all__ = ["LogBackend", "SamplerLog", "StdlibLogBackend", "format_table"]