This file holds only lasting guidelines. Temporary or local instructions (work-in-progress notes, current refactoring state) go to TODO.local.md (gitignored, imported via CLAUDE.md); prune entries there as the work lands.
Each layer may look down, never up:
- CLI (commands, reporters,
CLI::Report) — builds Config, Store, Runner; renders. Nothing below knows a terminal exists. - Runner (
Executor,Runner::Task) — scheduling, concurrency, resume; the only place run status, reporter events, and result persistence (store.save) meet. - Trial (+
Trial::Setup/Snapshot/Patch/Verifier) — executes one attempt, fills the Result, saves artifacts through the store. Standalone-runnable:Trial.new(task).runfrom a console needs no runner machinery — keep it that way. - Agents / Environments — adapters doing the work; they report through return values.
- Config / TaskDefinition — passive data, parsed once; knows nothing above it.
Result and Store ride across the top: constructed at CLI/Runner level, filled/used down to Trial and its components — and never lower.
Boundary rules:
- Never use
Storeor theResultrecord below Trial (and its components). Agents hand evidence back viaAgent::Response(outcome, usage, trajectory, error, raw result, artifacts); the Trial persists it. Result's value parts (Outcome,Usage,CostSource) are the shared vocabulary any layer may construct. - An agent sees exactly three things: its
profile(theConfig::Agentsection, at construction), thetask, and theenvironment. Never the whole Config, never paths. - Environments know no lemans domain: they receive resolved config values (
ImageSpec,Resources,NetworkPolicy, opaque labels) and expose the narrow exec contract. No Task, Config, Result, or Store. - Phase machinery (Trial and its components) parameterizes from
task.<section>(the per-task projection), never from global config sections. Run-level knobs (agent profile, backend, models, attempts, digest, revision) are read fromconfigat Runner/Trial level only. - Only the Store knows persistence: layout, paths, and reading/writing result.json.
CLI::ReportandClobberconsume the store API (query/fetch/unreadable/delete); nothing globs the filesystem past the store.Resultalone owns the serialization (as_json/from_json). Executoris generic (anything with#run) and reporter-agnostic. Reporters are CLI-side consumers of exactly two payload contracts:Runner::Taskreaders on:started,Resultreaders on:finished.- Errors are a boundary too:
ConfigErroris the bench author's bug and raises through every layer; infrastructure/verifier failures become Result statuses at the Trial; aStandardErrorescaping aRunner::Taskis a harness bug and poisons the run at the Executor.
- Ruby 3.4: use modern syntax —
itblock parameter, shorthand hash/kwarg notation (config_path:), endless methods for one-liners. - No explanatory comments. A comment is only warranted for a non-obvious constraint the code cannot express. Never modify or delete comments written by a human.
- Adapter pattern: the abstract base class lives at the root as
Lemans::<Role>(store.rb,agent.rb,environment.rb); implementations live in the plural directory and inherit the root class (Stores::FS < Store,Agents::Miniswen < Agent); name-to-class registries live on the plural module (Agents.build,Environments.build). - Avoid the
#callinterface — use meaningful verb methods:Agent#run,Setup#execute!,Verifier#verify!. #initializeorganization (seeResult): required instance vars — blank line — containers (required initial empty values) — blank line — inferred state. Only attributes required at construction go into the signature; the rest is set through writers or lifecycle methods.- A long-lived process reads better as one procedural method (
Trial#run) than as extracted steps that make no sense on their own; peripheral extractions (save_trajectory,check_cost_limit!) are fine. - Don't extract constants used once in a constructor — inline them as keyword defaults (
timeout: 300), notTIMEOUT. - Keep legacy files until the very end of a refactoring, even unused ones; don't touch functionality outside the current focus.
- Not defensive: assume user-provided values have sane types (strings where strings are expected) and good faith. Validate semantics (unknown modes, mutually exclusive keys), never types — and never paths: no existence, confinement, or relative/absolute checks on user-declared files.
- No single-purpose helper modules (a module wrapping one validation is an anti-pattern) — make such helpers private methods of the class that uses them. Same for shared conversion modules: only generic value parsing belongs in
Config::Conversion; feature-specific validation (restore paths) lives on the section that owns the feature. - Prefer mutable value containers:
Struct.new(..., keyword_init: true)updated via writers, notDatawith#with. - No unit suffixes in names:
duration, notduration_sec— the unit is implied by convention (seconds, megabytes). - Persisted formats are wire contracts, not code. The schema may evolve deliberately — every result.json carries a
lemans_versionstamp — but readers (Result.from_json) must keep backward-compat hooks so new lemans always reads old runs (duration_sec,bench:, hash-shaped phases). Use#as_jsonfor the JSON protocol, never#to_h.
- Classic Minitest (
Minitest::Test),require "test_helper"first. - Files:
test/lemans/<path>/<unit>_test.rb, mirroringlib/. Class names flatten the namespace:Lemans::Config::Agent→ConfigAgentTest. - Concise test method names:
test_full_section,test_missing_model. Behavior-oriented, a few words, not sentences. - Few, dense tests: one method per behavior area with several assertions, not one assertion per method. For helper modules, one test method per helper covering valid and invalid inputs together.
- Unit-test at the contract level with plain Ruby data (
from_configtakes a hash) — no YAML files or fixtures unless the unit itself reads files. - To test a helper module,
includeit into the test class and call the methods directly. - Test adapters are named
Test*(TestStore,TestEnvironmentintest/support/adapters.rb); useScripted*for fakes that replay prerecorded answers (shells, git). Support filenames must not match thetest_*.rb/*_test.rbglobs. - Assertions:
assert_equalon the exact error message when the message is the contract,assert_includeswhen only a fragment matters;assert_in_deltafor floats;assert_nilfor nils. - Group each test as setup → act → blank line-separated asserts.
- Run:
bundle exec ruby -Ilib -Itest test/path/to/x_test.rb; lint withbundle exec rubocopbefore finishing.