Skip to content

grip_hook.quiz

grip_hook.quiz

The quiz itself: generate questions, ask them, grade, decide.

QuizOutcome dataclass

Result of one quiz run.

Source code in src/grip_hook/quiz.py
@dataclass(frozen=True, slots=True)
class QuizOutcome:
    """Result of one quiz run."""

    report: Report

    @property
    def passed(self) -> bool:
        """Whether the developer reached the passing score."""
        return self.report.passed

passed property

passed: bool

Whether the developer reached the passing score.

truncation_note

truncation_note(diff: Diff, max_bytes: int) -> str | None

What part of a truncated diff the model sees, or None when it sees all of it.

Source code in src/grip_hook/quiz.py
def truncation_note(diff: Diff, max_bytes: int) -> str | None:
    """What part of a truncated diff the model sees, or ``None`` when it sees all of it."""
    if not diff.truncated:
        return None
    if diff.size > max_bytes:
        return (
            f"grip saw the first {_size(max_bytes)} of a {_size(diff.size)} change "
            "(max_diff_bytes)."
        )
    return f"grip saw only the first {_size(max_bytes)} of this change (max_diff_bytes)."

render_intro

render_intro(term: Terminal, diff: Diff, cfg: Config, stage: Stage) -> None

Show what is about to be quizzed, and how much of it the model sees.

Source code in src/grip_hook/quiz.py
def render_intro(term: Terminal, diff: Diff, cfg: Config, stage: Stage) -> None:
    """Show what is about to be quizzed, and how much of it the model sees."""
    files = "\n".join(escape(f) for f in diff.files[:12])
    if len(diff.files) > 12:
        files += f"\n… and {len(diff.files) - 12} more"
    note = truncation_note(diff, cfg.max_diff_bytes)
    body = Text.from_markup(
        f"[bold]{QUESTION_COUNT} questions[/bold] about your "
        f"[bold]{escape(diff.description)}[/bold] ({stage.value}). "
        f"Pass mark: [bold]{cfg.passing_score}/{MAX_SCORE}[/bold]."
        + (f"\n[yellow]{escape(note)}[/yellow]" if note else "")
        + f"\n\n{files}"
    )
    term.console.print()  # pre-commit prints its own status on the current line
    term.console.print(Panel(body, title="grip", subtitle="keep a grip on your code", expand=False))

read_answer

read_answer(term: Terminal, prompt: str) -> str

One answer: the line typed, or every line of a paste, line breaks kept.

A paste arrives faster than anyone types, so the lines already waiting after the first belong to the same answer. An empty line ends it unless more of the paste is waiting right behind it: then it separates two paragraphs. So a paste that ends with an empty line ends the answer there, and a paste that stops mid-line (its last line waiting for Enter) runs on to the next empty line.

Source code in src/grip_hook/quiz.py
def read_answer(term: Terminal, prompt: str) -> str:
    """One answer: the line typed, or every line of a paste, line breaks kept.

    A paste arrives faster than anyone types, so the lines already waiting after the first
    belong to the same answer. An empty line ends it unless more of the paste is waiting
    right behind it: then it separates two paragraphs. So a paste that ends with an empty
    line ends the answer there, and a paste that stops mid-line (its last line waiting for
    Enter) runs on to the next empty line.
    """
    lines = [term.read_line(prompt)]
    if not term.pending():
        return lines[0]
    try:
        while True:
            line = term.read_line()
            if not line.strip() and not term.pending():
                break
            lines.append(line)
    except EOFError:
        pass
    return "\n".join(lines).strip()

ask_questions

ask_questions(term: Terminal, question_set: QuestionSet) -> list[Answer]

Present each question and collect one line of answer for it.

Source code in src/grip_hook/quiz.py
def ask_questions(term: Terminal, question_set: QuestionSet) -> list[Answer]:
    """Present each question and collect one line of answer for it."""
    console = term.console
    console.print(f"\n[dim]{escape(question_set.summary)}[/dim]\n")
    console.print(
        "[dim]Answer in a sentence or two. Press Enter on an empty line to skip. "
        "A pasted answer can span lines; an empty line ends it.[/dim]\n"
    )
    answers: list[Answer] = []
    for i, q in enumerate(question_set.questions):
        console.print(
            f"[bold cyan]Q{i + 1}/{QUESTION_COUNT}[/bold cyan] [dim]({escape(q.focus)})[/dim] "
            f"{escape(q.question)}"
        )
        try:
            text = read_answer(term, "[bold]> [/bold]")
        except EOFError:
            text = ""
        answers.append(Answer(question_index=i, text=text))
        console.print()
    return answers

