flowchart LR A[Refactored signals] --> B[Progressive assertions] B --> C[Ruff and MyPy] C --> D[Pre-commit gate] D --> E[CI workflow] E --> F[Open observatory dome]
Unit Challenge: Untangle the Clockwork Observatory
1. Open the observatory without changing its signals
The Clockwork Observatory receives unusual sky signals. Its clockwork dome opens only for high-priority observations. The current script prints the correct sample report, but one large function mixes validation, scoring, classification, sorting, and text rendering. Names are vague, the quality configuration is missing, and no clean runner repeats the local result.
Your goal is to untangle the project without changing the published report or boundary behavior.
The supplied scoring rules are complete:
clarityis an integer from 0 through 100 and contributes twice its value;- a rare signal earns 25 bonus points;
- each minute of age subtracts one point, capped at a 60-point penalty;
- a score of 180 or more means
OPEN DOME; - a score from 120 through 179 means
REVIEW; - a lower score means
ARCHIVE; - ranking uses descending score, then ascending signal name;
- caller-provided records must not be mutated; and
- invalid name, clarity, and age values raise the exact errors supplied below.
Run the baseline first. Make one extraction or configuration change, run the smallest matching check, and record the result. The challenge is difficult because several quality signals must agree—not because you must invent hidden domain rules.
2. Understand the acceptance example
The observatory receives:
signals = [
{"name": "Lyra Echo", "clarity": 90, "age_minutes": 5, "rare": True},
{"name": "Orion Bell", "clarity": 80, "age_minutes": 10, "rare": False},
{"name": "Cygnus Key", "clarity": 80, "age_minutes": 10, "rare": False},
{"name": "Moth Light", "clarity": 50, "age_minutes": 80, "rare": False},
]The exact result is:
Before editing, calculate all four scores by hand. Explain why Cygnus Key appears before Orion Bell, and predict the report for an empty sequence.
Project shape
Create this local project:
Use the notebook for calculation experiments if useful, but the hook and CI artifacts belong in a Git project.
3. Start from the contract
Place this valid but difficult implementation in signals.py. It establishes the baseline public report; you will replace its internal structure while keeping build_observatory_report public.
from collections.abc import Sequence
from typing import TypedDict
class Signal(TypedDict):
name: str
clarity: int
age_minutes: int
rare: bool
def build_observatory_report(signals: Sequence[Signal]) -> str:
"""Return the complete ranked observatory report."""
rows = []
for item in signals:
n = item["name"].strip()
c = item["clarity"]
a = item["age_minutes"]
if not n:
raise ValueError("signal name cannot be empty")
if not 0 <= c <= 100:
raise ValueError("clarity must be between 0 and 100")
if a < 0:
raise ValueError("age_minutes cannot be negative")
s = c * 2 + (25 if item["rare"] else 0) - min(a, 60)
if s >= 180:
d = "OPEN DOME"
elif s >= 120:
d = "REVIEW"
else:
d = "ARCHIVE"
rows.append({"name": n, "score": s, "decision": d})
rows.sort(key=lambda row: (-row["score"], row["name"]))
lines = ["CLOCKWORK OBSERVATORY"]
for position, row in enumerate(rows, start=1):
lines.append(
f"{position}. {row['name']} | {row['score']} | {row['decision']}"
)
return "\n".join(lines)Preserve these names and signatures in the completed design:
class RankedSignal(TypedDict):
"""A validated signal with its computed score and decision."""
name: str
score: int
decision: str
def score_signal(signal: Signal) -> int:
"""Return priority points for one validated signal."""
...
def classify_signal(score: int) -> str:
"""Return the observatory decision for a signal score."""
...
def rank_signals(signals: Sequence[Signal]) -> list[RankedSignal]:
"""Return ranked copies without changing the caller's sequence."""
...
def build_observatory_report(signals: Sequence[Signal]) -> str:
"""Return the complete ranked observatory report."""
...The ellipses are design placeholders for this displayed contract; replace them with working bodies in the project. Do not change the public names to make tests pass.
Quality configuration contract
Configure these policies in pyproject.toml:
- project requires Python 3.10 or newer;
- Ruff targets
py310, line length 88, andsrc/tests; - Ruff selects
E,F,I,UP,B, andRUF; - MyPy uses Python 3.10, strict mode, and visible error codes; and
- pytest adds
srcto its import path and discoverstests.
The final check-only command sequence is:
4. Build in small stages
Use this order to keep cause and evidence connected:
- Copy the starter and run the report assertions as a green baseline.
- Add
RankedSignal; run MyPy and explain every diagnostic before repairing it. - Extract
score_signal; rerun only score and validation checks. - Extract
classify_signal; rerun threshold boundaries. - Extract
rank_signals; verify tie order and no input mutation. - Reduce
build_observatory_reportto orchestration and rendering; compare the exact report. - Add Ruff/MyPy/pytest configuration and finish a clean manual gate.
- Add local pre-commit hooks for all four commands. Use check-only formatting in the final gate.
- Complete
quality.ymlso a clean GitHub Actions runner checks out source, sets up Python 3.10, installs the tools, and runs the same commands. - Inspect the final diff and explain why each extracted function has one reason to change.
Do not combine a scoring behavior change with the refactor. If you think a rule could be improved, record it as a future requirement after the challenge.
The dome opens only after the same source passes behavior, static, local, and clean-runner checks.
5. Run progressive assertions
Run these after the matching stage. A failing assertion identifies the earliest contract difference; do not edit expected values to manufacture a pass.
from copy import deepcopy
lyra = {
"name": "Lyra Echo",
"clarity": 90,
"age_minutes": 5,
"rare": True,
}
moth = {
"name": "Moth Light",
"clarity": 50,
"age_minutes": 80,
"rare": False,
}
assert score_signal(lyra) == 200
assert score_signal(moth) == 40
assert classify_signal(180) == "OPEN DOME"
assert classify_signal(179) == "REVIEW"
assert classify_signal(120) == "REVIEW"
assert classify_signal(119) == "ARCHIVE"signals = [
{"name": "Lyra Echo", "clarity": 90, "age_minutes": 5, "rare": True},
{"name": "Orion Bell", "clarity": 80, "age_minutes": 10, "rare": False},
{"name": "Cygnus Key", "clarity": 80, "age_minutes": 10, "rare": False},
{"name": "Moth Light", "clarity": 50, "age_minutes": 80, "rare": False},
]
original = deepcopy(signals)
ranked = rank_signals(signals)
assert [item["name"] for item in ranked] == [
"Lyra Echo",
"Cygnus Key",
"Orion Bell",
"Moth Light",
]
assert signals == original
assert build_observatory_report([]) == "CLOCKWORK OBSERVATORY"Validation checks:
invalid_signals = [
({"name": " ", "clarity": 50, "age_minutes": 1, "rare": False}, "signal name cannot be empty"),
({"name": "Nova", "clarity": 101, "age_minutes": 1, "rare": False}, "clarity must be between 0 and 100"),
({"name": "Nova", "clarity": 50, "age_minutes": -1, "rare": False}, "age_minutes cannot be negative"),
]
for signal, expected_message in invalid_signals:
try:
score_signal(signal)
except ValueError as error:
assert str(error) == expected_message
else:
raise AssertionError("invalid signal was accepted")Gate evidence targets
Your final record should show:
Exact timing and file counts can differ if your test layout differs. The four commands must exit zero, and a second pre-commit run --all-files must make no new modification.
Do not change 200, tie order, exception messages, or the report to match an incorrect implementation. Trace the first mismatch to one scoring, validation, ranking, or rendering stage.
6. Use the hint ladder only when needed
Hint 1
Extract score_signal without changing a single expression first. Keep validation in the same order, name the three score components, and run only the score/invalid checks.
Hint 2
In rank_signals, calculate score = score_signal(signal) once, then construct RankedSignal(name=..., score=score, decision=classify_signal(score)). Return sorted(ranked, key=lambda signal: (-signal["score"], signal["name"])).
Hint 3
Use local pre-commit hooks with language: system; set pass_filenames: false for mypy src and pytest -q. The CI workflow should use check-only commands and must not contain || true or rewrite formatting.
7. Keep debugging evidence
Keep one real failure from the rescue. Good candidates include a reversed tie, a score calculated twice with inconsistent data, a MyPy TypedDict mismatch, a hook modification, or a CI step running from the wrong directory.
| Failure | Evidence | Hypothesis | Controlled change | Verified rerun |
|---|---|---|---|---|
| What failed first? | Exact assertion, diagnostic, or hook line | Which single cause fits? | What one edit tested it? | Which focused and full checks now pass? |
Also keep a refactoring log:
| Stage | Structural purpose | Behavior check | Diff observation |
|---|---|---|---|
| Extract scoring | Isolate policy calculation | score/validation checks | Report untouched |
| Extract ranking | Expose tie and ownership rules | rank/mutation checks | Input still copied |
| Add gate | Reproduce policy | four zero exits | No tool rewrites remain |
8. Inspect a complete solution only after your attempt
Show the complete source and quality configuration
A complete signals.py is:
from collections.abc import Sequence
from typing import TypedDict
class Signal(TypedDict):
"""One signal received by the observatory."""
name: str
clarity: int
age_minutes: int
rare: bool
class RankedSignal(TypedDict):
"""A validated signal with its computed score and decision."""
name: str
score: int
decision: str
def score_signal(signal: Signal) -> int:
"""Return priority points for one validated signal."""
name = signal["name"].strip()
clarity = signal["clarity"]
age_minutes = signal["age_minutes"]
if not name:
raise ValueError("signal name cannot be empty")
if not 0 <= clarity <= 100:
raise ValueError("clarity must be between 0 and 100")
if age_minutes < 0:
raise ValueError("age_minutes cannot be negative")
rarity_bonus = 25 if signal["rare"] else 0
freshness_penalty = min(age_minutes, 60)
return clarity * 2 + rarity_bonus - freshness_penalty
def classify_signal(score: int) -> str:
"""Return the observatory decision for a signal score."""
if score >= 180:
return "OPEN DOME"
if score >= 120:
return "REVIEW"
return "ARCHIVE"
def rank_signals(signals: Sequence[Signal]) -> list[RankedSignal]:
"""Return ranked copies without changing the caller's sequence."""
ranked: list[RankedSignal] = []
for signal in signals:
score = score_signal(signal)
ranked.append(
RankedSignal(
name=signal["name"].strip(),
score=score,
decision=classify_signal(score),
)
)
return sorted(
ranked,
key=lambda signal: (-signal["score"], signal["name"]),
)
def build_observatory_report(signals: Sequence[Signal]) -> str:
"""Return the complete ranked observatory report."""
lines = ["CLOCKWORK OBSERVATORY"]
lines.extend(
f"{position}. {signal['name']} | {signal['score']} | {signal['decision']}"
for position, signal in enumerate(rank_signals(signals), start=1)
)
return "\n".join(lines)Use this pyproject.toml:
[project]
name = "clockwork-observatory"
version = "0.1.0"
requires-python = ">=3.10"
[tool.ruff]
line-length = 88
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "RUF"]
[tool.mypy]
python_version = "3.10"
strict = true
show_error_codes = true
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]Use this local .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: ruff-check
name: Ruff lint
entry: ruff check
language: system
types: [python]
- id: ruff-format-check
name: Ruff format check
entry: ruff format --check
language: system
types: [python]
- id: mypy
name: MyPy
entry: mypy src
language: system
pass_filenames: false
- id: pytest
name: pytest
entry: pytest -q
language: system
pass_filenames: false
always_run: trueUse this .github/workflows/quality.yml:
name: Quality
on:
pull_request:
push:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install quality tools
run: python -m pip install pytest ruff mypy
- name: Lint
run: ruff check src tests
- name: Check formatting
run: ruff format --check src tests
- name: Check types
run: mypy src
- name: Run tests
run: pytest -qThe verified reference project passes Ruff 0.15.20, MyPy 2.2.0 strict mode, pytest 9.1.1 with 11 collected cases, and the local pre-commit gate.
9. Check the observatory-specific decisions
10. Decide whether the challenge is complete
Evidence rubric
| Evidence | Ready to record when |
|---|---|
| Behavior | Score, threshold, validation, ordering, mutation, empty, and exact-report checks pass unchanged |
| Readability | Extracted names expose validation, scoring, classification, ranking, and rendering responsibilities |
| Static quality | Ruff lint/format check and strict MyPy exit zero without broad suppression |
| Automation | A second all-files hook run is clean and CI repeats the check-only command contract |
| Reasoning | One debugging record and refactoring log connect each change to evidence |
| Reproducibility | The complete project works from its documented root without hidden notebook state |
Record completion only when every statement is true:
This button stores a self-reported marker only in this browser. It does not submit the project, grade it, verify identity, or issue a certificate.
Not yet recorded.
Key points
- A maintenance rescue begins with a green behavior baseline and a named structural problem.
- Extract validation, scoring, classification, ranking, and rendering one stage at a time; rerun the smallest matching evidence.
- Types and tools should make the contract more visible, not hide problems with
Any, ignores, or rewritten expected values. - A local hook shortens feedback; a check-only CI workflow tests committed state on a clean runner.
- The finished artifact includes the source, configuration, exact report, debugging record, and an explanation another maintainer can follow.