Lifecycle (shared by every domain)
Twelve stages, four phases. The phases are how to read the machine; the stages are the failure-attribution detail underneath them.task.score() takes an
ObservationBundle and nothing else, so no adapter and no target can reach the
verdict. Everything a cloud can influence happens above that line.
The stages, in order:
- RESOLVE — look up the DomainPack, benchmark suite (
suite:<id>) and Adapter for aRunSpec. Not recorded inrun.stages: a RESOLVE failure writes no record at all, so “RESOLVE: ok” would be a constant in every record. - VALIDATE — parse and check the RunSpec, target, params and task config
(
core/validation.py). RESOLVE and VALIDATE failures areUserInputErrors: CLI exit code 2, no record written. - DESCRIBE — assemble the run’s identity, environment and the three
fingerprints from what the plugins declare (
workload_identity,provenance,environment_facts). It spans the preflight gate: facts that need a live adapter are collected only after PREFLIGHT passes, so DESCRIBE is recorded once, at the end. A plugin that crashes here yields aninvalidrecord and nothing is provisioned. (Before 0.6.0 these failures were filed under VALIDATE, which produced records reading “VALIDATE failed, PREFLIGHT ok”.) - PREFLIGHT —
adapter.preflight(task): credentials, SDK, connectivity and the minimal permissions this benchmark needs on this cloud, before provisioning. A CRITICAL failure produces aninvalidrecord and never enters SETUP. Bypass with--skip-preflight; run standalone withcsbench doctor. - SETUP —
adapter.setup(): provision (Terraform) or connect (SDK/HTTP). Bothsetup()andteardown()are optional no-op hooks — the three cloud phases create → connect → destroy are separable, and a connect-only run (config-connect to an already-running service viatarget.endpoint/credentials_ref) uses only connect. Whether a run provisions is an explicit adapter capability,provisions_resources()(default: real cloud executed live;Falsefor*-endpointconfig-connect adapters and simulators, or inheritProvisionedCloudAdapterto always declareTrue). The provisioned-cloud machinery — the live-run confirmation gate, the cost budget/ledger, and resource reconciliation/reaping — runs only whenprovisions_resources()isTrue; a connect-only run skips all of it. - EXECUTE —
task.execute(adapter, params): drive the workload, return anObservationBundleof raw observations only. - SEAL —
core/observation.py::seal(): prove the bundle is well formed and canonically encodable. It fetches nothing from the cloud — EXECUTE already returned the bundle; this is the seal before scoring, and it is a stage of its own so “the suite crashed” and “the suite returned something unencodable” are different, attributable failures. NamedCOLLECTbefore 0.6.0; the old name stays valid inSTAGESso records written by 0.5.x still load. - SCORE —
task.score(bundle): a pure function producing measurements and findings. A scorer failure keeps the observations and records a SCORE error. - ENRICH — installed
ResultEnrichers, each isolated: a failure becomes an ENRICH stage error and never changesstatus. - PERSIST —
core/store.py: temp file, flush,fsync, atomic rename, plus an emergency dump into the system temp directory (path printed) when the results directory cannot be written. - PUBLISH — off unless a
ResultPublisheris injected. Runs after PERSIST and writes an append-only receipt; it can never rewrite the core record.
finally boundary
around SETUP → SEAL: once SETUP is entered, adapter.teardown() always runs,
including when setup() failed half-way, and a teardown failure is recorded as
its own stage error without overwriting the execute or collect error that
caused it.
Two gates sit between PREPARE and CONNECT and are not stages of their own —
the live-run confirmation (--allow-live) and the cost budget
(--cost-budget). Both refuse before anything is provisioned and both show up
the same way: SETUP: skipped, status invalid, plus a critical finding whose
details.remediation says exactly what to do. csbench run prints that line to
stderr, so a blocked run explains itself without reading the JSON on stdout.
Result contract (schema 0.4)
Top-level fields are fixed:schema_version, run, identity, environment,
fingerprints, measurements, findings, observations, series,
artifacts, items, extensions, errors, status.
items(schema 0.4, optional) is the per-item substrate: a list ofItemResults (item_id,group,input/output/reference, per-itemscoreswith a 4-statestatus∈ok·fail·skip·error,usage). The scalarmeasurementsare their aggregation (core/aggregate.py— mean/ratio/ geomean/percentile + Wilson/normal confidence intervals + per-groupbreakdowns). Empty for suites not yet migrated to per-item scoring. Capped byCSBENCH_MAX_PERSISTED_ITEMS(default 1000); truncation is recorded underextensions.core.items_meta, never silent.status∈completed·failed·invalid·unsupportedfor any recordexecute()hands back. A fifth value,interrupted, exists only for Ctrl-C: teardown runs, the record is persisted, and theKeyboardInterruptis then re-raised — so you find that status on disk, never as a return value. There is no booleanok, no top-levelmetrics, no top-levelevidence_layerand noconfig_hash.environment.execution∈simulated·live·unknownmarks whether the numbers came from a simulated runtime or a real cloud. It is folded into the environment fingerprint, so simulated and live results never pool together.- Every measurement carries
value,unitandofficial, optionallyreproducibility_class,aggregation,sample_count,notesandci. - Judge-based metrics (
reproducibility_class="judge-based") are real via the judge base (core/judge.py): aJudgeModel(config-connectedEndpointJudge, SSRF-guarded) is asked for a CATEGORICAL verdict and fixed arithmetic maps it to a score — G-Eval’s non-reproducible top-logprob weighting is deliberately rejected.judge_emitgets structured output from any judge (native JSON-schema mode, else prompt + brace-slice/trailing-comma repair). Theresponse-qualitymetric is the reference: a rubric judge with optional self-consistency; itskips cleanly when no judge is configured (so CI never calls a live judge) and records the judge model id + verdict in each item score. Run it by opting the metric in and config-connecting a judge:params: {extra_metrics: [response_quality], judge: {provider: openai-compatible, endpoint: ..., model: ..., credentials_ref: env:KEY, cache: .judge_cache.json}}— a judge-configured run does network I/O in scoring and is not offline-re-scorable, by design. Acachepath wraps the judge in a content-addressedCachingJudgeso a re-run reuses verdicts and never re-pays the LLM calls (only judge scoring is cached — never environmental numbers). - Golden ↔ executed split: the benchmark spec is the versioned
DatasetHandle(version+digest, pinned in-repo) plus each item’sreference(golden); the run’soutputis the executed side. No separate Golden subsystem — the per-item substrate already carries both. - Every finding carries a stable
code, aseverity, asummaryanddetails. - Every stage error carries
stage,code,type,messageandretryable. Tracebacks are never stored in a record;csbench run --debugwrites them to<results>/debug/<run_id>.log. - All digests are full SHA-256 over the canonical JSON encoding
(
core/canonical.py): UTF-8, sorted keys, no insignificant whitespace, NaN/Infinity rejected. extensions["core"]is reserved for the core; a plugin writes under its own name (for exampleextensions["cb-pricing"]).
1.0 records are no longer supported. Re-run the benchmark to produce
a current 0.4 record.
Run plans and statistics (Phase 1C)
A single number is not a measurement.csbench run --repeat N --warmup W
(ops/runplan.py) runs the same RunSpec warmup + repeat times through the
lifecycle above — every run is still its own digested 0.4 record — then:
- discards the
warmupruns (cold-start / JIT / cache effects are not the steady state you publish), tagging each record’s role underextensions["core"]["run_plan"]so the choice is auditable, never a fingerprint; - reduces the measured runs to one distribution per measurement
(
core/statistics.py): numeric →n / mean / stdev / min / max / p50 / p95 / cv, label → distribution /mode/agreement; - writes a
run_plan_aggregate(with its own SHA-256 digest) underresults/aggregates/…, which the report loader skips as a summary-of-records.
benchmark and environment fingerprints match. A plan whose benchmark or
environment changes mid-flight aggregates only the largest self-consistent
group and says so; csbench query and csbench rollup expose the fingerprints so a mismatch (not
comparable at all, or comparable only with a caveat that code changed) is visible. Only completed / unsupported runs contribute
numbers — a failed run is counted, but it has no verdict to pool.
Phase 1C does not itself ship plugin API ranges, JSON Schema, a conformance
kit or workload sandboxing — those are Phase 1D, which has since shipped in the
open core (plugin API version ranges + conflict detection, JSON Schema for
RunSpec / ResultRecord / manifest, csbench conformance, and workload
sandbox layers 1+2; see ROADMAP.md / CHANGELOG.md). Phase 1C aggregates only
scalar measurements, not time series.
Layers
Plugin contracts
Built-in and third-party (including closed-source commercial) packs load
identically — installing a package or dropping in a workload directory is enough.
A pack wiring a real cloud registers a
RuntimeProvider (and, optionally, a
CampaignProbeHook and ResourceReaper). The open core defines these
abstractions and now ships reference implementations for them — the live
Aliyun AgentRun and AWS AgentCore RuntimeProviders and ResourceReapers are
registered in-tree (pyproject.toml), so a real-cloud path is reproducible from
the open core alone. The seam stays open for additional third-party / commercial
providers to register the same way.
Control plane vs. data plane
Theexecute (touches the cloud) / score (pure) split is also the control
plane / data plane split. AgentRuntimeAdapter carries one extra seam,
run_data_plane_probe(name, params) -> ObservationBundle
(domains/agent_runtime/adapters/base.py): a latency-class “data-plane”
measurement collapses to adapter.run_data_plane_probe("<probe>", {...}),
so the adapter, not the benchmark, decides where the measurement runs. The base
adapter dispatches to an in-process packer registry
(domains/agent_runtime/dataplane_dispatch.py), which is exactly what
local-sim and any not-yet-wired cloud use. A wired cloud can instead override
the seam to send the whole measurement to a load probe running inside the
target region, keeping the operator’s network and system proxy out of the
numbers, and return the same ObservationBundle for scoring. score() is
unchanged either way. The probe lifecycle for a run-plan campaign is driven by
the optional CampaignProbeHook (started, synced and reaped in a try/finally
around the task loop; default --probe local leaves this path untouched). The
open core defines the seam and the hook contract and ships the reference
Aliyun ECI probe carrier and its cloud transport in-tree; additional carriers
(other clouds, third-party or commercial) register the same way.
Evidence layers
A docs · B environment observation · C controlled measurement · D marketing.
Reports never blend dimensions into one score.
The benchmark contract (BenchmarkSuite + Evaluator)
The ONE way to add a benchmark — recognized external suites (SWE-bench, TPC-H) and any new benchmark alike:BenchmarkSuitedrives an upstream harness unmodified and returns opaqueRawArtifacts(a directory + manifest of named files). Core treats the artifacts as a black box; only the paired evaluator understands them.EvaluatorreadsRawArtifactsas a pure function (no cloud, no credentials) and returnsdict[str, Measurement]where every key is namespaced"<suite_id>.<metric_name>"(official evaluators) or"<evaluator_id>.<metric_name>"(custom evaluators).- Namespace / official invariant:
conformance.check_evaluator(evaluator, suite_id, measurements)verifies that official evaluators emit onlyofficial=Truemeasurements under the suite namespace, and custom evaluators emit onlyofficial=Falsemeasurements under their own id — and that custom evaluators never squat a registered suite’s namespace. Returnslist[CheckResult]; run it from the CLI withcsbench conformance --suite <suite_id>(exercises every registered evaluator over the suite’s mock artifacts), or compose withrun_conformancefor CI gates. SuiteRunner+Provenance(internal runner, not a plugin contract): threads provenance metadata (suite_id, suite_version, evaluator_id, unmodified flag) through everyTaskResultand folds it into the benchmark fingerprint, so records from different suite versions or evaluators are never pooled.- SWE-bench pilot (slice 1):
SweBenchSuite(registered entry pointswe-bench) +OfficialSweEvaluator(emitsswe-bench.resolvedandswe-bench.cost_per_resolved) + bundled fixtures for offline / CI use. The real Docker-backedrun()path requires the[swebench]extra and is gated behind@pytest.mark.slow. - SWE-bench Lite (variant): the 300-instance curated subset ships as
SweBenchLiteSuite(entry pointswe-bench-lite) +OfficialSweLiteEvaluator(emitsswe-bench-lite.resolved/swe-bench-lite.cost_per_resolved). It is a thin subclass of the flagship — identical harness, report shape, and agent-runtime plumbing; only the pinned HuggingFace split (princeton-nlp/SWE-bench_Lite), the bundled real fixtures, and the namespace differ. This is the parametrization seam future variants (Multimodal) reuse: a variant overrides thesuite_id/suite_version/fixtures_dir/dataset_nameclass attributes and inherits everything else. - SWE-bench Multimodal (variant): image-augmented issues from real
JavaScript / visual-frontend repos, shipped as
SweBenchMultimodalSuite(entry pointswe-bench-multimodal) +OfficialSweMultimodalEvaluator(emitsswe-bench-multimodal.resolved/.cost_per_resolved). Same thin-subclass seam as Lite, plus a 7th fixture fieldimage_assets(image URLs) threaded to the agent by_load_instance. It binds thedevsplit on purpose: the 510-instancetestleaderboard split has its gold patches held out (scored via the hostedsb-cliservice), whereas the 102-instancedevsplit ships real gold patches and is locally Docker-runnable — matching how Verified/Lite work. Wiring the hosted test-split path is a documented future (gated) addition.
Current domains
agent-runtime— the cloud-infra shell for managed agent runtimes: adapters, runtime providers, probes, reaper. Since the suite-first pivot it registers no self-designed tasks — benchmark runs arrive assuite:<id>jobs through the benchmark-suite contract. Adapters use amanaged/transport/modesplit so the same code drives the local simulated runtime or, once a runtime-provider plugin is installed, a real cloud; capability probes raiseCapabilityNotSupported→ recorded as a finding, never a crash. The adapter statuses below are generated from the registry (never hand-maintained — see “Maintaining this document”).
Benchmark & adapter inventory
Benchmarks — 10 suites
agent-runtime
Adapters: aliyun-agentrun experimental · aws-agentcore skeleton · huawei-agentarts skeleton · local-sim reference · volcengine-agentkit skeleton
data-warehouse
Adapters: duckdb-local reference
key-value
Adapters: ycsb-endpoint experimental · ycsb-local reference
llm
Adapters: llm-endpoint experimental · llm-mock reference
transactional-db
Adapters: benchbase-local reference · jdbc-endpoint experimental
Developer Preview readiness
referenceandwiredadapters can execute.experimentaladapters can execute with preview caveats.skeletonadapters are discoverable but rejected before preflight.- All five domains ship a runnable reference adapter:
local-sim,duckdb-local,ycsb-local,benchbase-localandllm-mock. The Aliyun AgentRun (and AWS AgentCore)RuntimeProviders are registered in-tree. Thealiyun-agentrunadapter isexperimental: it runs end-to-end inmode: mockand its live path ran a real-cloud campaign (cn-hangzhou, 2026-08-15 — 25completed+ 2 honestlyunsupported). It is not yet promoted towired(that status is reserved for a stabilized, repeatedly-validated live path); AWS AgentCore and the other clouds remainskeleton. - Bundled workloads live in
clousight_bench.resources.workloadsand are resolved withcore.resources.reference_workload_path(), so wheel and editable installs use the same files.
0.4 (see “Result contract” above). The
plugin API is 3.0 (the OTel-native tracing break; the registry refuses
2.x-range plugins).
This repository is public and Apache-2.0 licensed; it contains the whole open
core, including the reproducibility mechanisms — the live Aliyun AgentRun
RuntimeProvider, ECI probe carrier, ResourceReaper, Terraform and the seed
pricing enricher. The moat is data and service, not withheld code. The
separate private clousight-bench-pro repository ships only:
cb-pricing— a fuller / fresher private price feed, consumed by the openpricingenricher via theCLOUSIGHT_PRICING_DATAenvironment variable (not an entry point);cb-dataservice— token-gated private / held-out datasets, registered as aPrivateAssetResolveron theclousight_bench.asset_resolversentry point, plus a managed object-store upload (placeholder);cb-adapters-enterprise— an empty placeholder for future managed-SaaS and private-cloud / domestic-stack adapters.
Credentials & onboarding (convenience layer)
Principle: never require users to generate a separate set of keys for the benchmark — reuse the cloud provider’s own default credential chain.core/credentials.py::resolve_credentials(target, platform)only probes the credential source; it never reads or stores key values. Resolution order:auth_env(escape hatch — explicitly named env var) →profile(named CLI profile) → standard env vars → credential file. ReturnsCredentialResolution(ok, source, identity_hint, remediation);identity_hintcontains only the variable name or profile name, never the key material.ProviderAdapter.resolve_credentials()delegates to the resolver above (cross-domain;provideris inferred fromtarget.provideror the platform-name prefix, e.g.aliyun-agentrun→aliyun). The real adapter runtime still hands off to the official SDK’s default chain.- Each provider’s credential chain is registered in
PROVIDER_CREDENTIALS(aws / aliyun / huawei / volcengine: standard env vars, profile env, credential file, SDK module, documentation link). - CLI:
csbench init <provider>generates a private*.local.yaml+.env.exampleand writes.gitignore(configuration contains no secrets);csbench doctor --config xruns a step-by-step health check (provider → SDK importable? → credential chain resolvable? →mock_base_urlreachable? localhost fails immediately), plus target-connectivity probes for config-connect runs: TCP reachability of the endpoint, a Redis RESPPING, andjava -versiongates (BenchBase needs Java ≥ 17, YCSB ≥ 11) — all SSRF-guarded; mock targets skip the probes. Each step provides an actionable remediation hint.
core/preflight.py provides Check / PreflightReport and reusable check functions (credential_check / sdk_check / mock_reachable_check); doctor and the orchestrator share the same functions (single source of truth).
ProviderAdapter.preflight() -> PreflightReport: validates credentials + provider SDK by default. Usable across domains.AgentRuntimeAdapter.preflight(task): on top ofsuper(), appends amock_base_urlreachability check +check_permissions(task)for real cloud platforms (non-empty provider). local-sim (empty provider, self-hosted mock) skips both additions and always passes.- The orchestrator runs
adapter.preflight(task)after RESOLVE and before SETUP; any CRITICAL failure triggers an early exit (no provisioning): the record gets aStageError(stage="PREFLIGHT", code="preflight_failed")plus aFinding(code="core.preflight_failed", severity="critical")whosedetails.checkslists every check line, and the record’sstatusisinvalid.--skip-preflightdisables this gate.
Least privilege: the (benchmark × cloud) matrix
The minimum permissions required by a given benchmark differ across cloud providers, so permission validation is mapped per task, with responsibilities split across two sides:- The benchmark declares WHAT: a suite’s
required_permissions: tuple[str, ...]lists abstract capability tokens (cloud-agnostic). Tokens are defined indomains/agent_runtime/permissions.py:session:create/session:state/tool:invoke/tool:register/trace:read/trace:export. - Adapter says HOW + verifies: each real-cloud adapter defines a
PERMISSION_MAP(token → the concrete minimum IAM/RAM actions for that cloud).required_actions(task)resolves the task’s tokens into the cloud-specific action set;_probe_permissions(actions)performs the actual authorization check (the default implementation returnsNone= unverified → emits a WARNING listing the minimum actions required for this benchmark run on this cloud; real adapter overrides call STS GetCallerIdentity / RAM policy simulation / dry-run and return(ok, missing)— any missing action is a CRITICAL blocker).
PERMISSION_MAP on the Adapter — the two sides are entirely decoupled. csbench doctor --domain <d> --platform <p> --task suite:<id> prints the minimum action list for any (benchmark × cloud) combination directly.
Dataset distribution (three tiers, one resolver)
A benchmark is more than code — it also includes assets (datasets, corpora, held-out scoring keys). The methodology is open, the data is tiered, and scoring keys are private. All of this is served by a single resolver; the open/private boundary is purely a matter of configuration:assets:section in the manifest (workload / task):core/assets.py:AssetSpec+resolve_asset(spec, base_dir, cache_dir, private_resolver);WorkloadEngine.resolve_assets()resolves all assets before a run and exposes{name: local_path}to the workload viaparams["assets"].- Reproducible without leaking secrets:
describe()folds each asset’sidentity()(name@version + sha256) into theconfig_hash— only the fingerprint is stored, never the content; scoring-key content never enters the record. - Scoring-key contamination resistance: a task’s methodology (how to score) can be open-sourced while its held-out key values are declared
source: private— “how to score” is public, “the answer key” is private, which naturally prevents benchmark leakage. - Extension point: private resolvers implement
PrivateAssetResolver(name+resolve(spec, cache_dir)) and register viaclousight_bench.asset_resolvers; the open core ships no implementation. Thecb-dataservicecomponent ofclousight-bench-proprovidesDataServiceAssetResolver(CLOUSIGHT_BENCH_TOKENauthentication + sha256 verification).
Data contract & extension points
ResultRecord carries three independent channels that do not overwrite one another; consumers read whichever they need:
measurements(scalar scores) — numbers used by the report layer for aggregation and comparison (there is no top-levelmetricsfield).series(time series,{name: [[t, value], ...]}) — high-frequency samples such as per-second latency or GPU utilization.artifacts({"kind", "path"/"uri", "media", "sha256"}pointers) — large files such as OTel traces and log bundles; only the fingerprint is stored inrecord.json, not the content.
metric/log/result events, with no interference between them:
sample events are accumulated into WorkloadResult.series keyed by series name; the sha256 for artifact events is computed by the engine from the file contents at workload_dir/path — the workload itself does not need to hash anything.
Persistence: record.json retains its historical path results/<domain>/<platform>/<task_id>-<run_id>.json unchanged (core/store.py::ResultStore owns this; orchestrator._persist delegates to it and the signature is stable). When the optional [store] extra (duckdb + pyarrow) is installed and the record contains series data, the series are externalized as a series.parquet tall table in the run directory, and the series field in record.json is rewritten to a pointer {"$parquet": "<relative_path>"}. Without the extra, series are embedded inline in record.json (lossless for small volumes).
Parquet tall-table columns (the stable handshake consumed by cb-dataservice and the SaaS web frontend):
unit column: when ResultStore writes Parquet, it reads the unit string for each series <name> from record.measurements[<name>].unit — a measurement with the same name as the series (for example, a latency_ms series takes its unit from the latency_ms measurement); when no such measurement exists, the column is an empty string "". This is an implicit convention, not enforced — omitting it still persists the row; the unit column is simply empty.
ResultStore.query_series(sql=None, glob="**/series.parquet") runs SQL directly against all Parquet files under results_dir via DuckDB; the glob path is passed to read_parquet(?) as a bound parameter (no string concatenation); raises ImportError when the [store] extra is absent.
Extension point: the clousight_bench.enrichers entry-point group hosts ResultEnricher subclasses — name: str + enrich(self, record: ResultRecord) -> ResultRecord. orchestrator.execute(spec, results_dir=None, enrich=True, preflight=True, publisher=None, …) (the publisher= kwarg injects an optional ResultPublisher for the PUBLISH stage) invokes them in sequence after the record is constructed and before _persist, ordered by the list returned from registry.load_enrichers() (sorted by name for deterministic execution order). The core ships no enricher implementations (such as cost estimation) — commercial plugins are injected via entry point; csbench run --no-enrich skips the entire chain.
Plugin compatibility contract: clousight_bench.PLUGIN_API_VERSION = "3.1" (SemVer). MAJOR is bumped only when schema fields, entry-point group names, or ResultEnricher/ResultStore signatures change incompatibly. Package version and plugin API version are two independent dimensions: clousight_bench.__version__ (currently 0.7.0) increments with every release; PLUGIN_API_VERSION moves only when the plugin-facing surface has a breaking change — the two do not evolve in lockstep. Phase 1A commercial plugin pyproject.toml pins against the current package version as clousight-bench>=0.7,<0.8 (the >=1.0,<2.0 form seen in early design documents was a target-state sketch written against PLUGIN_API_VERSION major versions and was not updated when the 0.2 developer preview shipped; corrected here).
Pro’s three extension boundaries (injected by clousight-bench-pro via entry point; the open core carries no implementations):
Maintaining this document
Some of this document is generated, and the rest is prose you keep current by hand — in the same change that alters the behaviour.- Generated blocks live between
{/* BEGIN generated:… */}/{/* END generated:… */}markers (currently the task & adapter inventory). They are rendered from the domain registry — the same source ascsbench list --json— byscripts/gen_docs.py. Never edit inside the markers by hand. After adding/renaming a task or changing an adapter status, runpython scripts/gen_docs.pyand commit the result. CI runspython scripts/gen_docs.py --checkandtests/test_docs_inventory.py, so a stale block (a hand-typed “28 tasks” that no longer matches the registry) fails the build instead of shipping. - Prose sections (everything outside the markers) are part of the definition of done for a behaviour change: when a task’s measurement shape, an adapter’s contract, or a lifecycle rule changes, update the paragraph that describes it in the same PR. A doc that drifts a release behind is worse than no doc — reviewers stop trusting it. Keep each edit small and scoped to what changed; the generated blocks absorb the mechanical facts so the prose only has to carry intent.
