ml4t-coursework 0.2.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.
- ml4t_coursework/__init__.py +29 -0
- ml4t_coursework/checks.py +221 -0
- ml4t_coursework/components.py +204 -0
- ml4t_coursework/contracts.py +101 -0
- ml4t_coursework/course.py +86 -0
- ml4t_coursework/data/__init__.py +129 -0
- ml4t_coursework/data/__main__.py +30 -0
- ml4t_coursework/data/etf_close_fingerprint.csv +101 -0
- ml4t_coursework/fixtures.py +67 -0
- ml4t_coursework/project.py +87 -0
- ml4t_coursework/py.typed +0 -0
- ml4t_coursework/reference/__init__.py +6 -0
- ml4t_coursework/reference/_config.py +51 -0
- ml4t_coursework/reference/_shared.py +36 -0
- ml4t_coursework/reference/allocator.py +143 -0
- ml4t_coursework/reference/availability_lag.py +88 -0
- ml4t_coursework/reference/backtest_config.py +57 -0
- ml4t_coursework/reference/baseline_strategy.py +117 -0
- ml4t_coursework/reference/cost_model.py +119 -0
- ml4t_coursework/reference/data_panel.py +96 -0
- ml4t_coursework/reference/exit_rule.py +151 -0
- ml4t_coursework/reference/features.py +115 -0
- ml4t_coursework/reference/fold_splitter.py +116 -0
- ml4t_coursework/reference/holdout_split.py +85 -0
- ml4t_coursework/reference/labeler.py +127 -0
- ml4t_coursework/reference/model_gbm.py +118 -0
- ml4t_coursework/reference/model_linear.py +128 -0
- ml4t_coursework/reference/objective.py +41 -0
- ml4t_coursework/reference/preprocessor.py +150 -0
- ml4t_coursework/reference/quality_gates.py +125 -0
- ml4t_coursework/reference/signal.py +105 -0
- ml4t_coursework/reference/strategy_spec.py +42 -0
- ml4t_coursework/reference/task_form.py +102 -0
- ml4t_coursework/reference/universe.py +100 -0
- ml4t_coursework/report.py +71 -0
- ml4t_coursework/results.py +82 -0
- ml4t_coursework-0.2.0.dist-info/METADATA +130 -0
- ml4t_coursework-0.2.0.dist-info/RECORD +40 -0
- ml4t_coursework-0.2.0.dist-info/WHEEL +4 -0
- ml4t_coursework-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The plumbing every ML4T course notebook runs on.
|
|
2
|
+
|
|
3
|
+
!pip install -q ml4t-coursework
|
|
4
|
+
import ml4t_coursework as mlc
|
|
5
|
+
mlc.use("foundations")
|
|
6
|
+
|
|
7
|
+
from ml4t_coursework import save_component, load_component, append_result, report
|
|
8
|
+
|
|
9
|
+
The package holds the student's project folder, the components they write and the contracts those
|
|
10
|
+
are checked against, their results log and their submission report. It is named for what it does
|
|
11
|
+
rather than for a course, because more than one course installs it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
from .components import catalog, load_component, save_component, source_of, status
|
|
17
|
+
from .contracts import add_source
|
|
18
|
+
from .contracts import get as contract
|
|
19
|
+
from .course import Course, active_course, register_course, use
|
|
20
|
+
from .project import describe, home, setup
|
|
21
|
+
from .report import report
|
|
22
|
+
from .results import append_result, latest, results
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"save_component", "load_component", "append_result", "report",
|
|
26
|
+
"setup", "home", "describe", "status", "catalog", "contract", "results", "latest",
|
|
27
|
+
"source_of", "use", "Course", "register_course", "active_course", "add_source",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The five checks every component contract carries, and the failure they raise.
|
|
2
|
+
|
|
3
|
+
`certification.md` § 2 is the specification. The checks assert properties, never equality with a
|
|
4
|
+
reference output: two correct fold splitters legitimately differ and an equality check would fail
|
|
5
|
+
correct work and teach copying. The reference delta is the one exception and it is reported, not
|
|
6
|
+
enforced.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Failure(Exception):
|
|
19
|
+
"""A contract check that did not pass.
|
|
20
|
+
|
|
21
|
+
Every failure names the expectation and the observed value, because "expected 12 folds, got 9"
|
|
22
|
+
is feedback and a bare AssertionError is not.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, check: str, expected: str, observed: str, hint: str = "") -> None:
|
|
26
|
+
self.check = check
|
|
27
|
+
self.expected = expected
|
|
28
|
+
self.observed = observed
|
|
29
|
+
self.hint = hint
|
|
30
|
+
message = f"{check}: expected {expected}, got {observed}"
|
|
31
|
+
if hint:
|
|
32
|
+
message = f"{message}\n {hint}"
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def require(condition: bool, check: str, expected: str, observed: str, hint: str = "") -> None:
|
|
37
|
+
if not condition:
|
|
38
|
+
raise Failure(check, expected, observed, hint)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CheckResult:
|
|
43
|
+
name: str
|
|
44
|
+
passed: bool
|
|
45
|
+
detail: str = ""
|
|
46
|
+
|
|
47
|
+
def line(self) -> str:
|
|
48
|
+
mark = "pass" if self.passed else "FAIL"
|
|
49
|
+
return f" [{mark}] {self.name}{': ' + self.detail if self.detail else ''}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Conformance:
|
|
54
|
+
component: str
|
|
55
|
+
conformant: bool
|
|
56
|
+
checks: list[CheckResult] = field(default_factory=list)
|
|
57
|
+
delta: str = ""
|
|
58
|
+
stamped_at: str = ""
|
|
59
|
+
helper_version: str = ""
|
|
60
|
+
unit: str = ""
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
head = f"{self.component}: {'conformant' if self.conformant else 'NOT conformant'}"
|
|
64
|
+
body = "\n".join(c.line() for c in self.checks)
|
|
65
|
+
tail = f"\n [note] reference delta: {self.delta}" if self.delta else ""
|
|
66
|
+
return f"{head}\n{body}{tail}"
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"component": self.component,
|
|
71
|
+
"unit": self.unit,
|
|
72
|
+
"conformant": self.conformant,
|
|
73
|
+
"checks": [{"name": c.name, "passed": c.passed, "detail": c.detail} for c in self.checks],
|
|
74
|
+
"reference_delta": self.delta,
|
|
75
|
+
"stamped_at": self.stamped_at,
|
|
76
|
+
"helper_version": self.helper_version,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- comparison helpers, shared by determinism, the leakage probe and the delta ---------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def same(left: Any, right: Any, tol: float = 1e-12) -> bool:
|
|
84
|
+
"""Value equality that understands the shapes components actually return."""
|
|
85
|
+
if isinstance(left, pd.DataFrame) and isinstance(right, pd.DataFrame):
|
|
86
|
+
if left.shape != right.shape or not left.index.equals(right.index):
|
|
87
|
+
return False
|
|
88
|
+
if list(left.columns) != list(right.columns):
|
|
89
|
+
return False
|
|
90
|
+
return bool(np.allclose(left.to_numpy(dtype=float), right.to_numpy(dtype=float),
|
|
91
|
+
atol=tol, rtol=0, equal_nan=True))
|
|
92
|
+
if isinstance(left, pd.Series) and isinstance(right, pd.Series):
|
|
93
|
+
if not left.index.equals(right.index):
|
|
94
|
+
return False
|
|
95
|
+
return bool(np.allclose(left.to_numpy(dtype=float), right.to_numpy(dtype=float),
|
|
96
|
+
atol=tol, rtol=0, equal_nan=True))
|
|
97
|
+
if isinstance(left, (list, tuple)) and isinstance(right, (list, tuple)):
|
|
98
|
+
return len(left) == len(right) and all(same(a, b, tol) for a, b in zip(left, right))
|
|
99
|
+
if isinstance(left, pd.Index) and isinstance(right, pd.Index):
|
|
100
|
+
return left.equals(right)
|
|
101
|
+
if isinstance(left, np.ndarray) and isinstance(right, np.ndarray):
|
|
102
|
+
return left.shape == right.shape and bool(np.allclose(left, right, atol=tol, equal_nan=True))
|
|
103
|
+
return bool(left == right)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def overlap(left: Any, right: Any) -> tuple[Any, Any]:
|
|
107
|
+
"""Restrict two indexed objects to the rows they share, in the same order."""
|
|
108
|
+
if isinstance(left, (pd.Series, pd.DataFrame)) and isinstance(right, (pd.Series, pd.DataFrame)):
|
|
109
|
+
shared = left.index.intersection(right.index)
|
|
110
|
+
return left.loc[shared], right.loc[shared]
|
|
111
|
+
return left, right
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def describe_delta(student: Any, reference: Any) -> str:
|
|
115
|
+
"""One line on how far the student's output sits from the shipped reference's.
|
|
116
|
+
|
|
117
|
+
Reported and never enforced. Where a design choice legitimately differs the delta is
|
|
118
|
+
information; only the properties gate.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
a, b = overlap(student, reference)
|
|
122
|
+
if isinstance(a, (pd.Series, pd.DataFrame)) and isinstance(b, (pd.Series, pd.DataFrame)):
|
|
123
|
+
if len(a) == 0:
|
|
124
|
+
return "no overlapping rows, so nothing to compare"
|
|
125
|
+
if isinstance(a, pd.DataFrame) and isinstance(b, pd.DataFrame):
|
|
126
|
+
cols = a.columns.intersection(b.columns)
|
|
127
|
+
if len(cols) == 0:
|
|
128
|
+
return "no overlapping columns, so nothing to compare"
|
|
129
|
+
a, b = a[cols], b[cols]
|
|
130
|
+
diff = np.abs(np.asarray(a, dtype=float) - np.asarray(b, dtype=float))
|
|
131
|
+
scale = np.nanmean(np.abs(np.asarray(b, dtype=float)))
|
|
132
|
+
mean = np.nanmean(diff)
|
|
133
|
+
rel = f", about {mean / scale:.1%} of the reference's own scale" if scale else ""
|
|
134
|
+
return f"mean absolute difference {mean:.6g} over {len(a)} rows{rel}"
|
|
135
|
+
if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
|
|
136
|
+
return f"{len(a)} against the reference's {len(b)}"
|
|
137
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
138
|
+
differing = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
|
|
139
|
+
return "identical to the reference" if not differing else f"differs on {', '.join(differing)}"
|
|
140
|
+
return "identical to the reference" if same(a, b) else f"{a!r} against the reference's {b!r}"
|
|
141
|
+
except Exception as exc: # a delta is information; it must never break a save
|
|
142
|
+
return f"not computable ({type(exc).__name__})"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- the four gating checks --------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_interface(contract, obj) -> CheckResult:
|
|
149
|
+
contract.interface(obj)
|
|
150
|
+
return CheckResult("interface", True, contract.interface_detail)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_determinism(contract, obj) -> CheckResult:
|
|
154
|
+
first = contract.probe(obj)
|
|
155
|
+
second = contract.probe(obj)
|
|
156
|
+
require(
|
|
157
|
+
same(first, second),
|
|
158
|
+
"determinism",
|
|
159
|
+
"the same output from the same input twice",
|
|
160
|
+
"two different outputs",
|
|
161
|
+
"Something in the component is drawing on state that changes between calls - an unseeded "
|
|
162
|
+
"random draw, a mutable default, or a value read from the clock.",
|
|
163
|
+
)
|
|
164
|
+
return CheckResult("determinism", True, "same input twice, same output")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_leakage(contract, obj) -> CheckResult:
|
|
168
|
+
if contract.leakage is None:
|
|
169
|
+
return CheckResult("leakage probe", True, contract.leakage_note)
|
|
170
|
+
detail = contract.leakage(obj)
|
|
171
|
+
return CheckResult("leakage probe", True, detail or contract.leakage_note)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run_invariants(contract, obj) -> list[CheckResult]:
|
|
175
|
+
results = []
|
|
176
|
+
for label, fn in contract.invariants:
|
|
177
|
+
detail = fn(obj)
|
|
178
|
+
results.append(CheckResult(label, True, detail or ""))
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def evaluate(contract, obj) -> Conformance:
|
|
183
|
+
"""Run all five checks, stopping the gating ones at the first failure."""
|
|
184
|
+
result = Conformance(component=contract.name, unit=contract.units[0], conformant=True)
|
|
185
|
+
stages: list[Callable[[], Any]] = [
|
|
186
|
+
lambda: [run_interface(contract, obj)],
|
|
187
|
+
lambda: [run_leakage(contract, obj)],
|
|
188
|
+
lambda: [run_determinism(contract, obj)],
|
|
189
|
+
lambda: run_invariants(contract, obj),
|
|
190
|
+
]
|
|
191
|
+
names = ["interface", "leakage probe", "determinism", "domain invariants"]
|
|
192
|
+
for name, stage in zip(names, stages):
|
|
193
|
+
try:
|
|
194
|
+
result.checks.extend(stage())
|
|
195
|
+
except Failure as failure:
|
|
196
|
+
result.checks.append(CheckResult(failure.check, False, str(failure).split(": ", 1)[1]))
|
|
197
|
+
result.conformant = False
|
|
198
|
+
break
|
|
199
|
+
except NameError as exc:
|
|
200
|
+
missing = str(exc).split("'")[1] if "'" in str(exc) else "something"
|
|
201
|
+
result.checks.append(CheckResult(name, False, (
|
|
202
|
+
f"the component calls {missing!r}, which is not part of it. "
|
|
203
|
+
f"Colab does not keep your session, so a later notebook loads this file on its own "
|
|
204
|
+
f"and {missing!r} will not be there. Either move it inside the component, or pass "
|
|
205
|
+
f"it along: save_component(..., also=[{missing}]) for a function, "
|
|
206
|
+
f"include={{{missing!r}: ...}} for a value."
|
|
207
|
+
)))
|
|
208
|
+
result.conformant = False
|
|
209
|
+
break
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
result.checks.append(
|
|
212
|
+
CheckResult(name, False, f"the check could not run: {type(exc).__name__}: {exc}")
|
|
213
|
+
)
|
|
214
|
+
result.conformant = False
|
|
215
|
+
break
|
|
216
|
+
if result.conformant:
|
|
217
|
+
try:
|
|
218
|
+
result.delta = describe_delta(contract.probe(obj), contract.probe(contract.reference()))
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
result.delta = f"not computable ({type(exc).__name__})"
|
|
221
|
+
return result
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""`save_component` and `load_component`: how a component is checked, stamped and recovered.
|
|
2
|
+
|
|
3
|
+
What persists between units is the student's *code*, not intermediate data. A notebook defines its
|
|
4
|
+
component from scratch and writes it here; a later pipeline notebook loads it, or falls back to the
|
|
5
|
+
shipped reference and says so. Restart-and-run-all works on any unit, in any order, on a cold
|
|
6
|
+
session, which is the property this module exists to protect.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import textwrap
|
|
15
|
+
from typing import Any, Callable, Sequence
|
|
16
|
+
|
|
17
|
+
from . import contracts, project
|
|
18
|
+
from .checks import Conformance, evaluate
|
|
19
|
+
|
|
20
|
+
__version_marker__ = "components"
|
|
21
|
+
|
|
22
|
+
_PREAMBLE = (
|
|
23
|
+
"# Saved by ml4t-coursework. This is your own code, exactly as you wrote it.\n"
|
|
24
|
+
"import numpy as np\n"
|
|
25
|
+
"import pandas as pd\n\n"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version() -> str:
|
|
30
|
+
from . import __version__
|
|
31
|
+
|
|
32
|
+
return __version__
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now() -> str:
|
|
36
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _source_of(obj: Any, name: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return textwrap.dedent(inspect.getsource(obj))
|
|
42
|
+
except (OSError, TypeError) as exc:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Could not read the source of the object you passed for {name!r}.\n"
|
|
45
|
+
f" Define it as a function or a class in a notebook cell and pass the object itself, "
|
|
46
|
+
f"not a lambda, a partial or an instance.\n"
|
|
47
|
+
f" ({type(exc).__name__}: {exc})"
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rebuild(source: str, symbol: str, name: str) -> Any:
|
|
52
|
+
"""Execute the saved source in a fresh namespace and return the object.
|
|
53
|
+
|
|
54
|
+
This is deliberately done before the checks run. What gets validated has to be what a later
|
|
55
|
+
notebook will actually load, and a component that only works because of something else in the
|
|
56
|
+
student's session would pass in place and fail on a cold kernel.
|
|
57
|
+
"""
|
|
58
|
+
namespace: dict[str, Any] = {}
|
|
59
|
+
import numpy as np
|
|
60
|
+
import pandas as pd
|
|
61
|
+
|
|
62
|
+
namespace.update({"np": np, "pd": pd, "numpy": np, "pandas": pd})
|
|
63
|
+
try:
|
|
64
|
+
exec(compile(_PREAMBLE + source, f"<{name}>", "exec"), namespace)
|
|
65
|
+
except NameError as exc:
|
|
66
|
+
missing = str(exc).split("'")[1] if "'" in str(exc) else "something"
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Your {name} refers to {missing!r}, which is not part of it.\n"
|
|
69
|
+
f" Colab does not keep your session, so a later notebook loads this file on its own "
|
|
70
|
+
f"and {missing!r} will not be there.\n"
|
|
71
|
+
f" Either move it inside the component, or pass it along: "
|
|
72
|
+
f"save_component({name!r}, {symbol}, also=[{missing}])"
|
|
73
|
+
) from exc
|
|
74
|
+
if symbol not in namespace:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"The saved source does not define {symbol!r}.\n"
|
|
77
|
+
f" Name the function or class you pass, and pass it by that name."
|
|
78
|
+
)
|
|
79
|
+
return namespace[symbol]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_component(
|
|
83
|
+
name: str,
|
|
84
|
+
obj: Any,
|
|
85
|
+
also: Sequence[Callable] = (),
|
|
86
|
+
include: dict[str, Any] | None = None,
|
|
87
|
+
quiet: bool = False,
|
|
88
|
+
) -> Conformance:
|
|
89
|
+
"""Check a component against its contract, stamp the verdict, and write it to the project.
|
|
90
|
+
|
|
91
|
+
`also` carries any function you defined in another cell that the component calls; `include`
|
|
92
|
+
carries any plain value it reads, as `{name: value}`. A component has to stand on its own,
|
|
93
|
+
because a later notebook loads this file on a cold session with nothing else in scope.
|
|
94
|
+
"""
|
|
95
|
+
contract = contracts.get(name)
|
|
96
|
+
folder = project.components_dir()
|
|
97
|
+
|
|
98
|
+
if contract.kind == "config":
|
|
99
|
+
if not isinstance(obj, dict):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"{name} is a recorded choice, not a function.\n"
|
|
102
|
+
f" Pass a dictionary. Expected keys: {contract.interface_detail}\n"
|
|
103
|
+
f" You passed a {type(obj).__name__}."
|
|
104
|
+
)
|
|
105
|
+
payload = dict(obj)
|
|
106
|
+
checked: Any = payload
|
|
107
|
+
body = json.dumps(payload, indent=2, default=str)
|
|
108
|
+
target = folder / f"{name}.json"
|
|
109
|
+
symbol = name
|
|
110
|
+
else:
|
|
111
|
+
symbol = getattr(obj, "__name__", None)
|
|
112
|
+
if not symbol:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{name}: pass the function or class itself, by name, not an instance or a lambda."
|
|
115
|
+
)
|
|
116
|
+
parts = [f"{key} = {value!r}" for key, value in (include or {}).items()]
|
|
117
|
+
parts += [_source_of(helper, name) for helper in also]
|
|
118
|
+
parts.append(_source_of(obj, name))
|
|
119
|
+
body = "\n\n".join(parts)
|
|
120
|
+
checked = _rebuild(body, symbol, name)
|
|
121
|
+
target = folder / f"{name}.py"
|
|
122
|
+
|
|
123
|
+
result = evaluate(contract, checked)
|
|
124
|
+
result.stamped_at = _now()
|
|
125
|
+
result.helper_version = _version()
|
|
126
|
+
|
|
127
|
+
target.write_text(_PREAMBLE + body if contract.kind == "callable" else body)
|
|
128
|
+
meta = result.to_dict() | {"symbol": symbol, "file": target.name, "kind": contract.kind}
|
|
129
|
+
(folder / f"{name}.meta.json").write_text(json.dumps(meta, indent=2))
|
|
130
|
+
|
|
131
|
+
if not quiet:
|
|
132
|
+
print(result)
|
|
133
|
+
if result.conformant:
|
|
134
|
+
print(f" saved to {target}")
|
|
135
|
+
else:
|
|
136
|
+
print(f" saved to {target}, and it is recorded as not conformant.")
|
|
137
|
+
print(" Nothing is blocked: fix it whenever you like, and any pipeline notebook you "
|
|
138
|
+
"run meanwhile uses the shipped reference for this step.")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _meta(name: str) -> dict | None:
|
|
143
|
+
path = project.components_dir(create=False) / f"{name}.meta.json"
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(path.read_text())
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def source_of(name: str) -> str:
|
|
153
|
+
"""`'yours'` or `'reference'` - what `load_component` would return right now."""
|
|
154
|
+
meta = _meta(name)
|
|
155
|
+
return "yours" if meta and meta.get("conformant") else "reference"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_component(name: str, quiet: bool = False) -> Any:
|
|
159
|
+
"""Return the student's component when it is conformant, the shipped reference otherwise.
|
|
160
|
+
|
|
161
|
+
It always says which. This is what stops a wrong Part 4 from blocking Part 7.
|
|
162
|
+
"""
|
|
163
|
+
contract = contracts.get(name)
|
|
164
|
+
meta = _meta(name)
|
|
165
|
+
if meta and meta.get("conformant"):
|
|
166
|
+
folder = project.components_dir(create=False)
|
|
167
|
+
path = folder / meta["file"]
|
|
168
|
+
try:
|
|
169
|
+
if contract.kind == "config":
|
|
170
|
+
obj = json.loads(path.read_text())
|
|
171
|
+
else:
|
|
172
|
+
obj = _rebuild(path.read_text(), meta["symbol"], name)
|
|
173
|
+
if not quiet:
|
|
174
|
+
stamped = meta.get("stamped_at", "")[:10]
|
|
175
|
+
print(f"{name}: using your implementation (checked {stamped})")
|
|
176
|
+
return obj
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
if not quiet:
|
|
179
|
+
print(f"{name}: your saved version would not load ({type(exc).__name__}), so this "
|
|
180
|
+
f"run uses the shipped reference.")
|
|
181
|
+
elif not quiet:
|
|
182
|
+
reason = "you have not written it yet" if meta is None else "yours is recorded as not conformant"
|
|
183
|
+
print(f"{name}: using the shipped reference, because {reason}.")
|
|
184
|
+
return contract.reference()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def status() -> str:
|
|
188
|
+
"""Every component the course asks for, and where each one stands."""
|
|
189
|
+
lines = []
|
|
190
|
+
for name, contract in sorted(contracts.load_all().items()):
|
|
191
|
+
meta = _meta(name)
|
|
192
|
+
if meta is None:
|
|
193
|
+
state = "not written yet"
|
|
194
|
+
elif meta.get("conformant"):
|
|
195
|
+
state = f"conformant, {meta.get('stamped_at', '')[:10]}"
|
|
196
|
+
else:
|
|
197
|
+
failed = [c["name"] for c in meta.get("checks", []) if not c["passed"]]
|
|
198
|
+
state = f"NOT conformant ({', '.join(failed) or 'unknown'})"
|
|
199
|
+
lines.append(f" {name:<20} unit {contract.units[0]:<5} {state}")
|
|
200
|
+
return "components in your project folder:\n" + "\n".join(lines)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def catalog() -> str:
|
|
204
|
+
return contracts.catalog()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""What a component is, and the registry of the twenty the course asks for.
|
|
2
|
+
|
|
3
|
+
One `Contract` per component, declared beside its reference implementation in `reference/`. The
|
|
4
|
+
contract is authored *from* the reference, which is what guarantees it is passable, and every
|
|
5
|
+
reference is tested against its own contract plus a deliberately broken variant that must fail it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import pkgutil
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
CONTRACTS: dict[str, "Contract"] = {}
|
|
17
|
+
|
|
18
|
+
_SOURCES: list[str] = [f"{__package__}.reference"]
|
|
19
|
+
_LOADED: set[str] = set()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Contract:
|
|
24
|
+
name: str
|
|
25
|
+
kind: str
|
|
26
|
+
units: tuple[str, ...]
|
|
27
|
+
summary: str
|
|
28
|
+
probe: Callable[[Any], Any]
|
|
29
|
+
interface: Callable[[Any], None]
|
|
30
|
+
reference: Callable[[], Any]
|
|
31
|
+
interface_detail: str = ""
|
|
32
|
+
invariants: tuple[tuple[str, Callable[[Any], str | None]], ...] = ()
|
|
33
|
+
leakage: Callable[[Any], str | None] | None = None
|
|
34
|
+
leakage_note: str = "not applicable to this component, and here is why"
|
|
35
|
+
dependencies: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.kind not in {"callable", "config"}:
|
|
39
|
+
raise ValueError(f"{self.name}: kind must be 'callable' or 'config', got {self.kind!r}")
|
|
40
|
+
|
|
41
|
+
def describe(self) -> str:
|
|
42
|
+
lines = [f"{self.name} ({self.kind}, written in unit {' and '.join(self.units)})",
|
|
43
|
+
f" {self.summary}",
|
|
44
|
+
f" interface: {self.interface_detail}",
|
|
45
|
+
f" leakage probe: {self.leakage_note}"]
|
|
46
|
+
for label, _ in self.invariants:
|
|
47
|
+
lines.append(f" invariant: {label}")
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register(contract: Contract) -> Contract:
|
|
52
|
+
existing = CONTRACTS.get(contract.name)
|
|
53
|
+
if existing is not None and existing is not contract:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Two different contracts are both called {contract.name!r}.\n"
|
|
56
|
+
f" Already registered from: {existing.reference.__module__}\n"
|
|
57
|
+
f" Now registering from: {contract.reference.__module__}\n"
|
|
58
|
+
f" Component names are shared across every registered source, so pick another."
|
|
59
|
+
)
|
|
60
|
+
CONTRACTS[contract.name] = contract
|
|
61
|
+
return contract
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_source(package: str | ModuleType) -> None:
|
|
65
|
+
"""Add a package of contract modules to discovery. A course outside this one calls this."""
|
|
66
|
+
name = package if isinstance(package, str) else package.__name__
|
|
67
|
+
if name not in _SOURCES:
|
|
68
|
+
_SOURCES.append(name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_all() -> dict[str, Contract]:
|
|
72
|
+
"""Import every module in every registered source package, each of which registers a contract.
|
|
73
|
+
|
|
74
|
+
Tracked per source rather than by whether the registry is empty: a caller that pre-registers
|
|
75
|
+
one contract of its own must not suppress the import of every other source.
|
|
76
|
+
"""
|
|
77
|
+
for source in list(_SOURCES):
|
|
78
|
+
if source in _LOADED:
|
|
79
|
+
continue
|
|
80
|
+
_LOADED.add(source)
|
|
81
|
+
package = importlib.import_module(source)
|
|
82
|
+
for module in pkgutil.iter_modules(package.__path__):
|
|
83
|
+
if not module.name.startswith("_"):
|
|
84
|
+
importlib.import_module(f"{package.__name__}.{module.name}")
|
|
85
|
+
return CONTRACTS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get(name: str) -> Contract:
|
|
89
|
+
contracts = load_all()
|
|
90
|
+
if name not in contracts:
|
|
91
|
+
known = ", ".join(sorted(contracts))
|
|
92
|
+
raise KeyError(
|
|
93
|
+
f"There is no component called {name!r} in this course.\n"
|
|
94
|
+
f" The ones there are: {known}\n"
|
|
95
|
+
f" Check the spelling against the name the unit's notebook asked you to save."
|
|
96
|
+
)
|
|
97
|
+
return contracts[name]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def catalog() -> str:
|
|
101
|
+
return "\n\n".join(c.describe() for _, c in sorted(load_all().items()))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Which course a notebook belongs to, and the few facts that differ between them.
|
|
2
|
+
|
|
3
|
+
Almost everything here is shared: the checks, the contract machinery, the results columns, the
|
|
4
|
+
report. Three things are not, and they are the three a package named after one course would have
|
|
5
|
+
gotten wrong - the project folder on the student's Drive, the environment override that points at
|
|
6
|
+
it, and the list of stage names a results row may carry. They live on a `Course` and a notebook
|
|
7
|
+
selects one in its opening cell.
|
|
8
|
+
|
|
9
|
+
There is deliberately no default. A notebook that never says which course it belongs to would
|
|
10
|
+
otherwise write its components into another course's folder, and the failure would surface weeks
|
|
11
|
+
later as a component that loads but does the wrong thing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
COURSES: dict[str, "Course"] = {}
|
|
19
|
+
_active: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Course:
|
|
24
|
+
key: str
|
|
25
|
+
title: str
|
|
26
|
+
folder: str
|
|
27
|
+
env_var: str
|
|
28
|
+
stages: tuple[str, ...]
|
|
29
|
+
terminal_stages: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
unknown = [s for s in self.terminal_stages if s not in self.stages]
|
|
33
|
+
if unknown:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"{self.key}: terminal stages must also be stages, and "
|
|
36
|
+
f"{', '.join(unknown)} is not."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_course(course: Course, replace: bool = False) -> Course:
|
|
41
|
+
existing = COURSES.get(course.key)
|
|
42
|
+
if existing is not None and existing != course and not replace:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"A different course is already registered as {course.key!r}.\n"
|
|
45
|
+
f" Registered: {existing}\n"
|
|
46
|
+
f" Pass replace=True if you meant to change it."
|
|
47
|
+
)
|
|
48
|
+
COURSES[course.key] = course
|
|
49
|
+
return course
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def use(key: str) -> Course:
|
|
53
|
+
"""Select the course this session belongs to. Every notebook calls this in its opening cell."""
|
|
54
|
+
global _active
|
|
55
|
+
if key not in COURSES:
|
|
56
|
+
known = ", ".join(sorted(COURSES)) or "none yet"
|
|
57
|
+
raise KeyError(
|
|
58
|
+
f"There is no course registered as {key!r}.\n"
|
|
59
|
+
f" Registered: {known}\n"
|
|
60
|
+
f" A course registers itself with ml4t_coursework.register_course()."
|
|
61
|
+
)
|
|
62
|
+
_active = key
|
|
63
|
+
return COURSES[key]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def active_course() -> Course:
|
|
67
|
+
if _active is None:
|
|
68
|
+
known = ", ".join(repr(k) for k in sorted(COURSES)) or "none yet"
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"This session has not said which course it belongs to, so there is no project "
|
|
71
|
+
"folder to read or write.\n"
|
|
72
|
+
f" Add ml4t_coursework.use(...) to the notebook's opening cell, one of: {known}.\n"
|
|
73
|
+
" The setup notebook does this and so does every unit notebook."
|
|
74
|
+
)
|
|
75
|
+
return COURSES[_active]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
register_course(Course(
|
|
79
|
+
key="foundations",
|
|
80
|
+
title="ML4T: Foundations",
|
|
81
|
+
folder="ml4t-foundations",
|
|
82
|
+
env_var="ML4T_FOUNDATIONS_HOME",
|
|
83
|
+
stages=("baseline", "v0", "part_02", "part_04", "part_05", "part_06", "part_07", "part_08",
|
|
84
|
+
"final"),
|
|
85
|
+
terminal_stages=("baseline", "v0", "final"),
|
|
86
|
+
))
|