Skip to content

Academy · Module 1

Run an agent with the network off

You will load a frozen five-month window of SEC filings, run one agent across it three times under three different ideas of when a filing became knowable, and end holding a receipt whose hashes anyone can recompute. The four commands run in about sixteen seconds; budget half an hour for reading what they print.

Written against pit-harness 0.1.0, Python 3.11.15, and the release pit-eval-sec-2022-11_2023-03 at content digest sha256:bc924991…. Every output below was pasted from a run on that build.

boundary.py · 09:30 in New York, 10 March 2023 one row, three clocks

$ python3 boundary.py

leaky  served  SVB FINANCIAL GROUP 8-K  acceptance_at=2023-03-10T22:23:03Z
dump   not_yet_knowable  This row exists in the release but its clock has not passed at 2023-03-10T14:30:00Z under the dump policy.
pit    not_yet_knowable  This row exists in the release but its clock has not passed at 2023-03-10T14:30:00Z under the pit policy.

The same filing gets three different answers at the same instant. At the opening auction on 10 March 2023 the leaky policy hands an agent SVB Financial Group's receivership 8-K. EDGAR stamped that filing as accepted at 22:23:03 UTC, nearly eight hours after the bell. The other two policies refuse the row and name the clock that had not passed.

A backtest that joins filings on their index date is running the first column. This module builds the run that measures the difference and writes the measurement into a file a second reader can recompute from their own copy of the data.

What a run is

A run is a grid: every arm of the release crossed with every clock policy. For each cell the harness walks a session calendar and calls your agent once per session, with four tools bound to that session's opening bell — pit_list, pit_search, pit_read and pit_coverage. A row reaches the agent only when its clock under that cell's policy has passed. Ask for one that has not and the tool returns not_yet_knowable and the row's key, with none of the row's fields.

ClockReadsEnters atReachable in practice
leaky the partition date at 00:00:00Z the opening auction of the filing's own index day no
dump published_at, 23:59:59Z on the index day the opening auction of the next session yes
pit acceptance_at where stamped, published_at otherwise the first auction after the acceptance receipt yes

Midnight UTC is seven or eight in the evening in New York, so a join on the filed date acts on filings hours before they existed. 23:59:59Z is 18:59:59 in New York, after the close, so the dump arm waits for the next morning. An acceptance receipt is an intra-day timestamp, which puts the entry on the same morning when it arrives before 09:30 and on the next one when it arrives after.

Before you start

You need Python 3.11 or newer, a shell, and the release directory pit-eval-sec-2022-11_2023-03. It needs no key, no model and no market data. pit-harness has no runtime dependencies, and every arm ships as JSONL beside its parquet twin, so the whole module runs on the standard library.

install

$ python3.11 -m venv .venv && . .venv/bin/activate
$ pip install ./pit-harness

Successfully installed pit-harness-0.1.0

pit-harness is Apache-2.0 and is not on PyPI yet, so today it installs from a source directory. Mail operations@aqx.llc for that directory and the release together. Work from the directory that holds the release, or give an absolute path. The receipt records the basename either way, so both produce the same hashes.

Step 1 — read the release before you run against it

pit-harness bundle 7s

$ pit-harness bundle pit-eval-sec-2022-11_2023-03

{
  "arms_loaded": {
    "main": 287929,
    "redacted": 287929,
    "shifted": 287929
  },
  "builder": "cmd/bundle",
  "bundle_schema_version": 1,
  "clocks": {
    "acceptance_at": 3124,
    "available_at": 0,
    "event_at": 0
  },
  "content_digest": "sha256:bc924991d6ea64984dd533874014e83dac8a52b9bd442f08f89b60a5f055c15c",
  "corpus_version": "sha256:29336a245255fdc7be9b5b0b1a6e90053e958a78a58166dbefeaeedce05eea7a",
  "distinct_ids": 287925,
  "manifest_rows": 287929,
  "partitions": {
    "complete": 103,
    "days": 151,
    "empty": 0,
    "missing": 48,
    "partial": 0
  },
  "read_format": "jsonl",
  "release": "pit-eval-sec-2022-11_2023-03",
  "root": "pit-eval-sec-2022-11_2023-03",
  "source_id": "sec.edgar",
  "text": {
    "days_with_text": 0,
    "note": "…",
    "rows_with_text": 0,
    "status": "absent"
  },
  "window": {
    "end": "2023-03-31",
    "start": "2022-11-01"
  }
}

One value is cut: text.note, a paragraph saying the same thing status does. Everything else is verbatim.

