Skip to content

grip_hook.models

grip_hook.models

Data models shared across grip.

The pydantic models double as the JSON schemas sent to LLM providers for structured output, so keep field descriptions precise: the model reads them.

Stage

Bases: StrEnum

Where in the git workflow grip is running.

Source code in src/grip_hook/models.py
class Stage(StrEnum):
    """Where in the git workflow grip is running."""

    PRE_COMMIT = "pre-commit"
    PRE_PUSH = "pre-push"
    MANUAL = "manual"

Difficulty

Bases: StrEnum

How demanding the questions and grading should be.

Source code in src/grip_hook/models.py
class Difficulty(StrEnum):
    """How demanding the questions and grading should be."""

    EASY = "easy"
    NORMAL = "normal"
    HARD = "hard"

Question

Bases: BaseModel

One quiz question, generated from the diff.

Source code in src/grip_hook/models.py
class Question(BaseModel):
    """One quiz question, generated from the diff."""

    question: str = Field(
        description=(
            "A single, specific question about the change, phrased so a developer who "
            "wrote the code could answer in one to three sentences."
        )
    )
    rubric: str = Field(
        description=(
            "The key points a fully correct answer must cover. Never shown to the "
            "developer; used only for grading."
        )
    )
    focus: str = Field(
        description=(
            "A short label for what the question tests, e.g. 'behaviour', 'edge case', "
            "'motivation', 'risk', 'testing', 'dependency'."
        )
    )

QuestionSet

Bases: BaseModel

The full set of questions for one quiz.

Source code in src/grip_hook/models.py
class QuestionSet(BaseModel):
    """The full set of questions for one quiz."""

    summary: str = Field(
        description="One or two sentences describing what the change does, for the developer."
    )
    questions: list[Question] = Field(
        description=f"Exactly {QUESTION_COUNT} questions.",
        min_length=QUESTION_COUNT,
        max_length=QUESTION_COUNT,
    )

Answer

Bases: BaseModel

The developer's answer to a question.

Source code in src/grip_hook/models.py
class Answer(BaseModel):
    """The developer's answer to a question."""

    question_index: int
    text: str

    @field_validator("text")
    @classmethod
    def _tidy(cls, text: str) -> str:
        """Line breaks kept as newlines, whatever the platform sent; outer blanks trimmed."""
        return text.replace("\r\n", "\n").replace("\r", "\n").strip()

QuestionGrade

Bases: BaseModel

The grade for a single answer.

Source code in src/grip_hook/models.py
class QuestionGrade(BaseModel):
    """The grade for a single answer."""

    question_index: int = Field(description="Zero-based index of the question being graded.")
    score: int = Field(
        ge=0,
        le=POINTS_PER_QUESTION,
        description=f"Points awarded, from 0 to {POINTS_PER_QUESTION} inclusive.",
    )
    feedback: str = Field(
        description=(
            "One or two sentences: what was right and, when points were lost, where in "
            "the code to look again and what kind of point is missing or wrong. Never "
            "state the missing rubric point itself."
        )
    )

GradeSheet

Bases: BaseModel

Grades for every answer plus an overall verdict.

Source code in src/grip_hook/models.py
class GradeSheet(BaseModel):
    """Grades for every answer plus an overall verdict."""

    grades: list[QuestionGrade] = Field(
        description=f"Exactly one grade per question, {QUESTION_COUNT} in total, in order.",
        min_length=QUESTION_COUNT,
        max_length=QUESTION_COUNT,
    )
    verdict: str = Field(
        description=(
            "One sentence for the developer summarising how well they understand this change."
        )
    )

    @property
    def total(self) -> int:
        """Sum of all per-question scores, capped at :data:`grip_hook.MAX_SCORE`."""
        return min(sum(g.score for g in self.grades), MAX_SCORE)

total property

total: int

Sum of all per-question scores, capped at :data:grip_hook.MAX_SCORE.

ModelCall

Bases: BaseModel

One request to the model: what it was for, how long it took, what it used.

