Story S1-04 — Rubric Protocol¶
Step: Step 1 — Establish contracts: package scaffold, wire models, registry, Protocol
Status: HARDENED
Effort: S
Depends on: S1-02 (wire models — BenchCase, BenchScore)
ADRs honored: ADR-0001 (subprocess invocation is the runner's call site; in-process is the bench-author-test-only call site), Phase 5 ADR-0006 (Protocol vs ABC convention — structural Protocol, no shared behavior)
Validation notes¶
Validated 2026-05-26 by phase-story-validator — verdict: HARDENED. Full report at _validation/S1-04-rubric-protocol.md. Key changes:
- AC-6 was factually wrong and would have failed the green step. The original AC asserted
Rubric.__abstractmethods__ == frozenset({"score"})"only by virtue of Protocol semantics" — but a vanilla@runtime_checkable Protocolbody does NOT populate__abstractmethods__in Python 3.11+; that frozenset isfrozenset(). The only way to populate it is to add@abstractmethod— which the same AC forbids. Replaced with AC-6a/b/c (typing._get_protocol_attrscanonical; AST no-@abstractmethod;dir()-filter belt). Empirically verified against Python 3.13. - Signature shape is now introspected, not just annotated (AC-3a + AC-3b). A regression renaming
case→corharness_output→outputwould silently break the subprocess JSON-to-kwargs unpacking that ADR-0001 depends on; mypy alone doesn't catch parameter-name drift across modules that share the Protocol. - Protocol's runtime-isinstance limitation is pinned as specification-by-example (AC-10 + AC-10a).
isinstance(obj, Rubric)checks attribute names only — a class withdef score(self): pass(wrong signature) orscore = 42(non-callable) passes the gate. Tests document the limitation as deliberate-per-ADR-0001, not a bug to "fix" with a runtime signature check. - Module-shape conventions enforced as observable contracts (AC-12/13/14):
__all__ = ["Rubric"],from __future__ import annotations, module docstring cites both ADRs. Mirrors sibling Protocol-port files (vuln_index/protocol.py,fallback/leaf/port.py). Depends on:corrected from—toS1-02 (wire models)— the story's_ok_score()helper constructs aBenchScore, so S1-02 must be GREEN before S1-04's red marker can be reached.- Notes for implementer expanded with: ADR-0001 footgun discipline (don't expect runtime isinstance to catch signature mismatches); sibling Protocol-port file:line references; explicit push-back on adding a runtime
isinstance(rubric, Rubric)guard at S1-03's registration site (ADR-0001 binding — mypy is the structural enforcer); deferred extract trigger for aport_base.pykernel; AST-introspection as a structural-defense pattern.
Context¶
The Rubric Protocol is the per-task-class scoring contract: one method, score(case, harness_output) -> BenchScore. The runner never imports a rubric module — ADR-0001 mandates subprocess invocation across a process boundary. The Protocol exists primarily so bench-author unit tests (bench/<tc>/tests/test_rubric_unit.py) can type-check the in-process call, and so the registry's TaskClass.rubric_class: type[Rubric] field carries a non-vacuous static-type relationship for mypy --strict. Phase 5 ADR-0006 chose Protocol over ABC for cases where there is no shared default behavior across implementations; rubrics are the textbook fit (every rubric is task-class-specific; nothing is shared).
This story is tiny on the surface (one file, ~20 LOC) but load-bearing: it is what makes the @register_task_class decorator's type[Rubric] annotation meaningful, and what S1-03's tests use to declare their stub rubric classes. The empirically-correct Python 3.11+ Protocol semantics (verified during validation) drive the test discipline — see _validation/S1-04-rubric-protocol.md for the verification transcript.
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design → src/codegenie/eval/rubric.py— full module contract;@runtime_checkableProtocol, singlescoremethod, two call sites (in-process for bench-author tests; subprocess for runner).../phase-arch-design.md §Agentic best practices — Tool-use safety— the Protocol exists because the runner does not type-check across the subprocess boundary; bench-author unit tests are the trusted typed surface.- Phase ADRs:
../ADRs/0001-rubric-execution-isolation-via-subprocess.md— "TheRubricProtocol exists primarily so bench-author unit tests can type-check (the runner does not type-check the subprocess — there is no static type relationship across the process boundary)."- Production / cross-phase precedent:
../../05-sandbox-trust-gates/ADRs/0006-protocol-vs-abc-convention.md— Phase 5 chose Protocol where there is no shared default behavior across implementations. Rubrics meet that criterion (every task class has its own).- Sibling Protocol-port files in this repo (the codebase convention to mirror):
src/codegenie/vuln_index/protocol.py—FeedProtocol;@runtime_checkable, one logical port,__all__, module docstring citing the Phase-3 ADR that justifies the seam,from __future__ import annotations.src/codegenie/fallback/leaf/port.py—LeafLlmPort; cross-process-boundary precedent (Protocol surface vs subprocess wire — same asymmetry as Rubric).- This phase, parallel stories:
- S1-02 —
BenchScore,BenchCasetypes this Protocol references (dependency: S1-02 must be GREEN first). - S1-03 —
TaskClass.rubric_class: type[Rubric]annotation depends on this story landing.
Goal¶
Land src/codegenie/eval/rubric.py exposing a @runtime_checkable Rubric(Protocol) with one method, score(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScore, plus the module-shape conventions (from __future__ import annotations, __all__ = ["Rubric"], module docstring citing ADR-0001 and Phase 5 ADR-0006); plus a unit test suite asserting Protocol semantics (canonical declared-attrs, signature introspection, structural conformance, runtime-isinstance specification-by-example, module-shape conventions, direct-instantiation defense).
Acceptance criteria¶
- [ ] AC-1:
src/codegenie/eval/rubric.pyexists;from codegenie.eval.rubric import Rubricsucceeds. - [ ] AC-2:
Rubricis decorated@runtime_checkableand inherits fromtyping.Protocol(Python 3.11+). Verified byRubric._is_runtime_protocol is TrueandRubric._is_protocol is True. - [ ] AC-3:
Rubricdeclares exactly one method:def score(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScore: ...— body is...(no implementation; Protocol semantics). - [ ] AC-3a:
inspect.signature(Rubric.score).parameterskeys equal("self", "case", "harness_output")exactly (introspected, not annotated); arity (excludingself) is 2. - [ ] AC-3b:
typing.get_type_hints(Rubric.score, globalns=vars(codegenie.eval.rubric))resolvescase → BenchCase,harness_output → collections.abc.Mapping[str, typing.Any], return →BenchScoreexactly (notdict[str, Any], not any structural-supertype). - [ ] AC-4: A duck-typed class with a
score(self, case, harness_output) -> BenchScoremethod passesisinstance(instance, Rubric)at runtime. - [ ] AC-5: A class missing
score(a typo'devaluatefor example) failsisinstance(instance, Rubric)at runtime — the Protocol catches the missing-attribute case. - [ ] AC-6a:
typing._get_protocol_attrs(Rubric) == frozenset({"score"})— the canonical Protocol-internals introspection pins exactly one declared attribute. - [ ] AC-6b: AST inspection of
src/codegenie/eval/rubric.pyproves (i) nofrom abc import abstractmethod(or equivalent) import, (ii) no@abstractmethoddecorator on thescoremethod. The Protocol does not mix@abstractmethodper Phase 5 ADR-0006. - [ ] AC-6c: Belt-and-suspenders structural smoke:
{name for name in dir(Rubric) if not name.startswith("_") and callable(getattr(Rubric, name, None))} == {"score"}. This test passes today and would catch a future Protocol-internals leak (e.g., a public name added totyping.Protocolin a Python upgrade). - [ ] AC-7: mypy
--strictis clean: a stub class that implementsscore(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScoretype-checks asRubricwithout explicit inheritance (structural subtyping). - [ ] AC-8: The red tests from §TDD plan exist, were committed at the red marker, and are now green.
- [ ] AC-9:
ruff check,ruff format --check,mypy --strict,pytest tests/unit/test_rubric_protocol.pyall pass. - [ ] AC-10: A class with a wrong-signature
score(e.g.,def score(self): pass— zero positional params beyond self) passesisinstance(instance, Rubric). This is deliberate per ADR-0001 — runtime Protocol isinstance checks attribute names, not signatures; mypy--strictis the structural enforcer. Test docstring names the rationale. - [ ] AC-10a: A class with a non-callable
scoreattribute (e.g.,score = 42) passesisinstance(instance, Rubric). Same rationale as AC-10 — deliberate, not a bug to "fix" with a runtime callable-check. - [ ] AC-11: Direct instantiation
Rubric()raisesTypeError; the exception message contains the substring"Protocol"(not"abstract"). A regression refactoringRubricfromProtocoltoABCwould raiseTypeError("Can't instantiate abstract class Rubric...")instead — this AC catches that mutation. - [ ] AC-12: Module exports exactly
__all__ = ["Rubric"]— pinned by attribute introspection on the imported module (from codegenie.eval import rubric; assert tuple(rubric.__all__) == ("Rubric",)). - [ ] AC-13: AST inspection finds
from __future__ import annotationsinrubric.py's import block — codebase convention for Protocol-port files (cf.vuln_index/protocol.py:17,fallback/leaf/port.py). - [ ] AC-14: Module docstring (parsed via
ast.get_docstring) cites bothADR-0001andADR-0006literally (substring presence is sufficient — the goal is rationale traceability, not prose form).
Implementation outline¶
- Write
tests/unit/test_rubric_protocol.pyfirst (red — 12 tests, see §TDD plan); confirmModuleNotFoundErrorforcodegenie.eval.rubric. - Create
src/codegenie/eval/rubric.py: - First line:
"""<module docstring>"""— naming../phase-arch-design.md §Component design → rubric.py,ADR-0001(subprocess isolation rationale), andPhase 5 ADR-0006(Protocol vs ABC convention). Cite the two sibling Protocol-port files (vuln_index/protocol.py,fallback/leaf/port.py) as the codebase convention being mirrored. - Second:
from __future__ import annotations(codebase convention; supports forward references and PEP 604 syntax). - Imports:
from collections.abc import Mapping,from typing import Any, Protocol, runtime_checkable,from codegenie.eval.models import BenchCase, BenchScore. __all__ = ["Rubric"].@runtime_checkable class Rubric(Protocol):with onedef score(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScore: ....- One-line class docstring naming the two call sites (bench-author tests in-process; runner via subprocess per ADR-0001).
- Run
ruff format,ruff check,mypy --strict,pytest tests/unit/test_rubric_protocol.py. - Verify AC-14 by reading the docstring's literal text contains both
"ADR-0001"and"ADR-0006".
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/test_rubric_protocol.py
# tests/unit/test_rubric_protocol.py
"""Protocol-shape contracts for codegenie.eval.rubric.Rubric.
Empirically grounded against Python 3.11+ typing semantics. Tests pin
both the structural conformance (happy path + obvious-typo negative)
AND the deliberate language limitations (runtime isinstance is name-only,
not signature-checking — per ADR-0001's two-call-site asymmetry).
"""
from __future__ import annotations
import ast
import importlib
import inspect
import typing
from collections.abc import Mapping
from typing import Any, get_type_hints
import pytest
from pydantic import ValidationError
from codegenie.eval.models import BenchCase, BenchScore
from codegenie.eval import rubric as rubric_mod
from codegenie.eval.rubric import Rubric
def _ok_score() -> BenchScore:
"""Construct a minimal valid BenchScore. Routes S1-02-drift failures to S1-02."""
try:
return BenchScore(
passed=True, score=0.5, breakdown={},
failure_modes=(), cost_usd=0.0, wall_clock_ms=0,
)
except ValidationError as exc: # pragma: no cover
pytest.skip(f"S1-02 BenchScore field-set drift; resolve in S1-02: {exc}")
# ---------------------------------------------------------------------------
# Structural conformers used by isinstance tests
# ---------------------------------------------------------------------------
class _DuckTypedRubric:
"""No inheritance from Rubric — structural conformance only."""
def score(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScore:
return _ok_score()
class _MissingScore:
"""Lacks .score — should fail isinstance(..., Rubric)."""
def evaluate(self, case, harness_output): # type: ignore[no-untyped-def]
return _ok_score()
class _WrongSignatureScore:
"""Has `score` but wrong arity. AC-10: isinstance() passes deliberately."""
def score(self): # type: ignore[no-untyped-def]
return None
class _NonCallableScore:
"""Has `score` but it's a non-callable int. AC-10a: isinstance() passes deliberately."""
score = 42
# ---------------------------------------------------------------------------
# AC-4 / AC-5 — original obvious-conformer + obvious-typo coverage
# ---------------------------------------------------------------------------
def test_rubric_is_a_runtime_checkable_protocol() -> None:
"""AC-4: duck-typed conformer satisfies isinstance."""
assert isinstance(_DuckTypedRubric(), Rubric)
def test_class_missing_score_fails_isinstance() -> None:
"""AC-5: a class with a typo'd method name does not silently satisfy the contract."""
assert not isinstance(_MissingScore(), Rubric)
# ---------------------------------------------------------------------------
# AC-2 — runtime_checkable + Protocol markers (positive introspection,
# no try/except; mutation-resistant)
# ---------------------------------------------------------------------------
def test_runtime_checkable_marker_is_set() -> None:
"""AC-2: @runtime_checkable sets the canonical typing-internal flag."""
assert getattr(Rubric, "_is_runtime_protocol", False) is True
assert getattr(Rubric, "_is_protocol", False) is True
def test_runtime_isinstance_returns_false_for_unconformant_object() -> None:
"""AC-2: complement — a bare object() doesn't have score, must return False
(not raise — that'd indicate @runtime_checkable was dropped)."""
assert isinstance(object(), Rubric) is False
# ---------------------------------------------------------------------------
# AC-3a / AC-3b — signature introspection (the wire contract per ADR-0001)
# ---------------------------------------------------------------------------
def test_score_signature_parameter_names_and_arity() -> None:
"""AC-3a: parameter names AND arity are the wire contract — subprocess
JSON-to-kwargs unpacking depends on these names (ADR-0001). A rename
`case`→`c` is mypy-clean for any new call site but silently breaks
every existing keyword call."""
sig = inspect.signature(Rubric.score)
assert tuple(sig.parameters.keys()) == ("self", "case", "harness_output")
# Arity excluding self is 2.
assert len(sig.parameters) - 1 == 2
def test_score_annotation_types() -> None:
"""AC-3b: return annotation is BenchScore exactly (not dict[str, Any], not
any structural-supertype). Forward refs resolved via the module's globalns."""
hints = get_type_hints(Rubric.score, globalns=vars(rubric_mod))
assert hints["case"] is BenchCase
# Mapping[str, Any] — origin Mapping, args (str, Any).
assert typing.get_origin(hints["harness_output"]) is Mapping
assert typing.get_args(hints["harness_output"]) == (str, Any)
assert hints["return"] is BenchScore
# ---------------------------------------------------------------------------
# AC-6a / AC-6b / AC-6c — Protocol declared-attrs triad
# ---------------------------------------------------------------------------
def test_protocol_attrs_canonical() -> None:
"""AC-6a: typing._get_protocol_attrs is the canonical declared-attrs API.
Returns exactly {'score'} for a Protocol with one method body."""
# _get_protocol_attrs is a private but stable typing helper since 3.8.
get_attrs = typing._get_protocol_attrs # type: ignore[attr-defined]
assert get_attrs(Rubric) == frozenset({"score"})
def test_ast_proves_no_abstractmethod_decorator_or_import() -> None:
"""AC-6b: Protocol vs ABC convention (Phase 5 ADR-0006). The Protocol
body must use bare `...`; no `@abstractmethod`, no `from abc import
abstractmethod`. AST inspection is the structural-defense pattern
(mirrors tests/fence/ discipline)."""
src = (importlib.resources.files("codegenie.eval") / "rubric.py").read_text()
tree = ast.parse(src)
# No `from abc import ... abstractmethod ...`
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "abc":
names = {alias.name for alias in node.names}
assert "abstractmethod" not in names, "ADR-0006: Protocol must not mix @abstractmethod"
# The Rubric class's `score` method has no @abstractmethod decorator.
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == "Rubric":
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == "score":
for dec in item.decorator_list:
# Either Name("abstractmethod") or Attribute(attr="abstractmethod")
attr = getattr(dec, "attr", getattr(dec, "id", ""))
assert attr != "abstractmethod", (
"ADR-0006: Protocol must not mix @abstractmethod"
)
def test_dir_filter_belt_and_suspenders() -> None:
"""AC-6c: structural smoke — exactly one public callable named 'score'.
Catches a future Protocol-internals leak (e.g., a new public name
surfaced on typing.Protocol in a Python upgrade)."""
members = {
name for name in dir(Rubric)
if not name.startswith("_") and callable(getattr(Rubric, name, None))
}
assert members == {"score"}
# ---------------------------------------------------------------------------
# AC-10 / AC-10a — Protocol-isinstance footgun, specification-by-example
# ---------------------------------------------------------------------------
def test_isinstance_passes_for_wrong_signature_score_method() -> None:
"""AC-10: DELIBERATE per ADR-0001. Runtime Protocol isinstance checks
attribute NAMES, not signatures. mypy --strict is the structural
enforcer; the runtime check is name-presence only. A future contributor
must NOT add a runtime signature guard expecting tighter semantics —
that would be a category error per the two-call-site asymmetry."""
assert isinstance(_WrongSignatureScore(), Rubric) is True
def test_isinstance_passes_for_non_callable_score_attribute() -> None:
"""AC-10a: DELIBERATE per ADR-0001. Runtime Protocol isinstance does
not even require the attribute to be callable — name presence is the
only check. mypy is the structural enforcer at type-check time."""
assert isinstance(_NonCallableScore(), Rubric) is True
# ---------------------------------------------------------------------------
# AC-11 — direct instantiation defense
# ---------------------------------------------------------------------------
def test_rubric_cannot_be_instantiated() -> None:
"""AC-11: Rubric() raises TypeError with 'Protocol' in the message.
A regression refactoring Protocol→ABC would still raise TypeError but
with 'abstract' in the message — this assertion catches that mutation."""
with pytest.raises(TypeError) as excinfo:
Rubric() # type: ignore[abstract]
assert "Protocol" in str(excinfo.value), (
f"Expected 'Protocol' in error; got: {excinfo.value!r}. "
f"Did Rubric get refactored from Protocol to ABC?"
)
# ---------------------------------------------------------------------------
# AC-12 / AC-13 / AC-14 — module-shape conventions
# ---------------------------------------------------------------------------
def test_module_exports_only_rubric() -> None:
"""AC-12: __all__ is the public-surface contract. Adding a 2nd export
requires an ADR amendment, not a silent edit."""
assert tuple(rubric_mod.__all__) == ("Rubric",)
def test_future_annotations_imported() -> None:
"""AC-13: codebase convention for Protocol-port files (cf.
vuln_index/protocol.py, fallback/leaf/port.py)."""
src = (importlib.resources.files("codegenie.eval") / "rubric.py").read_text()
tree = ast.parse(src)
has_future = any(
isinstance(node, ast.ImportFrom)
and node.module == "__future__"
and any(alias.name == "annotations" for alias in node.names)
for node in ast.walk(tree)
)
assert has_future, "rubric.py must `from __future__ import annotations`"
def test_module_docstring_cites_both_adrs() -> None:
"""AC-14: rationale traceability — the module docstring must name
ADR-0001 and ADR-0006 so a future reader sees the why."""
src = (importlib.resources.files("codegenie.eval") / "rubric.py").read_text()
docstring = ast.get_docstring(ast.parse(src)) or ""
assert "ADR-0001" in docstring, "Module docstring must cite ADR-0001"
assert "ADR-0006" in docstring, "Module docstring must cite Phase 5 ADR-0006"
Run; confirm ModuleNotFoundError. Commit the red marker.
Green — make it pass¶
# src/codegenie/eval/rubric.py
"""Rubric Protocol — the per-task-class scoring contract (one method, two call sites).
The Protocol exists primarily so bench-author unit tests
(`bench/{task-class}/tests/test_rubric_unit.py`) can type-check the in-process
call to `score()`. The eval runner NEVER imports a rubric module — per
ADR-0001 (rubric-execution-isolation-via-subprocess), invocation is across a
process boundary via `python bench/{task-class}/rubric.py` with scrubbed env.
There is no static type relationship across the subprocess; the Protocol is
a typing aid for the trusted in-process surface only.
Phase 5 ADR-0006 (Protocol vs ABC convention) chose Protocol over ABC where
no shared default behavior exists across implementations — every rubric is
task-class-specific; nothing is shared. The pattern mirrors sibling
Protocol-port files: `codegenie.vuln_index.protocol.Feed`,
`codegenie.fallback.leaf.port.LeafLlmPort`.
See `docs/phases/06.5-per-task-class-eval-harness/phase-arch-design.md`
§Component design → rubric.py for the full contract.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Protocol, runtime_checkable
from codegenie.eval.models import BenchCase, BenchScore
__all__ = ["Rubric"]
@runtime_checkable
class Rubric(Protocol):
"""Score one bench case's harness output. In-process (bench-author tests)
or subprocess (runner) per ADR-0001's two-call-site asymmetry."""
def score(self, case: BenchCase, harness_output: Mapping[str, Any]) -> BenchScore: ...
Nothing else.
Refactor — clean up¶
- Module docstring cites the two ADRs (
ADR-0001for "why subprocess, not in-process — the Protocol is a typing aid, not a runtime contract for the runner") andPhase 5 ADR-0006(Protocol vs ABC). Already required by AC-14. - One-line class docstring on
Rubricnaming the two call sites (bench-author tests in-process; runner via subprocess). - Confirm mypy
--strictresolvesBenchCaseandBenchScorewithout forward references;from __future__ import annotationsis already present per AC-13. - No
scorebody even aspass— Protocol convention is...literal as the method body, which signals "this is an abstract method-spec, not a default implementation." (AC-6b's AST check forbids@abstractmethod; the test does not constrain...vspass, but the convention is documented here and in the sibling Protocol-port files.) - The AST-introspectable shape (
__all__,from __future__ import annotations, module docstring citing ADRs) is enforced by tests in this story, not by review-only convention. Drift would surface as a CI failure.
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/eval/rubric.py |
New file — @runtime_checkable class Rubric(Protocol) with one score method + module-shape conventions |
tests/unit/test_rubric_protocol.py |
New file — 12 tests covering AC-1..AC-14 (structural conformance, signature introspection, runtime-Protocol-limitation specification, module-shape conventions, direct-instantiation defense) |
Out of scope¶
type[Rubric]annotation onTaskClass.rubric_class— handled by S1-03 (this story provides the type; the registry uses it).isinstance(rubric, Rubric)runtime check at registration time — explicitly forbidden per ADR-0001. mypy--strictis the structural enforcer at type-check time; adding a runtime guard at the S1-03 registry would be a category error (the registration site is already-compiled code; the rubric class is the source the registration site names; mypy has already validated). If a reviewer or executor proposes such a guard during S1-03, push back with this paragraph. The onlyisinstance(..., Rubric)calls in production code paths live in this story's tests, nowhere else.- Subprocess invocation of
python rubric.py— handled by S3-03 (the runner spawns the subprocess; the Protocol does not). - Bench-author unit-test scaffolding (
bench/<tc>/tests/test_rubric_unit.py) — handled by S5-02 (vuln-remediation) and S6-01 (distroless); this story only provides the type bench-author tests import. - Adding
@abstractmethodtoscore— explicitly out of scope. Protocols use...body; mixing@abstractmethodis a category error per Phase 5 ADR-0006 and would invalidate the structural-typing intent. AC-6b enforces this via AST. - Extracting a shared
port_base.pykernel for the three Protocol-port files (Feed,LeafLlmPort,Rubric) — YAGNI. Each port is task-domain-specific; the shared invariants (@runtime_checkable,__all__, docstring ADRs) are Python-language conventions, not domain-relevant ones. Defer; revisit only if a 5th Protocol-port lands and the discipline drifts. - A
TaskClassName/RubricNamenewtype — not applicable here (this Protocol exposes no identifier surface). The deferral is tracked in_validation/S1-03-taskclass-dataclass-and-registry.mdfor theTaskClass.nameconsolidation surface.
Notes for the implementer¶
-
Resist the urge to add methods. The Protocol has one method. Phase 7 will be tempted to add a
prepare(case)hook or acleanup()hook; both belong in the subprocessif __name__ == "__main__":entrypoint, not in the Protocol surface. Widening the Protocol forces every existing bench-author rubric (vuln-remediation, distroless) to update — exactly the anti-pattern the open-registry design avoids. -
The Protocol body must be
..., notpass, notraise NotImplementedError. The first two are equivalent at runtime;...is the convention that signals "this is a method specification" to readers (and to mypy's structural-subtyping engine). -
@runtime_checkableis load-bearing for theisinstance(..., Rubric)calls in the tests (and only there). Without it,isinstanceraisesTypeError. The testtest_runtime_checkable_marker_is_setis the structural marker — if a future refactor drops the decorator, the test catches it. -
Empirically-grounded Protocol semantics (verified during validation 2026-05-26 on Python 3.13):
Rubric.__abstractmethods__isfrozenset()for a vanilla@runtime_checkable Protocol; it is not automatically populated with method names. Earlier drafts of this story claimed otherwise and would have had a failing test. Usetyping._get_protocol_attrs(Rubric)for the canonical declared-attrs introspection (returnsfrozenset({"score"})).- Runtime
isinstance(obj, Rubric)checks attribute names only — not signatures, not callability. A class withdef score(self): ...(wrong arity) orscore = 42(non-callable) passes the gate. AC-10/AC-10a pin this as deliberate per ADR-0001, not a bug. mypy--strictis the structural enforcer; runtime Protocol is the name-presence gate. -
Rubric()raisesTypeError("Protocols cannot be instantiated"). A regression to ABC would raiseTypeError("Can't instantiate abstract class ..."). AC-11 distinguishes the two via the"Protocol"substring. -
The Protocol's method signature must match the subprocess JSON contract. The runner spawns
python rubric.pyand passes JSON-serializedcase+harness_outputon stdin; the subprocess deserializes, callsscore(case, harness_output)internally on its own rubric instance, and writes theBenchScoreJSON to stdout. The Protocol describes the in-process surface; the wire contract (S5-02 and S6-01 will implement the rubric subprocess entrypoint) matches it by construction. The parameter namescaseandharness_outputare part of the wire contract — bench-author tests use them as kwargs; runner-side JSON unpacking uses them as kwargs. A rename is not a refactor; it is a breaking change. AC-3a is the introspection guard. -
The two call sites — bench-author tests (in-process, typed,
isinstance-checked) vs runner (subprocess, untyped across the process boundary) — is the asymmetry ADR-0001 calls out as deliberate. Do not try to "harmonize" them with a wrapper class; the asymmetry is the security posture. -
tests/unit/test_rubric_protocol.pyis the only placeisinstance(..., Rubric)is called in production code paths. The registry (S1-03) does not call it. If a reviewer asks "why doesn't the registry verify the decorated class is aRubric?" — the answer is: mypy--strictalready verifies it at type-check time; runtimeisinstanceadds nothing because the registration site (@register_task_class) takes a class and stores it; the only consumer is the bench-author test, which callsisinstanceitself. If S1-03's executor proposes adding such a guard, push back — ADR-0001 binding. -
Sibling Protocol-port lineage to mirror (Rule 11 — match codebase conventions):
src/codegenie/vuln_index/protocol.py:1-15— module docstring discipline (ADRs honored, port purpose stated in the first sentence,__all__immediately after imports).src/codegenie/vuln_index/protocol.py:17—from __future__ import annotationsdirectly after the docstring.-
src/codegenie/fallback/leaf/port.py— cross-process-boundary precedent; same asymmetry (typed in-process port + parallel subprocess wire) as Rubric. Use as the reference for the Rubric module's docstring. -
AST-based negative checks as structural defense. The tests use AST inspection to enforce three negative invariants (no
@abstractmethod, nofrom abc import abstractmethod) and three positive invariants (from __future__ import annotations,__all__ = ["Rubric"], module docstring cites both ADRs). This mirrors thetests/fence/discipline of enforcing structural defenses observably rather than by review-only convention. Convention without test enforcement drifts. -
The story envelope is ~20 LOC of production code (module docstring + imports +
__all__+ class + method stub) and ~150 LOC of tests (12 tests, several with rich docstrings naming ADR-0001 / Phase 5 ADR-0006 for the next reader). The asymmetry is intentional — the Protocol is small and load-bearing; the discipline must be enforced where it lives. -
Mapping[str, Any]forharness_output— typed-at-the-edge is the correct choice (notBaseModel, notdict[str, Any]). Each rubric internally narrows the mapping to its task-class-specific shape; the Protocol stays generic enough to support per-task-class diversity.BaseModelwould couple every rubric to a phase-pinned schema;dict[str, Any]would loseMapping's read-only invariance.