Four numbers here set the limits of what the rest of the module can claim. available_at is zero, so no cut in this release can be bounded by proven availability. acceptance_at is 3,124 out of 287,929, and all 3,124 sit on 2023-03-10, which is why the window below ends where it does. Anywhere else in the release the pit and dump arms produce identical results. partitions.missing is 48 of 151 days, which is weekends and holidays rather than lost data. And text.status is absent: a title is the company name and the form type joined together, so a model reading these rows has almost nothing to read and will answer from memory. Control arms, in module 3, are how that last one gets measured.

content_digest is the value to pin. It covers the data files and ignores the build clock, so two builds of the same corpus give the same digest. Every hash printed further down was produced against the build with digest sha256:bc924991….

Step 2 — run it

The config is one JSON file. This one asks for the main arm only, all three clocks, 8-K filings, and the first two weeks of March 2023. The window runs to the 14th rather than the 10th so that the filings of the 10th — the day with the acceptance stamps — have a session to enter on.

run.json

{
  "bundle_path": "pit-eval-sec-2022-11_2023-03",
  "arms": ["main"],
  "clocks": ["leaky", "dump", "pit"],
  "forms": ["8-K"],
  "start": "2023-03-01",
  "end": "2023-03-14",
  "bundle_format": "jsonl",
  "session_source": "partitions",
  "probe": false
}

Two settings are worth naming. bundle_format is pinned to jsonl because the format the run read goes into the receipt, so pinning it means a reader with pyarrow installed and a reader without it get the same hashes. session_source is partitions because you have no price file: the calendar becomes the days the corpus holds rows for, and the entry rule is unchanged.

pit-harness run 0.9s

$ pit-harness run --config run.json \
    --trajectory trajectory.jsonl --receipt receipt.json

run b04a6eb930ae9c0f  valid=True  receipt sha256:07502a39f9c197e77f117f16b9ae65220d5144c77af3281c9f849a0a7be7f9b6
trajectory sha256:659cd5d3a8eaccd49f5469062fbb46294de432b31971f30bf38367daa167112b  events 98
  main      leaky  total_return      n/a  trades    0  reachable=False
  main      dump   total_return      n/a  trades    0  reachable=True
  main      pit    total_return      n/a  trades    0  reachable=True
  protocol gaps: P1_model_version, P2_training_cutoffs

The agent that just ran is stub_agent, the default. It needs no model. It acts on every filing the window has just made knowable, reads no text and forms no view, so any difference between its cells comes from the clock. Use it to check the machinery. Replacing it with your own agent is one call to from_callable, which is what the later modules do.

total_return is n/a because the harness ships no market data and fetches none. You supply the bars, and module 5 is where that happens. The trajectory, the timing distribution and the receipt are written without them.

Step 3 — read what the clocks did

The trajectory is one JSON object per line, in the order things happened, with no wall clock anywhere in it. Each prompt line records how many rows were knowable at that session's bell.

sessions.py

import json
from collections import defaultdict

rows = defaultdict(dict)
for line in open("trajectory.jsonl"):
    event = json.loads(line)
    if event["kind"] == "prompt":
        rows[event["session_date"]][event["clock"]] = event["payload"]["knowable_rows"]

print(f"{'session':<12}{'leaky':>8}{'dump':>8}{'pit':>8}")
for session, seen in sorted(rows.items()):
    print(f"{session:<12}{seen['leaky']:>8}{seen['dump']:>8}{seen['pit']:>8}")

rows knowable at the bell 10 sessions

$ python3 sessions.py

session        leaky    dump     pit
2023-03-01       413       0       0
2023-03-02       760     413     413
2023-03-03      1014     760     760
2023-03-06      1322    1014    1014
2023-03-07      1627    1322    1322
2023-03-08      1866    1627    1627
2023-03-09      2234    1866    1866
2023-03-10      2617    2234    2308
2023-03-13      3059    2617    2617
2023-03-14      3357    3059    3059

On the first session the leaky column already holds 413 filings that nobody outside the filer had seen at 09:30. It stays exactly one session ahead for the whole window, which is the size of the error a date join introduces.

2023-03-10 is the one day in this release that carries acceptance receipts, and it is the only session where pit and dump disagree: 2,308 rows against 2,234. The 74 in between are filings EDGAR accepted before the opening bell that morning, which a day-end bound would make you wait another session for. Acting on them early is backed by evidence rather than by an assumption, because the receipt puts the filing in EDGAR's hands before the auction.

The receipt carries the same gap as an aggregate.

timing.py

import json

