PROTEUS docs
v0.1.0 Jeremy Hall

Architecture

The world model, the pure renderer and the emitters — three layers, and the reasons the seams sit where they do.

Proteus is three layers with two hard seams between them.

flowchart TD
    AI["chat · hunt · GUI"]
    WM["WorldModel"]

    subgraph pure["render() — pure"]
        FX["effect_at()"]
        GEN["generator"]
    end

    REC["records"]
    EM["OTLP · RUM emitters"]
    RE["RecordingEmitter"]
    AWS[("CloudWatch")]

    AI -- "edit only this" --> WM
    WM --> pure
    FX -- "fault seam" --> GEN
    GEN -- "record seam" --> REC
    REC --> EM
    REC -- "--dry-run" --> RE
    EM --> AWS

A world model (proteus/model/schema.py) is a document describing a synthetic estate: services, the infrastructure they share, the tenants who own them, and the faults currently in effect. It is plain JSON and it is the only durable state the tool keeps, besides the CloudFormation stack and the hunt ledger.

The renderer (proteus/render/engine.py) is a pure function from (WorldModel, Window) to a stream of signal-neutral records (proteus/render/records.py). No network, no file reads, no print, no wall-clock reads, and no use of the random module.

Emitters (proteus/emit/) translate records into wire calls. RecordingEmitter keeps them in memory instead, which is what --dry-run, the cost estimator and every test use. Nothing above the emitter layer knows which one it has.

Why the record seam exists#

The renderer could have built OTLP protobuf directly. It does not, and the reason is testability of a specific kind.

A fault in Proteus is a coordinated signature across signals: CPU climbs, p99 stretches, a FATAL line appears in the logs, a span gains an container.oom_kill event. Verifying that by hand means opening four CloudWatch consoles and forming an opinion. Verifying it against records means asserting on a dataclass.

So SpanRecord, MetricPoint, LogRecord and RumEvent are deliberately neutral: they carry no protobuf types, no SDK objects, and no endpoint knowledge. proteus/render/ imports no AWS SDK and needs no credentials. That is the property being bought, and it is why records.py is frozen.

Why the fault seam exists#

A fault never writes telemetry. proteus/faults/library.py returns a FaultEffect — a bundle of 14 numeric multipliers and addends plus a narrative payload — and the archetype generator applies it to its own baseline.

The alternative is every generator branching on FaultKind. That works for two faults and twelve archetypes. At 14 kinds and 68 placeable workload types it is 952 combinations that each have to be written and none of which can be reasoned about. With the effect seam, adding a fault touches one file under proteus/faults/ and adding a workload touches one file under proteus/catalog/, and neither touches the other.

The seam also buys something that would be awkward otherwise. noisy-neighbor has two signatures: the victim slows down with no local cause, and the aggressor looks merely busy. Because both are FaultEffect values, proteus/faults/resolve.py can hand the right one to each service on a shared InfraNode without any generator knowing that contention is a concept. See Faults.

The frozen files#

Six files are frozen. They are the contracts every other module is written against, so changing one silently changes the meaning of code you are not looking at.

File What it fixes
proteus/model/schema.py The world model: the estate's data shape and its JSON form
proteus/render/records.py The four record types and the Signal enum
proteus/emit/base.py The Emitter protocol, EmitResult, RecordingEmitter, EmitterSet
proteus/faults/effects.py FaultEffect, its units, combine, scaled
proteus/catalog/base.py ArchetypeGenerator, Tick, tick_rng, the registry
pyproject.toml Dependencies and the package surface

One further file is not frozen but is a cross-module contract: proteus/catalog/_shared.py::LATENCY_BOUNDS_MS. Every latency histogram in the project buckets over it, and _compute_shared.py::SLOW_BOUNDS_MS extends it. It must stay strictly ascending and must stay a strict prefix of any extension. Changing it changes the wire format of every latency metric, so it changes in one pass with its extension or not at all.