Source code in src/grip_hook/models.py
class ModelCall(BaseModel):
    """One request to the model: what it was for, how long it took, what it used."""

    kind: str
    """``questions``, ``board``, ``grade`` or ``appeal``."""
    seconds: float
    """Wall time of the request, retries included."""
    input_tokens: int | None = None
    """Tokens the model read, when the provider reports it (the APIs do; most coding-agent
    CLIs do not)."""
    output_tokens: int | None = None
    """Tokens the model wrote, when the provider reports it."""

    @property
    def tokens(self) -> int | None:
        """Input plus output tokens, or ``None`` when either is unknown."""
        if self.input_tokens is None or self.output_tokens is None:
            return None
        return self.input_tokens + self.output_tokens

kind instance-attribute

kind: str

questions, board, grade or appeal.

seconds instance-attribute

seconds: float

Wall time of the request, retries included.

input_tokens class-attribute instance-attribute

input_tokens: int | None = None

Tokens the model read, when the provider reports it (the APIs do; most coding-agent CLIs do not).

output_tokens class-attribute instance-attribute

output_tokens: int | None = None

Tokens the model wrote, when the provider reports it.

tokens property

tokens: int | None

Input plus output tokens, or None when either is unknown.

Appeal

Bases: BaseModel

A developer's appeal against the grade of one question, and how it ended.

Source code in src/grip_hook/models.py
class Appeal(BaseModel):
    """A developer's appeal against the grade of one question, and how it ended."""

    question_index: int
    """Zero-based index of the appealed question."""
    justification: str
    """Why the developer says their answer was right, as they wrote it."""
    old_score: int
    """Points before the appeal."""
    new_score: int
    """Points after it: never lower than ``old_score``."""
    feedback: str
    """What the second grading said about the appeal."""
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

    @property
    def upheld(self) -> bool:
        """Whether the appeal raised the score."""
        return self.new_score > self.old_score

question_index instance-attribute

question_index: int

Zero-based index of the appealed question.

justification instance-attribute

justification: str

Why the developer says their answer was right, as they wrote it.

old_score instance-attribute

old_score: int

Points before the appeal.

new_score instance-attribute

new_score: int

Points after it: never lower than old_score.

feedback instance-attribute

feedback: str

What the second grading said about the appeal.

upheld property

upheld: bool

Whether the appeal raised the score.

Report

Bases: BaseModel

Everything about one quiz run, persisted as JSON for later inspection.

Source code in src/grip_hook/models.py
class Report(BaseModel):
    """Everything about one quiz run, persisted as JSON for later inspection."""

    version: int = 1
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    stage: Stage
    provider: str
    model: str
    diff_digest: str
    passing_score: int
    score: int
    passed: bool
    summary: str
    questions: list[Question]
    answers: list[Answer]
    grades: list[QuestionGrade]
    verdict: str
    calls: list[ModelCall] = Field(default_factory=list)
    """The model requests behind this report, oldest first. Questions or a board reused
    from the cache made none. Empty in reports from before grip measured them."""

    appeals: list[Appeal] = Field(default_factory=list)
    """``grip appeal`` re-grades, oldest first; at most one per question."""

    @property
    def max_score(self) -> int:
        """The maximum reachable score."""
        return MAX_SCORE

    @property
    def seconds(self) -> float | None:
        """Time spent waiting for the model, or ``None`` when nothing was measured."""
        return sum(c.seconds for c in self.calls) if self.calls else None

    @property
    def tokens(self) -> int | None:
        """Tokens used by every request, or ``None`` when one of them did not say."""
        counts = [c.tokens for c in self.calls]
        if not counts or any(n is None for n in counts):
            return None
        return sum(n for n in counts if n is not None)

calls class-attribute instance-attribute

calls: list[ModelCall] = Field(default_factory=list)

The model requests behind this report, oldest first. Questions or a board reused from the cache made none. Empty in reports from before grip measured them.

appeals class-attribute instance-attribute

appeals: list[Appeal] = Field(default_factory=list)

grip appeal re-grades, oldest first; at most one per question.

max_score property

max_score: int

The maximum reachable score.

seconds property

seconds: float | None

Time spent waiting for the model, or None when nothing was measured.

tokens property

tokens: int | None

Tokens used by every request, or None when one of them did not say.