FreeCampus Python

Unit Challenge: Untangle the Clockwork Observatory

Rescue a working but difficult signal-ranking project by preserving its report while adding clear structure, strict types, local hooks, and a reproducible CI gate.
python-foundations code-quality-maintainability unit-challenge
Open in Colab
  • Level: Python Foundations · Unit 14 challenge
  • Estimated time: 60–120 minutes
  • Outcome assessed: Preserve a signal report while making its source, type contracts, tool policy, local gate, and CI workflow understandable and reproducible.
  • Evidence: Passing progressive assertions, a clean check-only gate, one reviewed refactoring log, and an explained workflow.

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:

  • clarity is 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.
NoteAim for a sequence of small rescues

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:

CLOCKWORK OBSERVATORY
1. Lyra Echo | 200 | OPEN DOME
2. Cygnus Key | 150 | REVIEW
3. Orion Bell | 150 | REVIEW
4. Moth Light | 40 | ARCHIVE

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:

clockwork-observatory/
├── pyproject.toml
├── .pre-commit-config.yaml
├── .github/
│   └── workflows/
│       └── quality.yml
├── src/
│   └── clockwork_observatory/
│       ├── __init__.py
│       └── signals.py
└── tests/
    └── test_signals.py

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, and src/tests;
  • Ruff selects E, F, I, UP, B, and RUF;
  • MyPy uses Python 3.10, strict mode, and visible error codes; and
  • pytest adds src to its import path and discovers tests.

The final check-only command sequence is:

ruff check src tests
ruff format --check src tests
mypy src
pytest -q

4. Build in small stages

Use this order to keep cause and evidence connected:

  1. Copy the starter and run the report assertions as a green baseline.
  2. Add RankedSignal; run MyPy and explain every diagnostic before repairing it.
  3. Extract score_signal; rerun only score and validation checks.
  4. Extract classify_signal; rerun threshold boundaries.
  5. Extract rank_signals; verify tie order and no input mutation.
  6. Reduce build_observatory_report to orchestration and rendering; compare the exact report.
  7. Add Ruff/MyPy/pytest configuration and finish a clean manual gate.
  8. Add local pre-commit hooks for all four commands. Use check-only formatting in the final gate.
  9. Complete quality.yml so a clean GitHub Actions runner checks out source, sets up Python 3.10, installs the tools, and runs the same commands.
  10. 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.

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]

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"
expected_report = """CLOCKWORK OBSERVATORY
1. Lyra Echo | 200 | OPEN DOME
2. Cygnus Key | 150 | REVIEW
3. Orion Bell | 150 | REVIEW
4. Moth Light | 40 | ARCHIVE"""
assert build_observatory_report(signals) == expected_report

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:

All checks passed!
3 files already formatted
Success: no issues found in 2 source files
11 passed

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.

WarningExpected values are the contract

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: true

Use 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 -q

The 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.

Next: Documentation as Part of the Product Overview

Back to top