timing = json.load(open("receipt.json"))["body"]["metrics"]["timing_distribution"]
for field in ("events", "acceptance_stamped", "leaky_entries_before_acceptance",
              "mean_leaky_lookahead_hours", "max_leaky_lookahead_hours"):
    print(f"{field:<34}{timing[field]}")
ahead = {bucket["value"]: bucket["count"] for bucket in timing["sessions_pit_ahead_of_dump"]}
print(f"{'events pit gained a session on':<34}{ahead.get(1, 0)}")

metrics.timing_distribution 3,357 rows loaded

$ python3 timing.py

events                            3059
acceptance_stamped                383
leaky_entries_before_acceptance   309
mean_leaky_lookahead_hours        6.6616
max_leaky_lookahead_hours         8.0267
events pit gained a session on    74

383 of these 8-Ks carry an acceptance receipt. On 309 of them the leaky clock entered before that receipt existed, by a mean of 6.66 hours and at worst 8.03, hours during which the filing was not yet public and the position was already open.

Step 4 — verify the receipt

The receipt is one JSON file with a body and a hash over that body. emitted_at is the only wall-clock field and it sits outside the hash, so two runs of the same config over the same release produce byte-identical bodies.

pit-harness verify exit 0

$ pit-harness verify receipt.json \
    --trajectory trajectory.jsonl --bundle pit-eval-sec-2022-11_2023-03

body_sha256 ok  sha256:07502a39f9c197e77f117f16b9ae65220d5144c77af3281c9f849a0a7be7f9b6
run_valid True  protocol_complete False
trajectory ok  sha256:659cd5d3a8eaccd49f5469062fbb46294de432b31971f30bf38367daa167112b
bundle content_digest ok  sha256:bc924991d6ea64984dd533874014e83dac8a52b9bd442f08f89b60a5f055c15c

Three of those lines are recomputations: the body against its own hash, the trajectory file's bytes against the hash the receipt bound, and the release's digest against the one the run read. All three run on your machine against files you already hold, so the check does not depend on anything we serve.

Run the whole thing a second time into a fresh directory and both hashes come back identical. Object keys sort at every level, floats are fixed at six decimals, and nothing derived from the clock on the wall goes inside the body.

What verify recomputes. It confirms that the receipt describes this run. Whether the run counts is the run_valid field on the second line, and a run marked invalid still passes all three hash checks.

Step 5 — make the guard fire

While a run is active the harness replaces the socket entry points with ones that raise: INET socket creation, connect, create_connection, DNS resolution and TLS wrapping. The guard takes no allowlist and no override flag, so the way to watch it work is to hand it an agent that tries.

leaky_agent.py

"""An agent that reaches for the internet in the middle of a scored run."""

from urllib.request import urlopen

from pit_harness.adapters.plain import from_callable


def _look_it_up(prompt, tools):
    rows = tools.pit_list(limit=5)["rows"]
    urlopen("https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany")
    return [r["row_key"] for r in rows]


trader = from_callable(_look_it_up)

Copy run.json to run-leaky.json and add "on_egress": "continue", which records each attempt and carries on instead of stopping at the first one. Either setting marks the run invalid, and this one leaves you a receipt to read.

pit-harness run · guard armed exit 2

$ PYTHONPATH=. pit-harness run --config run-leaky.json \
    --agent leaky_agent:trader --trajectory leaky.jsonl --receipt leaky-receipt.json

run 14f160d394e01c21  valid=False  receipt sha256:d9f6d004c5188f50dab2ed9a52ae721f4442a2dea180e6dfcef6f4630591035e
trajectory sha256:c6acd2155c4494df0418ac6e8c6a593e3f54965ff215cc1f33bbb96c2f9380b3  events 128
  main      leaky  total_return      n/a  trades    0  reachable=False
  main      dump   total_return      n/a  trades    0  reachable=True
  main      pit    total_return      n/a  trades    0  reachable=True
  protocol gaps: P1_model_version, P2_training_cutoffs
  INVALID: network egress attempted while the guard was armed; the agent attempted network egress

The receipt's network block holds egress_attempts: 30 — one per decision point — with the call site of each, and invalidated_by names both the guard and the agent. The trajectory carries an egress_blocked line where each one happened.

The guard reaches one process. It stops a tool, an SDK or a stray urlopen inside the interpreter it is running in. It does not follow a subprocess your agent spawns, and it has no effect at all on a model that already holds March 2023 in its weights. Run the harness inside a network namespace to cover the first. The exercise below is about the second.

Exercise — close the protocol gaps

Every run so far has ended on the same two names: P1_model_version and P2_training_cutoffs. The receipt fills six protocol slots, and four of them it fills from the run itself. These two are declarations you make, and leaving them empty lists them in protocol_gaps rather than filling them with a guess.