render_result

render_result(term: Terminal, question_set: QuestionSet, sheet: GradeSheet, cfg: Config) -> None

Print the score table and verdict.

Source code in src/grip_hook/quiz.py
def render_result(
    term: Terminal, question_set: QuestionSet, sheet: GradeSheet, cfg: Config
) -> None:
    """Print the score table and verdict."""
    console = term.console
    table = Table(
        show_header=True, header_style="bold", expand=False, pad_edge=False, box=box.ROUNDED
    )
    table.add_column("#", justify="right")
    table.add_column("Focus")
    table.add_column("Score", justify="right")
    table.add_column("Feedback")
    for grade in sheet.grades:
        q = question_set.questions[grade.question_index]
        style = _score_style(grade.score * (MAX_SCORE // POINTS_PER_QUESTION), cfg.passing_score)
        table.add_row(
            str(grade.question_index + 1),
            escape(q.focus),
            f"[{style}]{grade.score}/{POINTS_PER_QUESTION}[/{style}]",
            escape(grade.feedback),
        )
    console.print(table)

    total = sheet.total
    passed = total >= cfg.passing_score
    style = _score_style(total, cfg.passing_score)
    headline = (
        f"[{style}]Grip Score: {total}/{MAX_SCORE}[/{style}]  "
        + ("[bold green]PASS[/bold green]" if passed else "[bold red]FAIL[/bold red]")
        + f" [dim](pass mark {cfg.passing_score})[/dim]"
        + ("" if passed else "  [dim]re-read: grip last[/dim]")
    )
    console.print(Panel(Text.from_markup(f"{headline}\n\n{escape(sheet.verdict)}"), expand=False))

usage_line

usage_line(report: Report) -> str | None

took 9.8 s · 3,140 tokens for report, or None when nothing was measured.

Source code in src/grip_hook/quiz.py
def usage_line(report: Report) -> str | None:
    """``took 9.8 s · 3,140 tokens`` for ``report``, or ``None`` when nothing was measured."""
    seconds = report.seconds
    if seconds is None:
        return None
    took = f"took {seconds:.1f} s"
    tokens = report.tokens
    if tokens is None:
        return f"{took} · tokens not reported by {report.provider}"
    return f"{took} · {tokens:,} tokens"

run_quiz

run_quiz(*, diff: Diff, cfg: Config, provider: Provider, term: Terminal, stage: Stage) -> QuizOutcome

Run the full quiz and return the outcome. Does not exit or raise on failure.

Source code in src/grip_hook/quiz.py
def run_quiz(
    *,
    diff: Diff,
    cfg: Config,
    provider: Provider,
    term: Terminal,
    stage: Stage,
) -> QuizOutcome:
    """Run the full quiz and return the outcome. Does not exit or raise on failure."""
    render_intro(term, diff, cfg, stage)

    # Questions reused from the cache come back at once: no spinner claims a read.
    question_set = None
    if isinstance(provider, CachedProvider):
        question_set = provider.cached_questions(diff, cfg.difficulty)
    if question_set is None:
        with term.console.status("[dim]grip is reading your diff…[/dim]"):
            question_set = provider.generate_questions(diff, cfg.difficulty)
    if len(question_set.questions) != QUESTION_COUNT:
        raise ProviderError(
            f"provider returned {len(question_set.questions)} questions, expected {QUESTION_COUNT}"
        )

    answers = ask_questions(term, question_set)

    with term.console.status("[dim]grip is grading…[/dim]"):
        sheet = provider.grade(diff, question_set.questions, answers, cfg.difficulty)
    if len(sheet.grades) != QUESTION_COUNT:
        raise ProviderError(
            f"provider returned {len(sheet.grades)} grades, expected {QUESTION_COUNT}"
        )
    sheet.grades.sort(key=lambda g: g.question_index)

    render_result(term, question_set, sheet, cfg)

    total = sheet.total
    report = Report(
        stage=stage,
        provider=provider.name,
        model=provider.model,
        diff_digest=diff.digest,
        passing_score=cfg.passing_score,
        score=total,
        passed=total >= cfg.passing_score,
        summary=question_set.summary,
        questions=question_set.questions,
        answers=answers,
        grades=sheet.grades,
        verdict=sheet.verdict,
        calls=calls_of(provider),
    )
    if footer := usage_line(report):
        term.console.print(f"[dim]{escape(footer)}[/dim]")
    return QuizOutcome(report=report)