Skip to content

Commit eb7588e

Browse files
uipreligaclaude
andcommitted
feat(lint): replace the prose baseline with two self-adjusting rules
_ESSAY_BASELINE_WORDS was the weakest part of the gate. It was a single tree-wide number, hand-edited eleven times in this work alone, that a reviewer had to take on trust; it let growth in one file hide behind shrinkage in another; and it said nothing at all about a file that did not exist yet. It is gone. Two rules replace it, and neither has a number anyone maintains: own-line comments per file <= MAX(20, 0.15 * file length) no docstring over 150 words of PROSE The comment budget is proportional, so deleting code takes its budget with it and a new file is governed from its first commit. Own-line only: a trailing `# noqa` is a directive and a per-member annotation on an enum is the contract a dispatcher reads — counting either would push against documenting them. The floor is what protects a constants module at one comment per constant, which is where the tree's natural maximum sits. The essay rule now counts prose, not structure: an Args/Returns/Raises block is interface documentation, and counting it pushed exactly the docstrings that document their contract best over the line. Exemptions are categorical rather than numeric — an @AbstractMethod docstring IS the contract implementers read, so the plugin SPI is covered by kind, and a new abstract method is covered automatically. The old "at most two, by fiat" allowance is unnecessary: the tree now has ZERO essays. Getting there took 44 comment lines out of 7 files. Most came out by reflowing two lines into one or cutting a section divider; the one real compression was a 22-line block in early_stop.py restating the floor bound that orchestration.md already owns, and the distractor-exclusion rule it uniquely held moved there rather than being dropped. One reflow silently merged `# pyright: reportImportCycles=false` into the prose line above it, which would have stopped pyright honouring it. --assert-code-unchanged caught it. That is the second time the directive multiset has earned its place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f7b5a19 commit eb7588e

12 files changed

Lines changed: 218 additions & 130 deletions

File tree

‎.claude/notes/orchestration.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,12 @@ trajectory continues. A `decide_within` timeout participates as an ordinary weig
236236
so a low-weight criterion's timeout that cannot doom the gate does not stop the run.
237237

238238
A **pass-stop** fires once the `on_pass: stop` subset's FLOOR — worst case, every
239-
still-undecided member scores 0 — already meets the threshold.
239+
still-undecided member scores 0 — already meets the threshold. Criteria armed only on the
240+
FAIL side (distractors) are excluded from both the numerator and the denominator of that
241+
bound: they can never live-pass and exist only to guard the fail side, so folding them in
242+
would veto every pass-stop and penalise the bound for a criterion it was never scoped to
243+
cover. With no `on_pass: stop` criteria at all the bound is vacuous and returns nothing —
244+
there is no pass-stop to take, and the run continues to the cap.
240245

241246
At the default threshold both bounds collapse exactly to "any single armed criterion's
242247
effective fail stops the run" and "every `on_pass: stop` criterion has live-passed".

‎CLAUDE.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ make evalboard-verify # the JS half: tsc --noEmit + vitest + next build
208208
make docs-indexes # README/docs index tables from the mkdocs nav (CE028)
209209
make plugin-reference # the plugin's criteria reference from the models (CE033)
210210