Add a model block to run.json and declare them. Use the model you would actually run here — for the stub agent, declare the stub and your own environment's cutoff.

run-declared.json · the added block

  "model": {
    "name": "stub-agent",
    "version": "pit-harness-0.1.0",
    "provider": "none",
    "training_cutoff": "2026-05",
    "declared_by": "you@example.com"
  }

pit-harness run

$ pit-harness run --config run-declared.json \
    --trajectory declared.jsonl --receipt declared-receipt.json

run 21b2bf285801a658  valid=True  receipt sha256:ba408bbacabec74ae568bbacb63fd7c9390f6375704609f706e0e588ac907422
trajectory sha256:72aa246b1845410b29bbfa66c42088b420c3ecf2ead418a9fec249fb997e1a9a  events 98

The gaps line is gone. Read what filling them in produced.

body.protocol.P2_training_cutoffs

{
  "evaluation_window": {
    "end": "2023-03-31",
    "start": "2022-11-01"
  },
  "note": "window_ends_before_cutoff means the main arm is inside the model's training data and its score is not evidence of forecasting",
  "training_cutoff": "2026-05",
  "verdict": "window_ends_before_cutoff"
}

Declaring the model changed nothing about what the agent did. It surfaced a fact that was already true of the run: the window ends in March 2023 and the declared cutoff is May 2026, so the model had three years to read about this period before the run started. The guard closed the retrieval channel; this verdict describes the other one. Modules 3 and 5 measure it, with control arms and the recoverability probe.

Check your work

pit-harness verify protocol_complete True

$ pit-harness verify declared-receipt.json \
    --trajectory declared.jsonl --bundle pit-eval-sec-2022-11_2023-03

body_sha256 ok  sha256:ba408bbacabec74ae568bbacb63fd7c9390f6375704609f706e0e588ac907422
run_valid True  protocol_complete True
trajectory ok  sha256:72aa246b1845410b29bbfa66c42088b420c3ecf2ead418a9fec249fb997e1a9a
bundle content_digest ok  sha256:bc924991d6ea64984dd533874014e83dac8a52b9bd442f08f89b60a5f055c15c

Three oks, run_valid True and protocol_complete True, which is the end of the module.

Your body_sha256 will match the value above if your release carries content digest sha256:bc924991… and you used these filenames. A rebuild that adds filing text keeps the release name and changes the digest, and every hash on this page moves with it. The three oks hold either way, because each one recomputes a hash from files you have rather than comparing against a value we publish.

What this run does not prove

Claim it does not supportWhy
"The agent did not see the future." The guard covers one process. A subprocess and the model's own weights are outside it.
"The agent read the filings." This release carries no filing text. The title is a mechanical concatenation, so there is nothing to read.
"The clock is worth N basis points." No prices were loaded, so total_return is n/a. The timing gap is measured in hours and sessions here, not in money.
"The stub agent is a strategy." It acts on every new filing and reads none of them, so it holds the machinery steady while you look at it.

Where the lead artifact came from

The three-clock output at the top of this page runs against the same release, using the row key of SVB's receivership 8-K, accession 0001193125-23-067777.

boundary.py

from datetime import datetime

from pit_harness import Bundle, WindowWorld

SVB = "row_e0f932ec9387facd"        # SVB Financial Group 8-K, accession 0001193125-23-067777
BELL = datetime.fromisoformat("2023-03-10T14:30:00+00:00")   # 09:30 in New York, 10 March

bundle = Bundle.load("pit-eval-sec-2022-11_2023-03", arms=["main"], forms=["8-K"],
                     start="2023-03-01", end="2023-03-14", format="jsonl")

for clock in ("leaky", "dump", "pit"):
    tools = WindowWorld(bundle.rows("main"), clock=clock).toolbox(BELL, session_date="2023-03-10")
    answer = tools.pit_read(SVB)
    if "row" in answer:
        print(f"{clock:<6} served  {answer['row']['title']}  acceptance_at={answer['row']['acceptance_at']}")
    else:
        print(f"{clock:<6} {answer['error']}  {answer['detail']}")

Ask the pit world again at the next session's bell, 2023-03-13T13:30:00+00:00, and the row comes back with its accession, its acceptance stamp and text: ''. The bell moved an hour in UTC between those two dates because US daylight saving started on 12 March, which the calendar resolves through America/New_York rather than a fixed offset.

Next

Module 2 covers the five timestamp fields behind these three policies, which three of them a query can be bounded by, and what the API does when a day has no certificate. It has no date yet. Timestamps and coverage are the reference pages under it, and both are written and live today.