Repository navigation
Add minimal base abstraction for pyhealth.benchmarks - #1329
Conversation
Test CoverageTotal: 73.38% (19729 / 26885 lines)
|
|
/review |
There was a problem hiding this comment.
Previous review
AI Review
suggestion: need change
decision: human review
Thank you for this PR. It adds a minimal pyhealth.benchmarks base layer (Benchmark, Evaluation, Result), a private canonical-serialization module, API docs and tests. The design is clear and well documented, and the validation flow is sensible. Coverage is above the bar: base.py is 98.31%, _serialization.py is 95.16%, __init__.py is 100%, and the whole repository is 70.28% against the 60% bar.
To get this merged:
- Non-string mapping keys are not rejected.
_check_no_type_keydoesn't check key types, andjson.dumpscoerces keys. SoEvaluation({"p": {1: "a"}})andEvaluation({"p": {"1": "a"}})get the same id and compare equal. That contradicts the documented "str-keyed mappings" contract and could make two different evaluations collide in storage. Please raiseTypeErrorfor non-strkeys anywhere inparams, and add a test. - Some classes get an id but can't be rebuilt.
_encodeaccepts classes and functions whosemodule.qualnamedoesn't resolve back to the same object. Examples are classes defined in__main__and module-level aliases created withtype(). Their ids compute, butfrom_dictcan't rebuild them in another process. That breaks the stated round-trip guarantee. Please either reject such objects at construction, for example by checking that_resolve(path) is objand excluding__main__, or document the limitation clearly. Please add a test either way.
Optional suggestions:
- A path with a leading dot (such as
".x") raisesTypeErrorfromimportlibinstead of the documentedValueError. Please catch it in_resolve. from_dictimports any installed module named in its input. A note in the docstring that it is for trusted input only would help.Result.statusisn't validated. I understand this is deferred on purpose, so it's fine to leave.
Reviewed commit 52eb8e7
|
/review |
1 similar comment
|
/review |
| for i in range(len(parts), 0, -1): | ||
| prefix = ".".join(parts[:i]) | ||
| try: | ||
| obj = importlib.import_module(prefix) |
There was a problem hiding this comment.
[medium] from_dict imports arbitrary modules and returns any function named in its input
_resolve calls importlib.import_module on every dotted path found in __type__ and then getattrs the rest. _is_type_ref (line 145) accepts any plain or builtin function, so input such as {"__type__": "os.system"} resolves successfully. Evaluation.from_dict is positioned as the loader for JSON/YAML specs that the future runner will read, so loading a spec from an untrusted or shared source imports any installed module (running its top-level code) and returns callables that the runner may later call with the params. The PR does not state this trust boundary. Fix: document in Evaluation.from_dict that its input must be trusted, and either restrict resolution to an allowlist of module prefixes (for example pyhealth. and the benchmark's own modules) or limit it to classes, with a test that a disallowed path is rejected.
| ): | ||
| raise | ||
| continue | ||
| except ValueError: # Empty or malformed module name. |
There was a problem hiding this comment.
[low] Relative-looking type paths raise TypeError, not the documented ValueError
importlib.import_module raises TypeError for names with a leading dot when no package is given, for example ".json" or ".". Only ValueError is caught here, so Evaluation.from_dict({"c": {"__type__": ".json"}}) raises TypeError, while from_dict documents ValueError for unresolvable paths. Fix: reject paths that have an empty component, or catch (ValueError, TypeError), and add ".json" to test_unresolvable_path_raises.
There was a problem hiding this comment.
AI Review
suggestion: need change
decision: human review
Thank you for this contribution. The minimal Benchmark, Evaluation and Result abstraction is clearly thought through. I like the canonical-JSON ids, the import-path encoding, and the way the deferred items are spelled out. Tests are thorough, and coverage is good: the new files are at 96–100%, and the repository total of 73.4% is above the 60% bar. The change looks close to ready.
To get this merged:
- Document or restrict what
Evaluation.from_dictwill load._resolvein_serialization.pyimports any module named in the input and returns any callable it finds there, including functions such asos.system. It doesn't call them, but importing a module can run code, and a benchmark may later call whatever a loadedparamsentry points to. Please do one of these:- State in the
from_dictdocstring, and in the docs page, that it must only be given trusted input. - Or restrict resolution, for example to an allow-list of module prefixes, or to a caller-supplied check.
- State in the
Optional suggestions:
- Relative-looking paths such as
".json"raiseTypeErrorinstead of the documentedValueError. Catching that case in_resolvewould make the contract match. - Tests for the
super()validator hooks, the "reports every problem at once" claim, andcache_dirpass-through would round out the suite.
Reviewed commit adceb6e
Logiquo
left a comment
There was a problem hiding this comment.
Document or restrict what
Evaluation.from_dictwill load._resolvein_serialization.pyimports any module named in the input and returns any callable it finds there, including functions such asos.system. It doesn't call them, but importing a module can run code, and a benchmark may later call whatever a loadedparamsentry points to. Please do one of these:
- State in the
from_dictdocstring, and in the docs page, that it must only be given trusted input.- Or restrict resolution, for example to an allow-list of module prefixes, or to a caller-supplied check.
I think security check can be left as the subclass to handle it.
This PR adds the base abstraction for a
pyhealth.benchmarkspackage:Benchmark,Evaluation, andResult. Concrete benchmarks (starting with CPBench) will subclass it.It is deliberately minimal. It contains only what is needed to define, validate, and execute a batch of evaluations. Anything consumed only by the job runner, result storage, or leaderboard is left out until those components exist. The "Deferred" section below lists each omission and what would trigger adding it. Every item there can be added later without breaking existing subclasses.
Design decisions
Evaluation.idis a SHA-256 of the canonical JSON ofparams(sorted keys). Evaluations are compared and hashed by id, so they work as dict keys and storage keys. An id is unique within one benchmark, so storage should key on(benchmark.name, evaluation.id).paramsas Python objects and are serialized by import path, e.g.{"__type__": "pyhealth.models.rnn.RNN"}. This follows the import-path approach already used bypyhealth.autoimprove.ModelSpec.Evaluation.from_dictresolves these paths back to objects, so evaluations round-trip through JSON and YAML. Public re-export paths are accepted, and the id always uses the defining module, sopyhealth.models.RNNandpyhealth.models.rnn.RNNgive the same id."__type__"is a reserved key inparams."__type__"key fail immediately.Result.statusis"success"or"failed"._evaluateis expected to catch per-evaluation failures and return failed Results, so one crash doesn't discard a whole batch. An exception that escapes_evaluatemeans the whole batch failed.evaluate()is final, and it runsvalidate_evaluations → _evaluate → validate_results. Subclasses implement_evaluateand extend the validators viasuper(). The base validators reject duplicate evaluations and require exactly one result per evaluation. Validation reports every problem at once.metadata["error"]must never be published.metadata["error_type"](the exception class name) is the display-safe field.cache_dirmay be shared by concurrent jobs, so benchmarks must write atomically (temporary file, thenos.replace). Benchmarks own their cache keys and reuse decisions.base.pycontains the public interface and its contracts._serialization.pyholds the private helpers for canonical JSON, encoding import paths, and resolving them. All helpers are_-prefixed and tested only through the public API, so they can be refactored freely.Known trade-offs
paramsmust not be mutated after construction, because the id is cached and is not deep-frozen.1and1.0give different ids.Deferred (add when needed)
versionproperty (non-abstract, with a default)group_evaluations()scheduling hintValueErrorsparamsResulthelper constructors and validationFiles to review
pyhealth/benchmarks/base.py: the public interface (Benchmark,Evaluation,Result) and their contractspyhealth/benchmarks/_serialization.py: private helpers for canonical JSON, encoding import paths, and resolving thempyhealth/benchmarks/__init__.py: exportstests/core/test_benchmark_base.py: tests (unittest, so they run under bothpytestandpixi run test), including thefrom_dictround trip and a golden-id test that guards the canonical serializationdocs/api/benchmarks.rstanddocs/index.rst: API docs page and toctree entry