211-
make docs-budget # prose budget report; fails `make verify` if the total grows
211+
make docs-budget # per-file comment budget + docstring essay check (fails `make verify`)
212212
```
213213

214214
Editing `src/coder_eval/pricing.py` means editing `evalboard/lib/pricing.ts` too — it is
@@ -361,8 +361,11 @@ bandit, pre-commit, mcp
361361
code cannot say
362362
- **A docstring states the contract, not the history** — what a caller must know to call
363363
it correctly. Why the design is this shape belongs in `.claude/notes/`; what it used to
364-
be belongs in git. `make docs-budget` reports the standing total and fails
365-
`make verify` if it grows
364+
be belongs in git. `make docs-budget` enforces two rules, both self-adjusting: a file's
365+
own-line comments may not exceed `MAX(20, 0.15 × its length)`, and no docstring may
366+
exceed 150 words of PROSE (an `Args:`/`Returns:`/`Raises:` block is structure, not
367+
prose; an `@abstractmethod` is exempt because its docstring IS the interface contract).
368+
There is no tree-wide total to hand-maintain — delete code and the budget shrinks with it
366369

367370
## Notes for AI Assistants
368371

‎src/coder_eval/config.py‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,9 @@
2626
).decode("utf-8")
2727

2828

29-
# Load .env file with override so .env values always win over shell environment
29+
# override=True so .env always wins over the shell's possibly-stale credentials.
3030
load_dotenv(override=True)
3131

32-
# For certain keys, we want .env values to take precedence over shell environment
33-
# because the shell may have outdated/different credentials
3432
env_values = dotenv_values(".env")
3533
for key in [
3634
"ANTHROPIC_API_KEY",
@@ -79,52 +77,43 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
7977
_reject_removed_default_knobs()
8078
super().__init__(*args, **kwargs)
8179

82-
# API Keys (for Claude Code agent only)
8380
anthropic_api_key: str | None = None
8481

85-
# Paths
8682
runs_dir: Path = Path("runs") # Base directory for timestamped runs
8783

88-
# API Backend routing
8984
api_backend: ApiBackend = ApiBackend.DIRECT
9085

91-
# AWS Bedrock settings (used when api_backend == "bedrock")
9286
aws_bearer_token_bedrock: str | None = None
9387
aws_region: str | None = None
9488
bedrock_model: str | None = None # Cross-region model ID
9589
bedrock_small_model: str | None = None # Cross-region small model ID
9690

97-
# HAZARD: these map to the ANTHROPIC_* vars, but ONLY inside the SDK subprocess
98-
# env. Deliberately NOT named anthropic_*, so the export loop below cannot leak
99-
# ANTHROPIC_BASE_URL process-wide and redirect the judge's own client.
91+
# HAZARD: these map to the ANTHROPIC_* vars ONLY inside the SDK subprocess env.
92+
# NOT named anthropic_*, so the export loop cannot leak ANTHROPIC_BASE_URL
93+
# process-wide and redirect the judge's own client.
10094
litellm_base_url: str | None = None
10195
litellm_auth_token: str | None = None
10296
litellm_model: str | None = None
10397
litellm_small_model: str | None = None
104-
# Must point at the SAME file the proxy writes. When set and present, the harness
105-
# joins each call's ACTUAL cost onto the turn; unset or missing => static pricing.
98+
# Must point at the SAME file the proxy writes; unset or missing => static pricing.
10699
# Rationale: .claude/notes/reporting.md § Cost joining
107100
litellm_cost_log: str | None = None
108101

109102
# CODEX_MODEL is the fallback when a task doesn't pin agent.model. For Azure set
110-
# CODEX_API_VERSION too and use the deployment name as the model. CODEX_BASE_URL
111-
# / CODEX_API_VERSION / CODEX_API_KEY are read via os.getenv in the agent.
103+
# CODEX_API_VERSION too and use the deployment name as the model.
112104
codex_model: str | None = None
113105

114106
# GEMINI_API_KEY is read from .env here so the export loop re-publishes it to
115107
# os.environ, where the SDK looks for it. ANTIGRAVITY_MODEL is the fallback.
116108
gemini_api_key: str | None = None
117109
antigravity_model: str | None = None
118110

119-
# Logging
120111
log_level: str = "INFO" # Default log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
121112
log_to_file: bool = False # Whether to enable file logging
122113

123-
# On by default via the baked-in connection string. TELEMETRY_ENABLED is the
124-
# single canonical disable gate.
114+
# On by default via the baked-in connection string, which any set value (env
115+
# or .env) overrides. TELEMETRY_ENABLED is the single canonical disable gate.
125116
telemetry_enabled: bool = True
126-
# Defaults to the embedded coder-eval resource; any set value (env or .env, via
127-
# the aliases below) overrides it — pydantic-settings prefers env over default.
128117
telemetry_connection_string: str | None = Field(
129118
default=_DEFAULT_TELEMETRY_CONNECTION_STRING,
130119
validation_alias=AliasChoices(
@@ -180,8 +169,7 @@ def _validate_litellm_settings(self) -> None:
180169
f"LiteLLM-endpoint routing is enabled but missing required settings: {', '.join(missing)}."
181170
+ " Please set them in your .env file."
182171
)
183-
# Reject a malformed base_url here so the downstream preflight and
184-
# environment_info get a well-formed absolute URL.
172+
# Reject a malformed base_url so the preflight and environment_info get a well-formed URL.
185173
parts = urlsplit(self.litellm_base_url or "")
186174
if parts.scheme not in ("http", "https") or not parts.hostname:
187175
raise ValueError(
@@ -215,15 +203,12 @@ def validate_api_keys(self, agent_type: str) -> None:
215203
return
216204

217205

218-
# Global settings instance
219206
settings = Settings()
220207

221-
# For external libraries that read os.getenv() rather than the Settings object.
222-
# Non-None values only, stringified.
208+
# For external libraries that read os.getenv(); non-None values only, stringified.
223209
for key, value in settings.model_dump().items():
224210
if value is not None:
225211
env_key = key.upper()
226-
# Convert Path objects and other types to strings
227212
if isinstance(value, Path):
228213
os.environ[env_key] = str(value)
229214
elif isinstance(value, bool):

‎src/coder_eval/criteria/agent_judge.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@
4444
path_uses_token,
4545
)
4646

47-
# Not part of the public coder_eval.models surface, but the single source of truth
48-
# for both files.
47+
# Not part of the public coder_eval.models surface, but the single source of truth for both files.
4948
from coder_eval.models.criteria import ( # noqa: CE001
5049
JUDGE_SECURITY_IGNORE_FLOOR,
5150
_default_judge_agent_config,

‎src/coder_eval/evaluation/sub_agent.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ def __init__(
8888
# drop a user's own nested subdir of the same name.
8989
# Rationale: .claude/notes/contracts.md § The security floor
9090
self._reference_ignore_patterns = reference_ignore_patterns or []
91-
# Runtime-only in-process MCP injection. NOT routed through ``sdk_options``
92-
# -- ``mcp_servers`` is framework-owned.
91+
# Runtime-only MCP injection, NOT via ``sdk_options`` -- ``mcp_servers`` is framework-owned.
9392
self._extra_mcp_servers = extra_mcp_servers or {}
9493
# Public so the criterion can read it after ``run_async()`` returns; absent
9594
# when the caller passed ``capture=None``.

‎src/coder_eval/isolation/docker_runner.py‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -653,8 +653,7 @@ async def run(self) -> EvaluationResult:
653653
widened_workspace = await asyncio.to_thread(grant_container_access, self.grade_workspace, writable=True)
654654
argv = self._build_argv(input_dir, output_dir, container_name=container_name, image=image)
655655
logger.info("Running task '%s' in docker: %s", self.rt.task.task_id, " ".join(argv))
656-
# Prime the heartbeat before the container starts so the
657-
# watchdog never sees an initial stale state.
656+
# Prime the heartbeat before the container starts so the watchdog never sees an initial stale state.
658657
heartbeat_path = output_dir / HEARTBEAT_FILENAME
659658
await asyncio.to_thread(heartbeat_path.touch)
660659
heartbeat_task = asyncio.create_task(_heartbeat_loop(heartbeat_path))
@@ -673,13 +672,11 @@ async def run(self) -> EvaluationResult:
673672
returncode = await self._stream_container_output(proc, log_fh)
674673
finally:
675674
heartbeat_task.cancel()
676-
# Narrowed so a genuine KeyboardInterrupt / SystemExit from a
677-
# parallel sibling still propagates.
675+
# Narrowed so a genuine KeyboardInterrupt / SystemExit from a parallel sibling still propagates.
678676
with contextlib.suppress(asyncio.CancelledError):
679677
await heartbeat_task
680678
await asyncio.to_thread(log_fh.close)
681-
# Cancelled mid-flight: kill the container AND the docker CLI
682-
# subprocess, best-effort.
679+
# Cancelled mid-flight: kill the container AND the docker CLI subprocess, best-effort.
683680
if proc.returncode is None:
684681
await self._kill_container(proc, container_name)
685682

@@ -906,8 +903,7 @@ def _assert_grade_honored(self, result: EvaluationResult, task_json: Path | None
906903
"""
907904
if self.grade:
908905
return
909-
# Keyed on EVIDENCE, not on the label: the question is not "what status is
910-
# this" but "did it grade".
906+
# Keyed on EVIDENCE, not on the label: the question is not "what status is this" but "did it grade".
911907
graded_anyway = bool(result.success_criteria_results) or result.weighted_score is not None
912908
if not graded_anyway and (
913909
result.final_status.is_execution_fact or result.final_status is FinalStatus.NOT_GRADED
@@ -1108,8 +1104,7 @@ def _prepare_task_dir_mount(self, staging: Path) -> None:
11081104
return
11091105
task_dir_copy = staging / "task_dir"
11101106
shutil.copytree(source, task_dir_copy, ignore=ignore_patterns_and_symlinks(REFERENCE_COPY_IGNORE))
1111-
# Read-only like the reference copy: criteria read fixtures here, nothing
1112-
# legitimately writes them.
1107+
# Read-only like the reference copy: criteria read fixtures here, nothing legitimately writes them.
11131108
grant_container_access(task_dir_copy, writable=False)
11141109
self._task_dir_mount_src = task_dir_copy
11151110

‎src/coder_eval/orchestration/config.py‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ class BatchRunConfig(BaseModel):
6969
),
7070
)
7171

72-
# Dataset sampling (for cheap smoke runs on dataset-backed tasks)
7372
max_rows: int | None = Field(
7473
default=None,
7574
ge=1,
@@ -85,7 +84,6 @@ class BatchRunConfig(BaseModel):
8584
),
8685
)
8786

88-
# Replicate count override
8987
repeats: int | None = Field(
9088
default=None,
9189
ge=1,
@@ -104,7 +102,6 @@ class BatchRunConfig(BaseModel):
104102
),
105103
)
106104

107-
# Logging
108105
verbose: bool = Field(default=False, description="Enable verbose (DEBUG level) logging for Docker output")
109106

110107
# Docker WORKDIR alignment for the NON-docker-driver dispatch path — a host
@@ -121,15 +118,8 @@ class BatchRunConfig(BaseModel):
121118
),
122119
)
123120

124-
# TODO(container-death-diagnostics): consider a run-level default resource
125-
# cap. Containers run uncapped today (sandbox.limits.{max_memory_mb,
126-
# max_cpus,max_pids} default to None -> _build_argv emits no --memory/
127-
# --cpus/--pids-limit), so at --max-parallel=20 a single runaway task can
128-
# pressure the whole host. An opt-in default cap is already expressible
129-
# via the EXISTING layered sandbox config -- defaults.sandbox.limits.
130-
# max_memory_mb in the experiment YAML, or `-D sandbox.limits.
131-
# max_memory_mb=N` on `coder-eval run` -- both flow through
132-
# resolve_all_tasks and are overridden by per-task limits. If a dedicated
133-
# CLI knob is ever wanted, add it as the FIRST (lowest-priority) layer in
134-
# _build_sandbox_layers so per-task limits win, and do NOT default it to
135-
# a non-None value (would change behavior for existing configs).
121+
# TODO(container-death-diagnostics): containers run uncapped today, so at a
122+
# high --max-parallel one runaway task can pressure the host. An opt-in
123+
# default is already expressible through the layered sandbox config; a
124+
# dedicated CLI knob would go in as the LOWEST-priority layer, never
125+
# defaulted to a value (that would change existing configs).