CONTRACTS.md records that the frozen list once named only three of those six files while the per-agent briefs named all six. An agent reading only CONTRACTS.md could reasonably have concluded catalog/base.py was editable — the one file most likely to tempt someone, since it holds the protocol everyone implements. The list is now complete in both places.

If a contract genuinely blocks you: stop and say what you need. Do not work around it by editing a frozen file, and do not define a parallel type.

Module map#

proteus/
  model/        schema.py (frozen), store.py, presets.py
  catalog/      base.py (frozen), profile.py, generic.py,
                12 archetype modules, profiles/ (56 declarative profiles)
  faults/       effects.py (frozen), library.py, resolve.py
  render/       records.py (frozen), engine.py, timeline.py
  emit/         base.py (frozen), otlp.py, rum.py, _proto.py, _transport.py,
                _logstreams.py, _diagnose.py
  guard/        estimate.py, policy.py, _volume.py
  cfn/          synth.py, deploy.py, _resources.py, _serialise.py,
                _names.py, _state.py, _templates3.py
  ai/           chat.py, hunt.py, bedrock.py
  web/          server.py, api.py, api_ops.py, jobs.py, catalog_view.py,
                validate.py, pricing_view.py, static/
  cli/          main.py, inject.py, doctor.py, inspect.py, interact.py, banner.py
  pricing.py, pricing_cache.py

Every file is under 500 lines, by rule. That is why several subsystems are one public module plus private helpers: synth.py re-exports from _names.py, _resources.py and _serialise.py; estimate.py re-exports from _volume.py; otlp.py re-exports diagnose from _diagnose.py. Import the public module, not the helper.

Determinism is a load-bearing property#

Every generator takes an explicit rng: random.Random. The random module is never called at module level, and the renderer never reads a clock — time is always passed in. The same WorldModel.seed over the same time range produces identical records on every run, in any process, in any evaluation order.

proteus/catalog/base.py::tick_rng gets this by hashing rather than by advancing a shared stream:

def tick_rng(seed: int, service_name: str, ts: datetime) -> random.Random:
    material = f"{seed}:{service_name}:{ts.isoformat()}".encode()
    digest = hashlib.blake2b(material, digest_size=8).digest()
    return random.Random(int.from_bytes(digest, "big"))

Because each (service, timestamp) pair derives its own stream, ticks are independent and order does not matter — which is what will let the renderer parallelise later without breaking a golden file.

The blind spot this design has#

Determinism makes wrong answers stable, and a stable wrong answer passes every check that compares output to previous output.

Three real defects in this project were all of that shape:

  • proteus.agent.invocations reported a flat 6 per minute — its sampling cap — against a modelled 1,001 to 3,079, and token counters under-reported by roughly 500x. Deterministic, order-free, idempotent, reproducible across processes.
  • A projected cost came out about 600x high, because guard/_volume.py guessed at three possible signatures for estimate_volume, matched none, and silently fell back to a heuristic table.
  • Three MetricSpec entries declared LATENCY in seconds because AWS genuinely publishes those counterparts in seconds. The renderer builds every latency histogram in milliseconds and passes unit straight through, so they read 1000x fast.

None of those was caught by reading. A golden file would have frozen each one as correct.

So the project's verification discipline is: render and diff, never read. Assert rendered values against the world model and the unit the contract states — never against recorded output, and never against the spec that produced the output. proteus/catalog/base.py::ArchetypeGenerator.metrics states the rule for counters directly: a capped archetype must scale its SUM counters from the sample back up to the modelled rate, because capping bounds payload size and is never the reported rate.

Where to go next#

The pipeline in order: the world model → the catalog → faults → the renderer → emitters.

The two things that wrap it: the cost guard, which decides whether a run is allowed to send at all, and provisioning, which creates the AWS-side scaffolding the telemetry lands in.

The web GUI is a view over all of it.

Proteus is MIT-licensed. Built by Jeremy Hall.

Synthetic telemetry for CloudWatch Omni. Everything it emits is fabricated; nothing in it describes a real system.