‎src/coder_eval/orchestration/early_stop.py‎

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,6 @@ def for_task(cls, task: TaskDefinition) -> EarlyStopWatcher:
284284
)
285285
return cls(task.task_id, armed, max_turns=max_turns, gate_threshold=gate_threshold)
286286

287-
# --- StreamCallback -------------------------------------------------- #
288-
289287
def on_event(self, event: StreamEvent) -> None:
290288
"""Fail-open wrapper around ``_on_event_impl``: any unexpected exception
291289
anywhere in the round — the collector reduction included, not just the
@@ -361,8 +359,6 @@ def disarmed(self) -> bool:
361359
"""True once a ``live_verdict`` raised and the watcher degraded to a full run."""
362360
return self._disarmed
363361

364-
# --- Stop rule -------------------------------------------------- #
365-
366362
def _ceiling(self, verdicts: list[LiveVerdict]) -> float:
367363
"""Best-case weighted score over the WHOLE armed set, given current verdicts.
368364
@@ -502,28 +498,12 @@ def _evaluate_impl(self, in_flight: CommandTelemetry | None = None) -> None:
502498
self._fire(reason, self._armed[candidate_index][0], tool_call_index=tool_call_index)
503499
return
504500

505-
# Pass-stop: the on_pass=stop subset's own floor bound (worst case:
506-
# every still-undecided member scores 0, weighted against only that
507-
# subset's total weight) already meets ``gate_threshold`` — guaranteed
508-
# regardless of what the rest of that subset still decides. Criteria
509-
# armed only on the fail side (distractors) are excluded from both the
510-
# numerator and the denominator: they can never live-pass and only
511-
# guard the fail side above, so folding them in would veto every
512-
# pass-stop and penalize this bound for a criterion it was never
513-
# scoped to cover. At the default ``gate_threshold=1.0`` this requires
514-
# every on_pass=stop criterion to actually be "pass" (any non-pass
515-
# drops the floor below 1.0). The vacuous case (no on_pass=stop
516-
# criteria at all) returns None — nothing to pass-stop on, the run
517-
# continues to the cap.
518-
#
519-
# Recall deferral, mirrored from the fail-stop: the pass-stop is HELD
520-
# while any pass-capable armed criterion OUTSIDE the on_pass=stop
521-
# subset is still undecided (members of the subset are already priced
522-
# into the floor). Cutting here would freeze a sibling
523-
# ``on_pass: continue`` criterion's expected signal out of the
524-
# trajectory — an unearned fail on the armed gate that a full run
525-
# would not have produced. Once every such criterion decides (pass or
526-
# fail), the still-satisfied floor fires the pass-stop on that round.
501+
# Pass-stop: the on_pass=stop subset's FLOOR already meets
502+
# ``gate_threshold``. Distractors are excluded from both the numerator and
503+
# the denominator; no on_pass=stop criteria at all returns None. HELD while
504+
# any pass-capable armed criterion OUTSIDE the subset is undecided --
505+
# cutting there would freeze a sibling's expected signal out of the run.
506+
# Rationale: .claude/notes/orchestration.md § The ceiling and floor bounds
527507
pass_stop_indices = [i for i, armed_pass in enumerate(self._pass_trigger) if armed_pass]
528508
outside_pass_capable_undecided = any(
529509
v == "undecided" and "pass" in pol and not armed_pass

‎src/coder_eval/path_utils.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@ def write_text_atomic(path: Path, text: str) -> None:
5656
"""Write ``text`` to ``path`` via a temp file + ``os.replace``.
5757
5858
A plain ``write_text`` truncates first, so a crash mid-write leaves a
59-
half-file — and a truncated ``task.json`` parses as *malformed*, which the
60-
recovery paths read as "not complete", so ``--resume`` pays for the agent
61-
again. One writer, so every producer of that file has the same crash
62-
semantics.
59+
half-file — and a truncated ``task.json`` parses as *malformed*, which
60+
``--resume`` reads as "not complete" and pays for the agent again. One
61+
writer, so every producer has the same crash semantics.
6362
6463
The temp file is opened ``O_CREAT | O_EXCL | O_NOFOLLOW`` under a name that
6564
is UNIQUE per call. ``O_NOFOLLOW`` closes a symlink-plant overwrite

0 commit comments

Comments
 (0)