Skip to content

feat(trajectories): canonical trace IR with ACP and ATIF converters - #984

Open
Galius5136 wants to merge 25 commits into
benchflow-ai:mainfrom
Galius5136:feat/trace-interop
Open

feat(trajectories): canonical trace IR with ACP and ATIF converters#984
Galius5136 wants to merge 25 commits into
benchflow-ai:mainfrom
Galius5136:feat/trace-interop

Conversation

@Galius5136

@Galius5136 Galius5136 commented Aug 13, 2026

Copy link
Copy Markdown

Trace interoperability: OTel ↔ ATIF ↔ ACP

Draft. This PR tracks the whole trace-interoperability task and lands it in
reviewable slices. Ten are included. OpenTelemetry is read but not written:
the inbound edge is implemented, and the emitter is deferred rather than
rejected — §8.12 of docs/trace-interop.md records why, and records it as our
own decision.

One opt-in wiring site, and nothing else reaches a run path. Everything
below is inert unless BENCHFLOW_VIEWER_TRACE_IR=1 is set: render_rollout
gains a single branch with a lazy import inside it, and with the switch off the
trajectory viewer emits the bytes it emitted before — verified on six pages,
same SHA-256 before and after. export_atif.py is untouched and still the only
writer of trainer/atif.json, no artifact changes, and a test names the one
file allowed to import the IR family so a second importer fails the suite.
Reverting the wiring commit unwires it completely.

Goal

OTel ↔ ATIF ↔ ACP trace interoperability.

What is included

A ACP-session capture events, formalized JSON Schema, docs, conformance suite
A2 ACP → ATIF preservation invariants what today's export keeps and drops, as tests
B Canonical Trace IR v0 typed, versioned, with an explicit loss-reporting contract
C ACP → IR the inbound edge, every absence declared
D IR → ATIF the outbound edge — byte-for-byte parity with the direct exporter
E ATIF → IR + round-trip measurement closes the loop, and measures what it costs
F OTLP/JSON → IR the inbound OTel edge, built against the versions uv.lock pins
G IR → ACP capture events the outbound ACP edge — fail-closed, and human-verified on two real rollouts
H IR → viewer trace steps the step list a viewer page renders; a tool category by membership, never inferred from a string
I viewer trace steps → a rendered page built against viewer.py as it stands on main, opt-in, verified in a browser

Each slice has its own comment on this PR with the details and the verification.

Why an IR at all

From reconnaissance of the current tree:

  • ACP-session capture events had no schema. trajectory/acp_trajectory.jsonl
    is read by the viewer, judges, the skill evaluator, task verifiers and the
    review skill, with no version field and no written specification. Each consumer
    parses it independently.
  • ATIF is export-side only. Nothing under src/ reads trainer/atif.json
    back; its shape is pinned by prose in a module docstring.
  • OpenTelemetry is not an active representation. No dependency, no OTLP, no
    gen_ai.* handling. The one module that existed was removed as unwired.
  • Three exporters already walk the same event list through shared helpers,
    each with its own private answer to the same questions — what happens to a tool
    call with no arguments, whether a thought boundary survives, whether a timeout
    is representable. Those answers already diverge (docs/trace-interop.md §5).

A hub makes each format one edge to a written contract, and makes the
information loss a value rather than a comment.

What the round trip measures

Slice D shows the hub reproduces the direct exporter byte for byte, which says
the IR is sufficient for ATIF. Slice E asks the more useful question — how much
of a trace is still there after a trip through the format — and answers it as a
measurement over the two real rollouts in docs/trace-interop.md §5.2:

  • Within the declared IR ↔ ATIF field mapping, nothing representable is lost
    on the two measured rollouts.
    Everything the loop drops is dropped because
    ATIF has nowhere to put it, so the remaining loss belongs to the format rather
    than to these converters. That is a result and not a definition: on a trace
    populating every IR field the same measurement reports two representable
    losses, which is a gap in our own edge rather than a cost of the format.
  • The trace comes back with more values than it left with — 46 in, 56 out for
    one rollout — while having lost information. arguments leaves declared
    SYNTHESIZED and returns unmarked, so the reconstructed trace asserts the tool
    was observed to be called with none. The information was not so much lost as
    overwritten with a plausible value of the same shape, and only the pair of loss
    reports still carries the difference.
  • A timeout costs five fields and the event carrying them — the timeout
    reason, the run's own outcome status, and the three fields the marker carried.
    §5 loss Readme suggestion #4, measured instead of asserted.

That last point is the argument for the hub in the one form an assertion cannot
take: it quantifies what a format costs instead of claiming it.

Full tables are in docs/trace-interop.md §8.10. These are machine measurements
over real captured rollouts; verification status for each slice is documented
separately in its PR comment.

Roadmap

  • Slice A — formalize the existing ACP-session capture events
  • Slice A2ACP → ATIF preservation and loss invariants
  • Slice B — canonical IR v0: typed representation, loss contract, invariants
  • Slice CACP-session capture events → IR
  • Slice DIR → ATIF, with parity against the direct exporter
  • Slice EATIF → IR, and the ACP → IR → ATIF → IR′ measurement
  • Slice F — OpenTelemetry, inboundOTLP/JSON → IR, built against the
    opentelemetry-proto / opentelemetry-semantic-conventions versions
    uv.lock already pins, with no OTel dependency added
  • Slice G — Canonical Trace IR → ACP capture eventsimplemented and
    HUMAN VERIFIED
    (G1–G10). The target is the ACP capture event format
    of §2.1, not the whole acp_trajectory.jsonl artifact, which has no
    contract and holds records from four producers. ACP → IR → ACP
    reproduces its input — structural equality and key order — on the two real
    captured rollouts of §5.2. The edge is fail-closed: when the target
    requires semantics the trace does not carry it raises rather than emitting
    a partial trajectory. name_semantics gates the kind slot, so an ATIF
    function_name or an OTel gen_ai.tool.name cannot be laundered into an
    ACP kind — a category tag — even when the value happens to spell one.
    ORACLE and UNKNOWN have no record shape in the contract and are
    refused. No runtime wiring, and no file is written. OTel → Viewer is
    not supported
    : an OTel trace is still four independent kinds of refusal
    away from exporting
  • IR → OTeldeferred, see §8.12: no outbound OTel consumer and no
    contract to build an emitter against exist yet. A decision of ours, current
    and reversible; open question 2 below is a separate matter and still open
  • Loss-bounded conformance gate over the measured round trip —
    implemented and HUMAN VERIFIED (H1–H8), docs/trace-interop.md §8.13.
    An independent join between the divergence the round trip observes
    and the loss each converter declares: the measurement still reads only
    the two traces, the converters still produce their own reports, and the
    gate is a third module that relates them. An undeclared fabrication is
    always a violation
    — no allowlist, including for the exporter's own
    structural metadata, which IR → ATIF now declares like anything else.
    The rule asks for SYNTHESIZED specifically, not merely for some
    record at the path
    : a NORMALIZED declaration does not excuse an
    invented value, since there is no source value being reshaped. Where a
    value is honestly declared in TARGET space and observed at a hub path,
    a small target→hub bridge relates the two; it is empirically
    checked against real values and is not an exemption
    — remove the
    declaration and the gate fails anyway. Divergence caused by events being
    fused away is explained per value, not per path, so an edit to an
    event that survived the trip is still caught. No runtime wiring. No
    OTel round trip
    , because IR → OTel remains deferred (§8.12) and a
    gate cannot measure a loop that does not exist
  • Slice H — Canonical Trace IR → viewer trace steps — implemented and
    HUMAN VERIFIED, docs/trace-interop.md §8.14. Steps only, deliberately
    not a payload: four of a viewer payload's five fields are functions of the
    rollout directory rather than of the trace. A tool's display category is
    emitted only when the source said the string is a category and the
    value is already a member of the display vocabulary — membership, never
    inference — so an ATIF function_name of execute and an OTel
    gen_ai.tool.name of read_file stay neutral. Every event becomes
    exactly one step: ORACLE and UNKNOWN land on a diagnostic step
    carrying a serialization of the canonical IR event, declared as such
    because the IR holds no source record
  • Slice I — viewer trace steps → the page the current viewer renders
    implemented and HUMAN VERIFIED in a browser, §8.15. Built against
    viewer.py as it stands on main, not against the reviewer-grade viewer
    proposed in feat(viewer): reviewer-grade trajectory pages, multi-run browsing, and hf:// dataset sources #1034: that branch is a design reference for the step
    vocabulary and nothing else — no code is imported, vendored or fetched
    from it — so this works whether or not it lands. A pure refactor first
    (_page, _prompt_block, _message_block, _thought_block,
    _result_block lifted out of _render_acp_events, byte-identical
    output), then an adapter that renders a step list through those cards,
    then one opt-in branch. Steps are never rebuilt into ACP capture
    events
    : the IR holds records ACP has no type for, and forging them would
    launder back the assumption the hub removes. Measured against the current
    page on the two captured rollouts: an agent_timeout reaches no card
    there and the word "timeout" appears nowhere, an unrecognized record
    reaches no card, and a tool card carries kind/title/status but not the
    tool's output — all three are that renderer's four branches rather than
    bugs, and this edge places its six step kinds and declares the difference
  • Wiring + human E2E — done for the viewer path, opt-in and reversible

Scope / open questions

The canonical IR and the OpenTelemetry direction are still not approved
decisions.
Everything above is built so it can be reviewed as code and deleted
in one commit if the answer is no. The questions, in the order they block work:

  1. Canonical IR direction — sanity check. Is an N ↔ 1 intermediate
    representation the shape you want, or would you rather see direct converters?
    Three exporters walking the same event list through shared helpers is what
    suggested it, but that is an inference, not a mandate.
  2. OpenTelemetry: ingest, emit, or both? The removed collector was inbound
    only, and it was removed deliberately. Reviving a receiver and emitting spans
    are different projects with different risk. This is the one blocking the
    next slice.
  3. Capture-layer enrichment: in scope or follow-up? The ACP protocol carries
    rawInput, rawOutput and locations on tool calls; handle_update reads
    five fields and drops the rest, which is why every exported format emits empty
    tool arguments. Per-event timestamps go the same way. Closing those gaps
    changes an on-disk format several consumers already parse, so it is a
    compatibility decision rather than a converter one.
  4. Artifact-level acp_trajectory.jsonl contract. Should the file carry a
    schema_version? And are the oracle record and the session-factory
    passthrough part of the trajectory contract, or separate concerns that happen
    to share a filename?

Happy to split this into separate PRs per slice if that reviews better.

`trajectory/acp_trajectory.jsonl` is produced by the ACP-session capture
path and read by the viewer, the judges, the skill evaluator, task verifiers
and the review skill — but it had no schema, no version and no written
specification. Every consumer hand-rolls its own parser.

This writes the format down and makes it machine-checkable, with zero
runtime behaviour change:

- `src/benchflow/trajectories/schemas/acp-capture-event-v1.schema.json` —
  Draft 2020-12 schema for one line of the artifact, scoped deliberately to
  the ACP-session capture events (`_events_to_trajectory` plus the
  `ACPSession` legacy fallback). `kind` stays an open string because
  `_canonical_tool_kind` passes agent-supplied values through unchanged;
  `status` is a closed enum because the serialized value is always
  `ToolCallStatus(...).value`. `additionalProperties: false` on every event
  variant, so a field the emitter does not produce today fails the suite.

- `docs/trace-interop.md` — the format as emitted, per-event required and
  optional fields, producers and consumers, the current state of ATIF and
  OpenTelemetry, and a table of the information losses observable in the
  existing ACP -> ATIF / ADP conversions. Claims are marked FACT or
  PROPOSAL; nothing marked PROPOSAL is agreed or implemented.

- `tests/trajectories/test_acp_capture_event_schema.py` — conformance suite
  over two corpora: records generated by driving real `ACPSession` objects
  through the production capture path and writer, and the ACP event lists
  already used as exporter inputs by the ATIF and ADP tests. Ten cases pin
  what the schema must reject. The event-type vocabulary is read out of
  `_events_to_trajectory` by AST rather than hardcoded, so adding a branch
  to that function fails the suite until the schema documents it.

- `jsonschema` declared as a dev dependency. It was already resolved as a
  litellm transitive; declaring it keeps the test suite off another
  package's dependency tree. `uv.lock` gains the two corresponding lines.

Scope is narrower than the artifact and the schema says so: oracle-mode
rollouts replace the trajectory with an oracle-only list, and
session-factory Sessions bypass the ACP-session emitter entirely via
`_snapshot_session_trajectory`. Neither is modelled here. No artifact-level
contract is defined or implied.

No runtime module imports the schema. Nothing under `src/benchflow` changes
behaviour.

Verified: 25 new tests; tests/trajectories 239 passed; ACP regression
(capture, streaming, acp, native usage) 147 passed; ruff check/format clean;
`uv lock --check` and `uv sync --locked --extra dev` both pass.
@Galius5136

Copy link
Copy Markdown
Author

Slice A complete

Formalizes the ACP-session capture-event subset written to
acp_trajectory.jsonl. Zero runtime behaviour change: no runtime module imports
the schema, and nothing under src/benchflow changes behaviour.

What landed

  • src/benchflow/trajectories/schemas/acp-capture-event-v1.schema.json
    Draft 2020-12, one event per document, three record shapes across five event
    types.
  • docs/trace-interop.md — the format as emitted, producers and consumers, the
    state of ATIF and OpenTelemetry, and the information losses observable in the
    existing conversions. Claims are marked FACT or PROPOSAL.
  • tests/trajectories/test_acp_capture_event_schema.py — 25 tests. The
    event-type vocabulary is read out of _events_to_trajectory by AST rather
    than hardcoded, so adding a branch there fails the suite until the schema
    documents it.
  • jsonschema declared as a dev dependency (previously used only as a litellm
    transitive).

Design choices worth flagging for review

  • kind is an open string, not an enum. _canonical_tool_kind passes
    agent-supplied values through unchanged, and values outside ToolKind reach
    disk in practice — including the literal tool, the fallback when a
    tool_call_update arrives for an id that was never opened. Constraining it
    would reject data the emitter can produce.
  • status is a closed enum: the serialized value is always
    ToolCallStatus(...).value, with an in_progress fallback for anything
    unparseable, so no out-of-vocabulary value can reach disk.
  • additionalProperties: false on every variant. This is what makes the schema
    a description rather than a formality: a field the emitter does not produce
    today fails the suite, so a future capture-layer change cannot land without
    updating the schema and the docs in the same PR.

Human verification (run by @Galius5136, not by CI)

  • H1 — positive path. A real ACPSession driven through the production
    capture path and writer; every line re-read from disk and validated. All five
    event types present and valid. PASS.
  • H2 — oracle boundary. An oracle-only artifact written through the
    production writer at the production artifact path, using the record shape
    derived from _run_oracle by AST. The record is rejected by the schema, which
    is the expected result. PASS. This did not execute _run_oracle against a
    live sandbox — it verified the producer-derived shape, the production writer
    and the schema boundary.
  • H3 — in-scope negative probe. A tool_call really emitted by the capture
    path validates; the same record re-read from disk, with a single rawInput
    field added, is rejected via additionalProperties. PASS.

What this surfaced

Preparing H2 exposed a real boundary that the first draft of the documentation
got wrong. Oracle mode does not append a record to an ACP trajectory: the
rollout does not run an agent at all, _run_oracle builds a new oracle-only
list, and that list becomes the rollout's trajectory. ACP rollouts and oracle
rollouts are mutually exclusive, and no production path emits a mixed artifact.
Separately, session-factory Sessions bypass the ACP-session emitter entirely via
_snapshot_session_trajectory, which returns session.steps unchanged.

So the same artifact path can hold different record families depending on how
the rollout ran. That is why this schema is scoped to the ACP-session capture
events and does not claim to be an artifact-level contract — and why the
artifact-level questions are listed as open in the PR description rather than
answered here.

Not included, deliberately

No canonical IR, no OTel work, no ATIF reader, and no capture-layer enrichment.
No fixes for the conversion losses/divergences surfaced during reconnaissance;
those remain documented and out of scope for this slice.

Picks up d30527b (fix(acp): retain live subprocess stderr, benchflow-ai#980). No conflicts:
that commit touches acp/container_transport.py, sandbox/process/* and their
tests, none of which Slice A or the trajectories export path touches.
test_export_atif.py pins the shape ATIF export produces. This pins which
information survives the conversion and which does not, so a future change to
the converter either keeps the property or fails a test that says in one line
what changed.

Adds tests/trajectories/test_atif_preservation.py: 10 preservation invariants,
12 loss characterizations, 2 producer-boundary tests. No runtime module and no
public format is touched, and no dependency is added.

Two choices make the suite falsifiable rather than decorative. Losses are
asserted with sentinel values absent from the whole serialized document, not
with structural checks on the one field a test happened to look at. And both
sides of the oracle-source divergence are read from source via AST -- the
converter's event-type branches and the validator's accepted `source` set -- so
changing either fails a test instead of silently invalidating the premise.

The load-bearing producer-boundary test drives a real ACPSession with a
tool_call update carrying rawInput, rawOutput, locations and _meta and shows
that those fields are absent before the ACP -> ATIF converter runs. Real
rollout evidence independently shows non-empty tool arguments in provider
capture for calls whose ATIF arguments are {}. Together, these observations
place the loss upstream of the ATIF converter rather than inside it.

docs/trace-interop.md gains a per-field table (5.1), one previously unrecorded
loss -- the agent_thought join is irreversible, so one thought containing a
blank line is indistinguishable from two events -- and a section recording what
two real gemini rollouts showed (5.2), including that the proxy capture holds
the tool arguments the ATIF document records as {}.
@Galius5136

Copy link
Copy Markdown
Author

Slice A2 — ACP → ATIF preservation/loss characterization

Turns the ATIF rows of the §5 loss table into executable assertions. 24 tests, one new file, no runtime change, no public format change, no new dependency — same profile as Slice A.

Deliberately not Slice B. Slice B in the roadmap above is the canonical-IR prototype, and it is gated on open question 1. This work is a sibling of A: it holds whichever way that question is answered, and it gives a baseline of what the existing converter preserves before anything is redesigned.

  • 10 preservation invariants — identity, ordering, referential integrity of source_call_id within its own step, textual output, thought text, and conformance to the ATIF validator this repository already ships;
  • 12 loss characterizations, each pinned with sentinels asserted absent from the whole serialized document rather than from the one field a test happened to look at;
  • 2 producer-boundary tests placing the empty arguments at the ACP wire boundary rather than in the converter.

Both sides of the oracle-source divergence are read from source via AST — the converter's event-type branches and the validator's accepted source set — so changing either fails a test instead of silently invalidating the suite's premise.

One previously unrecorded loss

Documented as #10: ThoughtBuffer joins buffered thoughts with a blank line, so a single thought that already contains one is indistinguishable from two consecutive events, and the thought-event count is unrecoverable. Reachable in production — _parse_gemini_trajectory appends one event per entry of a message's thoughts list.

What real rollouts showed

§5.2 records two gemini rollouts run through the production path (docker sandbox, ACP transport, the standard artifact writers), inspected by hand:

  • H1, real tool use. Every tool_call capture record carried exactly type, tool_call_id, kind, title, status, content. Ids reached ATIF unchanged, textual output reached observation, kind became function_name (observed execute, read, think — none of them ToolKind members), title/status appeared only in extra, every arguments was {}, and no ISO-8601 value appeared anywhere.
  • H2, real wall-clock timeout. An agent_timeout recorded in acp_trajectory.jsonl and counted in result.json under trajectory_summary.event_type_counts is absent from the ATIF document for the same rollout. Loss Readme suggestion #4 confirmed end to end, including that the signal does survive at rollout level.

The observation worth calling out: in those rollouts the proxy capture (llm_trajectory.jsonl) carries non-empty tool-call arguments — in one case keyed command, holding the shell command the agent ran — for the same call whose ATIF arguments is {}. The inputs were dropped, not unavailable.

This is not evidence that the ACP rawInput family was on the wire. Those four fields occurred nowhere in the captured artifacts, the proxy capture included, which is expected because that capture is not ACP.

Also observed and now documented: an ATIF document opens with two identical user steps — one from the prompts argument, one from the captured user_message event — so a consumer counting user turns over-counts by one.

Not exercised by a real rollout

Still resting on code reading plus the synthetic tests, and labelled as such in the document: the non-text content-block loss (#5), because neither agent emitted a file-edit or terminal block; and the oracle source divergence, because neither rollout ran in oracle mode.

Still a draft — Slices B–F remain a proposal pending the open questions above.

Four trace-shaped representations already exist here — the ACP-session capture
events, ATIF, ADP and the Verifiers/ORS record — and all three exporters walk
the same ACP event list from the same call site. The cost of that is not the
edge count; it is that each edge answers the same questions privately, and the
answers already diverge: for one `tool_call` event ATIF emits `"arguments": {}`
while ADP emits `"kwargs": {}`, ATIF keeps the tool status in a non-standard
`extra` while ADP drops it, both join thought boundaries irreversibly, and
neither represents `agent_timeout` at all.

This adds a canonical hub so each format becomes one edge against a written
contract, and so the information loss is a typed value rather than a comment in
a module docstring.

The direction is not approved. The four open questions in docs/trace-interop.md
§6 have had no maintainer answer, so this takes a provisional position on the
first one and implements it in isolation, where it can be reviewed as code and
reverted by deleting two files.

- `src/benchflow/trajectories/ir.py` — the IR types, the loss model
  (`LossReport` / `LossRecord` over an unsupported/dropped/normalized/
  synthesized taxonomy, the same one §5.1 already uses), and `validate_trace`,
  which returns one string per invariant violation rather than raising.

  The rule the module is built on is that the IR is a pragmatic superset of
  what BenchFlow can observe, not a model of what an agent trace could
  contain. Three consequences are load-bearing: optional values are tri-state
  (a value / `None` "this source never carried it" / an empty value "carried
  and empty"); a `None` that is not covered by a loss record makes the trace
  invalid, so absence is declared and never silent; and normalization is
  non-destructive — `source_type` keeps the source's own type string next to
  the normalized `kind`, and `name_semantics` records that an ACP `kind` is a
  category rather than a function name.

  The IR deliberately cannot fabricate an agent version, a synthetic tool-call
  id, a timestamp, or an OTel span id. Those are target-side obligations and
  belong in converters, which record them as SYNTHESIZED.

- `tests/trajectories/test_trace_ir.py` — 25 tests. Each invariant is exercised
  with a violating trace and a clean one, so a rule that stopped firing fails
  here instead of passing silently. The IR's tool-status vocabulary is checked
  against the ACP `ToolCallStatus` enum and its event kinds against the
  vocabulary `_events_to_trajectory` actually emits, read from source by AST —
  the mechanism the Slice A conformance suite already uses, so adding a branch
  to the capture path fails this suite until the IR accounts for it.

  Two tests pin the isolation claim rather than the design: no module under
  `src/benchflow` imports the IR, and the IR imports no benchflow module. The
  first is the executable form of "zero runtime behaviour change"; wiring the
  IR into a run path has to update it deliberately.

- `docs/trace-interop.md` §8 — replaces the "ideas not yet agreed" placeholder
  with the design: why a hub, the four alternatives considered and why each was
  not taken, the field classes (supported today / optional / needs enrichment /
  must not be invented), the planned ACP→IR and IR→ATIF mappings, an OTel
  sketch marked unverified because no version of the GenAI conventions is
  vendored here, the invariants, and an explicit list of what a review can
  still reject. §6 gains a note that question 1 now has a provisional, unagreed
  position and that questions 2-5 are untouched.

  The worked example in §8.4 is generated from the models and compared against
  the document by a test, so it cannot drift.

Not included, deliberately: `ACP → IR`, `ATIF ↔ IR`, any OTel work, any wiring
into a run path, any on-disk artifact. The representation is meant to be
reviewed before anything depends on it.

No existing format, exporter, artifact or code path changes. No new dependency
— pydantic is already a runtime dependency and `uv.lock` is untouched.

Verified: 25 new tests; tests/trajectories 288 passed; the trajectory, capture,
streaming and ACP regression lane 453 passed; `ruff check .` and
`ruff format --check` clean; `ty check src/` clean.
@Galius5136

Copy link
Copy Markdown
Author

Slice B — provisional canonical Trace IR

The four open questions in the PR description are still unanswered. Rather than
leave the task parked, this slice takes a provisional position on question 1
— a canonical hub — and implements it in a form that can be reviewed as code and
undone by deleting two files. The position is mine, not an agreed direction, and
the PR stays Draft for that reason.

Feedback on the architecture is still very welcome, and it is still cheap to
act on:
nothing depends on this module, so redirecting it costs a revert
rather than a migration.

Why a hub rather than direct converters

Four trace-shaped representations already exist here — the ACP-session capture
events, ATIF, ADP and the Verifiers/ORS record — and all three exporters walk
the same ACP event list from the same call site, _write_trainer_artifact. The
cost of pairwise conversion is not the edge count. It is that each edge answers
the same questions privately, and the answers already diverge:

  • for one tool_call event, ATIF emits "arguments": {} and ADP emits
    "kwargs": {};
  • ATIF keeps the tool status in a non-standard extra; ADP drops it;
  • both join agent_thought boundaries irreversibly through the same
    ThoughtBuffer;
  • neither represents agent_timeout at all.

Three independent decisions about the same event, taken three times, recorded
nowhere. A hub makes each format one edge against a written contract, and makes
the loss a typed value instead of a comment in a module docstring.

The alternatives I considered and did not take — promoting ATIF or the ACP
capture events to hub status, or extending the capture format instead — are
written up with their reasons in docs/trace-interop.md §8.1.

What is in the slice

src/benchflow/trajectories/ir.py, tests/trajectories/test_trace_ir.py (25
tests), and docs/trace-interop.md §8. No existing format, exporter, artifact
or code path changes, and no new dependency — pydantic is already a runtime
dependency, so uv.lock is untouched.

The rule the module is built on: the IR is a pragmatic superset of what
BenchFlow can observe, not a model of what an agent trace could contain.
Three
consequences do most of the work:

  1. Tri-state optionality. A value, None ("this source never carried it"),
    and an empty value ("carried, and empty") are three different facts.
    arguments={} vs arguments=None is the case that matters: every ACP-derived
    tool call is the second, and ATIF and ADP both serialize the first — which is
    why their documents read as though every tool was called with no arguments.
  2. Absence must be declared. A None not covered by a LossRecord makes the
    trace invalid. That is what turns the loss report into a contract instead of
    documentation.
  3. Normalization is never destructive. source_type keeps the source's own
    type string next to the normalized kind, and name_semantics records that
    an ACP kind is a category rather than a function name — the normalization
    the exporters currently perform silently.

The IR deliberately cannot fabricate an agent version, a synthetic
call_{n} id, a timestamp, or an OTel span id. Those are target-side
obligations; converters produce them and record them as SYNTHESIZED.

Two tests pin the isolation claim rather than the design: nothing under
src/benchflow imports the IR, and the IR imports no benchflow module. The
first is the executable form of "zero runtime behaviour change" — wiring the IR
into a run path has to update that test deliberately.

The event-kind and tool-status vocabularies are checked against the real
producer: ToolCallStatus is read off the enum, and the event types are read out
of _events_to_trajectory by AST, the same mechanism Slice A uses. Adding a
branch to the capture path fails this suite until the IR says what that event
becomes.

Deliberately not included

ACP → IR and ATIF ↔ IR converters, any OTel work, any wiring into a run
path, any on-disk artifact. I would rather the representation were looked at
before anything depends on it. The OTel mapping in §8.3 is a sketch and is
labelled unverified: no version of the GenAI semantic conventions is vendored
here and nothing validates against it.

What a review can still change

Everything, and cheaply. §8.6 lists it explicitly — the hub itself, the
declared-absence contract (the strongest opinion in here, and the one most
likely to feel heavy in a converter), name_semantics and reasoning_segments,
extensions as the escape hatch, TraceUsage.source (which exists only because
open question 4 is open), the version string, and every name in the module.

What a review cannot change by rejecting the IR: the losses in §5 are properties
of the current code, not of this proposal, and they stay whatever happens here.

Verification

25 new tests; tests/trajectories 288 passed; the trajectory, capture, streaming
and ACP regression lane 453 passed; ruff check ., ruff format --check and
ty check src/ clean. These are machine checks — no rollout was run for this
slice, and none is needed: nothing here executes in a run path.

…nwired

Slice B proposed a hub and a contract: every `None` in the IR is covered by a
`LossRecord`, and a conversion's cost is a typed value rather than a comment in
a docstring. This is the first real edge, and its job is as much to stress that
contract as to convert.

`acp_events_to_ir` reads the event list of `trajectory/acp_trajectory.jsonl` —
the ACP-session capture vocabulary Slice A pinned, plus the oracle and unknown
record families that share the file — and returns one `CanonicalTrace` carrying
its own report. It reads no other artifact: `result.json`, `timing.json` and the
proxy capture are separate, so a value that lives only there is a declared loss
rather than a silent enrichment.

What the conversion keeps that the existing exporters lose:

- `agent_timeout` becomes an event with its fields in `extensions`, and sets the
  trace outcome. Every exporter drops it today (§5 loss benchflow-ai#4).
- Non-text content blocks are carried as `opaque` with the block verbatim,
  instead of being skipped by `content_blocks_to_text` (loss benchflow-ai#5).
- Thought boundaries survive: one capture record is one reasoning segment, and
  nothing is joined, so the ambiguity `ThoughtBuffer` creates never arises
  (loss benchflow-ai#10). A thought whose own text contains a blank line stays one segment,
  because splitting it would invent a boundary the source does not have.
- `""` and absent stay distinguishable everywhere; text-empty events are kept
  rather than dropped.
- The `oracle` record keeps its own kind and role instead of becoming an agent
  step prefixed `[oracle: …]` that a consumer can only undo by string matching.
- An unrecognized `type` becomes `unknown` with the record carried verbatim,
  instead of being skipped silently.

What it refuses to invent: arguments, timestamps, tool-call ids, agent version.
It also does not prepend the `prompts` argument as leading user events, which
both existing exporters do — §5.2 showed the cost, an ATIF document that opens
with two identical `user` steps so user turns over-count by one. Those steps are
not ACP events; a target that wants them adds them at its own edge as
SYNTHESIZED.

Loss addressing distinguishes three shapes. `events[i].…` is a field of one IR
event, and is what `validate_trace` matches, so the per-call `arguments` records
use it. `source[i]` is an input entry that produced no IR event, which cannot be
addressed as `events[i]` because that index belongs to a different event once an
entry is skipped. Systemic losses — timestamps, per-event usage, agent version,
stop reason — are declared once each under an unindexed `events[].…` path.

That last choice is what makes the contract affordable, and it is measured
rather than asserted: the report is `n_tool_calls + 5` records and does not grow
with trace length. On the two real gemini rollouts of §5.2 it is 7 records (H1,
2 tool calls) and 6 (H2, 1 tool call and a real wall-clock timeout).

- `tests/trajectories/test_ir_from_acp.py` — 35 tests. Preservation is checked
  against events produced by driving a real `ACPSession` through the production
  capture path, reusing the Slice A2 fixture rather than hand-written dicts that
  would only prove the converter agrees with itself. The report is asserted as a
  complete set, so an undeclared loss and a spurious one both fail. One test
  removes a declared loss from a converted trace and shows the trace becomes
  invalid — the Slice B contract, demonstrated end to end. Two tests pin the
  volume property, including that doubling the non-tool events does not change
  the report at all. The block classifier is pinned against
  `content_blocks_to_text` so the IR and every existing consumer cannot disagree
  about what counts as text output.

- `tests/trajectories/test_trace_ir.py` — the isolation test is restated at the
  boundary that now matters. Slice B asserted that nothing under `src/benchflow`
  imports the IR; the converter necessarily does, so `ir.py` and
  `ir_from_acp.py` are declared a closed family and the test asserts nothing
  outside it imports either. The guarantee is unchanged in substance and the
  test is strictly stronger: a new converter has to join the family explicitly,
  and a stale name in that list now fails a test of its own.

- `docs/trace-interop.md` — §8.3 gains the implemented mapping table with a
  class per row and the measured loss counts; §8.7 records what is now
  implemented and restates the isolation property; §8.8 records what writing the
  first converter showed about the declared-absence rule, including the part of
  it most likely to be revised in review.

No runtime module imports either module, no capture path or exporter changes, no
on-disk format changes, and no new dependency.

Verified: 35 new tests; tests/trajectories 324 passed; the trajectory, capture,
streaming and ACP regression lane 489 passed; `ruff check .`, `ruff format
--check` and `ty check src/` clean. Two real rollouts converted and
cross-checked against their source files by hand.
Found by reading a converted real rollout by hand, not by the test suite, which
was green: §8.4 published its worked example with `exclude_none=True`, so
`arguments` was absent from the document while the loss report kept a record
addressing `events[1].tool_call.arguments`. The declaration that legalizes the
absence pointed at a key no reader of that document could find.

The suite could not see it because both sides of the comparison used the same
non-canonical encoding, so it was self-consistent.

`None` in this IR is a positive statement — the source did not carry this field
— and every one of them is paired with a `LossRecord` that addresses the field
by path. Dropping the key makes the address dangle and collapses "we looked and
it was not there" into "this version has no such field". Both encodings
re-validate to an equal pydantic model, so the model layer cannot enforce this;
the rule is stated in the `ir.py` docstring and enforced by tests.

- `ir.py` — declares the canonical encoding: nulls retained,
  `exclude_none=True` is not a valid encoding of a Trace IR document. No
  serializer is added: there is no on-disk artifact yet, and a writer would
  anticipate an interface this proposal has not earned. Adds the corollary that
  a record names the outermost absent node, so a conversion with no usage
  declares `usage`, not `usage.input_tokens`.

- `CanonicalTrace.outcome` is no longer optional. Applying the new guard
  immediately found a second instance of the same class: `outcome.stop_reason`
  is a loss every ACP conversion declares, and it could not resolve in any trace
  that did not time out, because the section itself was null. It is now always
  present with `None` fields, like `agent` — which is exactly why
  `agent.agent_version` resolved and `outcome.stop_reason` did not.

- `docs/trace-interop.md` — §8.2 gains the encoding rule as a fourth design
  choice, §8.4 is regenerated in the canonical encoding so `arguments: null` and
  its loss record are visible in the same document, §8.5 records the new
  test-pinned property, and §8.8 records what the human end-to-end pass showed,
  including the limit below.

- Tests — `test_every_concrete_loss_path_resolves_in_the_canonical_encoding`
  (IR) and `test_the_canonical_document_shows_null_arguments_beside_their_loss_record`
  plus `test_every_concrete_loss_path_of_a_converted_trace_resolves` (converter).
  Each asserts in the same test that the discarded encoding *fails* to resolve
  those paths, so none of them can pass for both encodings at once.

Known limit, recorded rather than fixed: the invariant forces a converter to
declare an absence, it cannot stop one from writing `arguments: {}` instead of
`null`. Such a trace is valid. Closing that would mean the IR taking a position
on what an empty map means for each source, which the tri-state rule
deliberately leaves to the converter.

Verified: tests/trajectories 328 passed; the trajectory, capture, streaming and
ACP regression lane 493 passed; `ruff check .`, `ruff format --check` and
`ty check src/` clean. Human end-to-end H1-H4 run against real captured
rollouts, all PASS.
@Galius5136

Galius5136 commented Aug 15, 2026

Copy link
Copy Markdown
Author

Slice C — ACP → IR, the first converter

Slice B proposed a hub and a contract: every None in the IR is covered by a
LossRecord, and a conversion's cost is a typed value rather than a comment in
a docstring. This slice is the first real edge, and its job was as much to
stress that contract as to convert.

Still unwired, still Draft, still provisional. ir.py and ir_from_acp.py form
a closed family — a test asserts nothing else under src/benchflow imports
either, and the converter imports one benchflow module, the IR.

What the conversion keeps that the existing exporters lose

  • agent_timeout becomes an event with its fields in extensions and sets
    the trace outcome. Every exporter drops it today (§5 loss 4).
  • Thought boundaries survive: one capture record is one reasoning segment,
    and nothing is joined, so the ambiguity ThoughtBuffer creates never arises
    (§5 loss 10). A thought whose own text contains a blank line stays one segment —
    splitting it would invent a boundary the source does not have.
  • Non-text content blocks are carried as opaque with the block verbatim
    instead of being skipped by content_blocks_to_text (§5 loss 5).
  • "" and absent stay distinguishable everywhere; text-empty events are
    kept rather than dropped.
  • The oracle record keeps its own kind and role instead of becoming an
    agent step prefixed [oracle: …] that a consumer can only undo by string
    matching.
  • An unrecognized type becomes unknown with the record carried verbatim,
    instead of being skipped silently.

It refuses to invent arguments, timestamps, tool-call ids or an agent version.
It also does not prepend the prompts argument as leading user events, which
both existing exporters do — §5.2 showed the cost, an ATIF document that opens
with two identical user steps so user turns over-count by one. Those steps are
not ACP events; a target that wants them adds them at its own edge as
SYNTHESIZED.

Is "declare every absence" actually affordable?

Measured rather than asserted. The report is n_tool_calls + 5 records and
does not grow with trace length: systemic absences (timestamps, per-event usage,
agent version, stop reason) are declared once each under an unindexed
events[].… path, and only arguments — which validate_trace requires per
event — scales. A test pins this by doubling the non-tool events and asserting
the report does not change at all.


Verification

Two kinds, deliberately not merged.

Machine checks

35 new tests. Preservation is checked against events produced by driving a real
ACPSession through the production capture path (reusing the Slice A2 fixture),
not against hand-written dicts that would only prove the converter agrees with
itself. The loss report is asserted as a complete set, so an undeclared loss
and a spurious one both fail. One test removes a declared loss from a converted
trace and shows the trace becomes invalid.

tests/trajectories 328 passed; the trajectory, capture, streaming and ACP
regression lane 493 passed; ruff check ., ruff format --check and
ty check src/ clean.

Human end-to-end — run by @Galius5136, not by CI

Against two real rollouts already captured for Slice A2 (gemini, docker
sandbox, ACP transport, standard artifact writers — the ones §5.2 documents),
converted and cross-checked against their own source files.

  • H1 — real tool use. PASS. The five real events convert in order; both tool
    calls keep id, kind and status, and carry arguments = None, while the same
    rollout's trainer/atif.json serializes {} for those same calls.
  • H2 — real wall-clock timeout. PASS. The timeout is preserved in the IR
    with its fields; the ATIF document for the same rollout contains no
    agent_timeout. §5 loss 4 is closed end to end on the ACP → IR path, on a rollout that really timed out.
  • H3 — hand inspection of a converted document. PASS, after a fix. See below.
  • H4 — negative control. PASS. Removing one LossRecord makes the trace
    invalid with absence must be declared; setting arguments = {} makes it
    valid again, which is the known limit recorded below.

H3 found a real defect, corrected before this comment

Reading the converted document by hand — not running the suite, which was green
— showed that §8.4 published its worked example with exclude_none=True, so
arguments was absent from the document while the loss report kept a record
addressing events[1].tool_call.arguments. The declaration that legalizes the
absence pointed at a key no reader of that document could find.
The suite could
not catch it because both sides of the comparison used the same non-canonical
encoding, so it was self-consistent.

The fix (48dbbff3) states the canonical encoding in the ir.py docstring —
nulls are retained; exclude_none=True is not a valid encoding of a Trace IR
document
— regenerates §8.4 so arguments: null and its loss record are
visible in the same document, and adds guards that assert in the same test that
the discarded encoding fails to resolve those paths, so they cannot pass for
both encodings at once.

Applying that guard immediately found a second instance of the same class:
outcome.stop_reason is a loss every ACP conversion declares, and it could not
resolve in a trace that did not time out, because the section itself was null.
CanonicalTrace.outcome is now always present, like agent.

No serializer was added. There is no on-disk artifact yet and a writer would
anticipate an interface this proposal has not earned.

Known limitation, not a solved problem

The invariant forces a converter to declare an absence. It cannot stop one
from writing arguments: {} instead of null
— such a trace is valid, and H4
demonstrates it deliberately. Closing that would mean the IR taking a position on
what an empty map means for each source, which the tri-state rule deliberately
leaves to the converter. Recorded in §8.8 as a limit rather than filed as a bug.

Not established

  • Non-text content blocks are covered by tests only. Neither rollout emitted
    a file-edit or terminal block, so opaque carrying has not been observed in
    the wild — the same gap Slice A2 recorded.
  • Oracle mode likewise: the oracle branch is covered by tests, not by a
    real oracle rollout.
  • No new rollout was run for this slice.

Feedback on the architecture is still welcome and still cheap to act on: nothing
imports either module, so redirecting this costs a revert rather than a
migration. The open questions in the PR description remain unanswered, and §8.6
still lists what a review can reject — the declared-absence contract first among
them.

Preparation for the first outbound converter, and a gap the inbound one had
already papered over.

`LossRecord.field` was documented as a path in IR terms, but not every record is
about an IR node. Slice C already needed an escape and invented a `source[i]`
string convention in a module docstring, recognized by `startswith` in two
guards. The ATIF edge makes the same gap unavoidable in the other direction: a
prompt-derived step, a `message: ""` the format requires, a `total_steps` the
document computes about itself — none of these has an IR antecedent, and giving
them an invented IR path would produce an address that does not resolve.

Adds `PathSpace` — `hub` · `source` · `target` — and `LossRecord.space`,
defaulting to `hub` so every record written before this field existed keeps its
meaning. `field` is the path *inside* its space and carries no prefix repeating
it: the space is a property of the record, never inferred from the string. Two
records may legitimately hold the identical path in different spaces, which is
exactly the `acp -> ir` case where a skipped input entry and the IR event that
inherited its index are both `events[3]`.

Three spaces cover every direction and a new format adds none, because every
edge has the IR on exactly one side and therefore exactly one non-hub space: an
inbound edge can talk about its source, an outbound one about its target.

Only `hub` records compose across edges — the IR is the output of an inbound
conversion and the input of an outbound one, so `events[1].tool_call.arguments`
denotes the same field in both reports and the records join on it. `source` and
`target` records are terminal by construction. No unified report is introduced;
composition happens at read time over `(direction, field, space)`.

The same asymmetry settles which side owns a report, now stated explicitly in
the module docstring: a trace is built exactly once so an inbound conversion may
attach its report to it, while a trace may be converted to many targets so an
outbound conversion returns its report and leaves `trace.losses` untouched.

- `validate_trace` invariant 7 now requires a *hub* record. A `target` record
  holding the same path addresses another document and declares nothing about
  this trace; previously the string alone would have satisfied it.
- Both canonical-path guards filter on `space is HUB` instead of testing string
  prefixes. `_is_per_event` classifies by space and takes a record rather than a
  string.
- `LossReport` gains `by_space`, and `for_field` takes the space — the space is
  part of the address, so it is not defaulted away silently.
- `ir_from_acp` migrates its one `source[i]` record to
  `space=SOURCE, field=events[i]`: the path is now the one the input actually
  uses, since the space no longer has to be spelled into the string.

Five new tests pin the contract, including that no space is inferred from the
string in either direction: a `source`/`target` record with a hub-shaped path
does not satisfy the declared-absence invariant, and a hub record with a
source-shaped path does.

`docs/trace-interop.md` §8.2 gains the spaces and the report-ownership rule as a
fifth design choice; §8.4 is regenerated so the example carries the new field.

Additive: no existing path changes meaning, no runtime module imports either IR
module, and no format, exporter or artifact changes.

Verified: tests/trajectories 334 passed; the wider trajectory/capture/ACP lane
466 passed; `ruff check .`, `ruff format --check` and `ty check src/` clean.
The first outbound edge, and the first converter that has to fabricate. Where
`ACP -> IR` stress-tested whether every absence could be declared, this one
stress-tests the other half of the taxonomy: ATIF requires values the IR does
not carry, so `SYNTHESIZED` stops being a decorative enum member.

`export_atif.py` is untouched and remains the only writer of
`trainer/atif.json`. Nothing imports the new module.

The claim it is built to support, and the reason the slice is worth anything:

    ir_to_atif(acp_events_to_ir(events), prompts=P)
        ==
    trajectory_to_atif_record(events=events, prompts=P)

Parity with the existing direct exporter, on the same inputs, for the document.
A hub that lost anything the direct path preserved would fail that equality.
Asserted over events driven through the production capture path plus nine
further shapes, and confirmed against the two real gemini rollouts of §5.2: for
both, the document produced through the hub is identical to the
`trainer/atif.json` those rollouts actually wrote, back when they ran.

Parity is about the document, not the report — the direct exporter produces no
report, which is the difference this whole proposal is about.

**One deliberate deviation, enumerated by a test rather than left in a diff.**
`acp_events_to_atif_steps` renders an oracle record as a `source: "agent"` step
prefixed `[oracle: …]`, recoverable only by string matching; §5.1 records this
as a live divergence, since the in-repo validator already accepts
`source: "oracle"` while no emitter produces one. The IR carries the role, so
this edge emits `source: "oracle"` with the command as the message. A test runs
both paths over a trajectory holding every capture event type plus an oracle
record and asserts that step is the only one that differs.

Seven fabrications, each recorded where it is invented: `agent.version` and
`agent.name`, a tool-call id, a `function_name`, `arguments`, `steps[].message`,
and `final_metrics.total_steps` — plus the prompt-derived user steps. The first
five are hub-space records at the IR field whose absence forced them; the last
three have no IR antecedent and are target-space.

`arguments` keeps its `{}`. It is what ATIF requires and what the direct
exporter writes, and departing from it would trade a real compatibility property
for a cosmetic one. What changes is that it is no longer silent: the fabrication
is declared at the same hub path the ACP edge declared `UNSUPPORTED`, so reading
the two reports together says the source never had arguments and the target
demanded them anyway. A tool call carrying real arguments — including a
genuinely captured `{}` — passes through and is declared nothing.

Losses are declared only for what this edge actually loses given the trace in
hand. An ACP-derived trace has no per-event timestamps or usage, and the inbound
report already declared those as `UNSUPPORTED`; repeating them here as `DROPPED`
would double-count one fact and misdescribe an edge that loses nothing it was
given.

- `src/benchflow/trajectories/ir_to_atif.py` — returns `(document, report)`
  rather than attaching, because a trace may be converted to many targets and
  none of those conversions describes how the trace was built. It imports one
  benchflow module, the IR: `ATIF_SCHEMA_VERSION` is redefined and pinned equal
  to `export_atif`'s by a test, and the `ThoughtBuffer` join is reimplemented and
  pinned the same way, so the hub does not depend on export plumbing.

- `tests/trajectories/test_ir_to_atif.py` — 35 tests, organized around parity.
  Also pins that an outbound conversion leaves the input trace byte-identical,
  that two conversions of one trace are independent, that every hub record of
  the outbound report resolves in the canonical encoding while target records do
  not, and that a hand-built trace converts without going through ACP.

- `test_only_the_ir_family_imports_the_ir` gains `ir_to_atif`. That test failing
  first was the mechanism working: a new converter joins the family explicitly.

- `docs/trace-interop.md` — §8.3 gains the implemented mapping with a class per
  row and the measured counts, §8.7 the status, §8.9 what the first outbound
  converter settled.

Measured on the real rollouts: H1 produces 12 outbound records (6 synthesized,
4 dropped, 2 normalized; 9 hub, 3 target), H2 produces 14, the extra drops being
the timeout event, its extensions and the trace outcome.

Known gap, recorded not fixed: `TraceUsage` has no cost field, so
`final_metrics.total_cost_usd` cannot be produced through the hub. Neither real
rollout carries one, so parity is unaffected there; closing it means adding a
field to the IR.

Verified: 35 new tests; tests/trajectories 369 passed; the trajectory, capture,
streaming and ACP regression lane 534 passed; `ruff check .`,
`ruff format --check` and `ty check src/` clean.
…ound loss

Two holes in Slice D's central claim, found by auditing it rather than by a
test. Both are about the same thing: the outbound edge was describing a trace
shaped like the one ACP happens to produce, not the trace it actually received.

## Cost

`trajectory_to_atif_record` accepts `total_cost_usd` and writes it to
`final_metrics`. The IR had no cost field, so a document produced through the
hub could not carry it — the parity claim held only for traces without one.

`TraceUsage` gains `cost_usd` and `price_source`, mapped to
`final_metrics.total_cost_usd` (preserved) and to a declared `DROPPED` (ATIF has
no slot). Cost sits with the token counters because that is where every
BenchFlow object already puts it — `agent_result`, `TaskTelemetry` and ATIF's
own `final_metrics` — and because it is derived from them. `price_source` is to
`cost_usd` what `source` is to the counters: BenchFlow computes no prices of its
own, it imports a number from the model gateway's log, so a cost without the
table that produced it is not comparable.

The writing path is real: `providers/litellm_logging.py:618-623` sums the
callback log's per-entry `cost` into `Trajectory.metadata["cost_usd"]`, it
surfaces as `AgentResult.cost_usd`, and `rollout/_results.py:448` hands it to
the ATIF writer. Stated precisely, because the audit turned up a nuance worth
recording: **no rollout artifact available here has ever carried a non-null
cost** — including four whose `usage_source` is `provider_response`, which went
through the proxy but whose gateway log carried no per-entry cost. So the
field's production is established by reading the code, not by observation, and
parity with a cost is asserted on synthetic input given to both paths.

Per-call cost stays out of scope: it exists in the proxy capture, and modelling
it is a larger question this proposal does not open.

## Conditional outbound losses

The rule was right and the coverage was not. Probing with a trace that really
carried the values showed six kinds of information the IR can hold, ATIF cannot
represent, and the report did not mention: trace `trace_id` / `started_at` /
`finished_at` / `provenance` / `extensions`, `agent.provider`, per-event
`outcome`, tool-call timestamps, the `raw` block behind a rendered text block,
and a `role` that disagrees with the source ATIF derives from the event kind.

Two were also addressing bugs rather than gaps: a tool-call timestamp was
covered by a condition that emitted a record at `events[].started_at`, blaming
the event for a value belonging to its tool call, and `finished_at` was never
named at all. Timestamps are now four separate claims, each addressed where the
value lives.

`role` is declared per event and only when it disagrees with the implied source
— for anything ACP produces the two always agree, so an ACP trace declares
nothing for it, which is the rule working.

## Pinning the rule

Two tests convert the same shape twice: once through the ACP edge, where the
fields are absent and nothing is declared, and once hand-built with every field
present, where the complete `DROPPED` set is asserted as an equality.

A third test derives the field list from the IR models themselves and requires
each one to have a disposition here — mapped, normalized, declared, container,
or representation. Adding a field to the IR that ATIF cannot represent now fails
the suite until its fate is decided, which is what makes the first two tests
stay honest as the IR grows.

## Verification

H1 and H2 parity is unchanged: for both, the document produced through the hub
is still identical to the `trainer/atif.json` the rollout really wrote. Their
outbound reports grow from 12 to 14 and from 14 to 16 records, the two additions
being the trace-level `provenance` and the `raw` behind each text block — both
values those traces really carry and ATIF really drops.

45 tests in the ATIF suite; tests/trajectories 379 passed; the wider lane 544
passed; `ruff check .`, `ruff format --check` and `ty check src/` clean. One
intermittent failure was seen once in
`tests/test_acp.py::TestIdleTimeoutDiagnostics` — a wall-clock assertion that
passes alone and on re-run, in a module that imports nothing from the IR family.
@Galius5136

Copy link
Copy Markdown
Author

Slice D — IR → ATIF, the first outbound edge

Where ACP → IR tested whether every absence could be declared, this edge
tests the other half of the taxonomy: ATIF requires values the IR does not
carry, so this is the first converter that has to fabricate, and
SYNTHESIZED stops being a decorative enum member.

Still unwired and still Draft. export_atif.py is untouched and remains the only
writer of trainer/atif.json.

The claim, and how it is checked

ir_to_atif(acp_events_to_ir(events), prompts=P)
    ==
trajectory_to_atif_record(events=events, prompts=P)

Parity with the existing direct exporter, for the document. A hub that lost
anything the direct path preserved would fail that equality.

Checked three ways, with no allow-list suppressing differences: the real
trainer/atif.json a rollout wrote months ago, the direct exporter re-run today
on the same inputs, and the same inputs through the hub. All three identical, on
two real rollouts.

PathSpace — which document a loss record talks about

The outbound edge forced a gap in the contract into the open. A prompt-derived
step, the message: "" ATIF requires, final_metrics.total_steps — none has an
IR antecedent, so none can carry a hub path, and inventing one produces an
address that does not resolve.

LossRecord.space is now hub · source · target, defaulting to hub, with
field the path inside its space and no prefix repeating it. Three spaces
cover every direction because every edge has the IR on exactly one side and
therefore exactly one non-hub space — OTel will add none.

Only hub records compose across edges, and that is the property the whole
proposal is for:

edge space field class
acp -> ir hub events[2].tool_call.arguments unsupported
ir -> atif hub events[2].tool_call.arguments synthesized

One field, two edges, one path: the source never carried arguments and the
target demanded them anyway. source and target records are terminal by
construction, and there is no unified report — composition happens at read time.

The same asymmetry settles ownership: a trace is built once, so an inbound
conversion may attach its report to it; a trace may be converted to many
targets, so ir_to_atif returns (document, LossReport) and leaves the
input trace byte-identical.

One deliberate deviation

acp_events_to_atif_steps renders an oracle record as a source: "agent" step
prefixed [oracle: …], recoverable only by string matching — §5.1 records this
as a live divergence, since the in-repo validator already accepts
source: "oracle" while no emitter produces one. The IR carries the role, so
this edge emits source: "oracle" with the command as the message. This is
the only intended difference
, and a test runs both paths over a trajectory
holding every capture event type plus an oracle record and asserts that step is
the only one that differs.

The loss report describes the trace it receives

Not the trace ACP happens to produce. An ACP-derived trace has no per-event
timestamps or usage, and the inbound report already declared those absences —
re-declaring them here would count one fact twice. The same conversion over a
trace that does carry them declares every one, addressed where the value lives
(a tool-call timestamp under the tool call, not under its event).

Both halves are asserted, the second as a complete set. A companion test derives
the field list from the IR models themselves, so a field added to the IR that
ATIF cannot represent fails the suite until its fate is decided.

Cost

TraceUsage gained cost_usd and price_source before this was published,
because the direct exporter accepts total_cost_usd and the IR could not carry
it — the parity claim would have been true only for traces without one. Cost
sits with the token counters because that is where agent_result,
TaskTelemetry and ATIF's own final_metrics already put it. price_source has
no ATIF slot and is declared dropped: BenchFlow computes no prices of its own,
so a cost without the table that produced it is not comparable. Per-call cost is
out of scope.


Verification

Two kinds, deliberately not merged.

Machine checks

45 tests in the ATIF suite; tests/trajectories 379 passed; the wider
trajectory / capture / streaming / ACP lane 544 passed; ruff check .,
ruff format --check and ty check src/ clean.

One intermittent failure was seen once, in
tests/test_acp.py::TestIdleTimeoutDiagnostics — a wall-clock assertion that
passed alone and on re-run, in a module that imports nothing from these modules.
Recorded rather than attributed.

Human end-to-end — run by @Galius5136, not by CI

D1, D2, D3 use real rollouts already captured (the gemini runs of §5.2,
docker sandbox, ACP transport, standard artifact writers).

  • D1 — real tool use. PASS. Three-way comparison identical; 14 outbound
    records; the unsupported → synthesized composition above verified on both
    tool calls.
  • D2 — real wall-clock timeout. PASS. Three-way comparison identical; the
    timeout is preserved as far as the IR and then declared dropped at the ATIF
    edge, which is the honest result — 16 outbound records.
  • D3 — hand inspection. PASS. An independent diff of the produced document
    against the rollout's own atif.json reports IDENTICAL. arguments: {}
    reproduced, agent.version: "unknown" reproduced, and the two leading user
    steps reproduced — including the duplicate §5.2 documents. Parity means
    reproducing the defect too, not silently fixing it.

D4 and D5 are constructed controls, and are labelled as such:

  • D4 — the oracle deviation. No real rollout ran in oracle mode; the input is
    built by hand.
  • D5 — negative control. Removing a tool title from the IR flips the parity
    check from True to False, so the comparison demonstrably discriminates.

Not established

  • Real cost end-to-end is not verified. Every rollout artifact available
    carries cost_usd: null — including four whose usage_source is
    provider_response, whose gateway log simply carried no per-entry cost. The
    writing path is established by reading the code; the mapping is covered by
    tests. D6 is a machine/integration proof and is not a human E2E.
  • Non-text content blocks remain covered by tests only — neither rollout
    emitted a file-edit or terminal block.
  • Oracle mode likewise: constructed input, never a real oracle rollout.
  • The richly-populated trace used to pin the complete loss set is
    hand-built; no source in the repository produces one yet.
  • No new rollout was run for this slice.

Feedback on the architecture is still welcome and still cheap to act on: nothing
imports these modules, so redirecting this costs a revert rather than a
migration. The open questions in the PR description remain unanswered, and §8.6
still lists what a review can reject.

@Galius5136 Galius5136 changed the title Trace interoperability: OTel ↔ ATIF ↔ ACP feat(trajectories): canonical trace IR with ACP and ATIF converters Aug 15, 2026
…nwired

Closes the ATIF pair: `ir_to_atif` writes the document, `atif_to_ir` reads one
back. Nothing imports either, `export_atif.py` is untouched, and it is still the
only writer of `trainer/atif.json`.

The module is built on one rule — read what the document says, never what it
probably meant. An ATIF document is the output of a lossy conversion and several
of its values were fabricated by the converter that wrote it: `agent.version` is
the literal "unknown" whenever BenchFlow had none, `arguments` is `{}` for every
ACP-derived call, `message` is `""` on a step carrying only a tool call. Reading
those back as absent would be guessing which values its counterpart invented,
and would make a round trip look better than it is. So every value is taken
verbatim.

The consequence is the point rather than a wart: a fabricated value returns
indistinguishable from an observed one. `arguments: {}` reads back as an
observed empty argument map, which the IR's tri-state contract cannot separate
from a real one. The information was not lost, it was replaced by a false
statement of the same shape — and only the two loss reports carry the truth.

What no ATIF document holds — trace id, timestamps at any level, run outcome,
provider, per-event provenance — is declared UNSUPPORTED, not DROPPED: the value
is absent from the source, not discarded here, and that is the distinction which
decides where a fix would land. Every DROPPED record in this direction addresses
the source document instead, in the source path space.

Two shapes are deliberately not undone. A step with both `message` and
`reasoning_content` stays one event, because the blank-line join that produced
it is not injective (§5 loss benchflow-ai#10) and splitting it would invent a boundary. A
step with several tool calls becomes one event per call, since the IR models one
per event; no writer here emits that, another producer can.

54 tests, over documents from both writers — the direct exporter and the hub —
plus malformed input a document read off disk can actually have.
…wired

Slice D proved the hub reproduces the direct exporter's document. With both ATIF
edges in place the loop closes, and this answers the harder question: how much
of a trace is still there after a trip through the interchange format.

It is a measurement, not an assertion. `compare_traces` reads the two traces and
never the loss reports, so a converter that lost something without declaring it
is caught rather than confirmed.

No percentage, because one number would merge two unrelated things. The report
crosses an observed axis — preserved / transformed / lost / fabricated, from the
comparison alone — with a declared one: whether ATIF has a slot at all. A loss
with a slot is a gap in our own edge and fixable; a loss without one is a cost of
the format. The declared half is a table with one entry per IR field, and a test
derives the field list from the models so a new field cannot reach the round trip
undecided. Comparison is by canonical path rather than by event position: after a
conversion that fuses and drops events there is no recoverable correspondence
between event i and event j, and inventing an alignment would be guessing.

Measured on the two real rollouts of docs §5.2, machine measurement only:

- Nothing representable is lost, on either. Every value the loop drops is
  dropped because ATIF has nowhere to put it, so there is no gap in our edges to
  close and the remaining loss belongs to the format. A test pins this, and a
  failure there names a converter bug.
- The trace comes back with MORE values than it left with — 46 in, 56 out for H1
  — while having lost information. Four fields are fabricated every run, and two
  of them matter: `agent.agent_version` and `tool_call.arguments` leave declared
  SYNTHESIZED and return unmarked, so the reconstructed trace asserts they were
  observed. The information was not lost so much as overwritten with a plausible
  value of the same shape, and only the pair of reports still carries the truth.
- A timeout costs six fields. H2 differs from H1 only by ending in one, and that
  single fact takes the event, its reason, the run status and three extension
  fields with it — §5 loss benchflow-ai#4, measured rather than asserted.

Docs: §8.3 gains the ATIF -> IR mapping table, §8.10 is the measurement, §8.7 is
updated. The doc for the converter that landed in the previous commit is here
rather than there, so the two tables could be written against each other.

31 tests. Still nothing imported by a run path; `export_atif.py` untouched and
still the only writer of trainer/atif.json.
… input

Four defects found by adversarially probing the edge with documents it does not
write itself. None of them touches a conformant document — the H1/H2 numbers are
unchanged — but each one broke the rule the module is built on, which is exactly
the rule that makes the round-trip measurement worth anything.

- A JSON `null` in a string slot became the literal `"None"`. `message: null`
  produced a four-character string the document never contained, and
  `reasoning_content: null` produced a segment list holding it. Invariant 5 was
  satisfied throughout, which is why the suite could not see it. Null and absent
  now both read as no value.
- A step with no usable `source` was attributed to the agent. Missing, non-string
  and out-of-vocabulary sources all became `agent_message` — not a harmless
  default, since that is the kind a consumer counts as a model turn. They become
  UNKNOWN, as `ir_from_acp` already does for an unrecognized record.
- A non-string `source` was discarded. It cannot be the IR's `source_type`, but
  it was the only thing the document said about the step's origin, so it is kept
  in extensions with a record.
- A `tool_calls` or `observation` this converter cannot read vanished with no
  record in any path space. Unreadable here is not the same as not present; both
  are now kept verbatim and declared in the source space.

Also closes a hole in the measurement itself. `lost` counts fields the table
calls representable that did not survive, and the table is written by hand — so
a field wrongly marked unrepresentable would move a real, fixable loss into the
format's column and leave `lost` at zero for free. Two tests make the table
falsifiable: on a trace populating every IR field `lost` is 2 (`ir_to_atif`
writes no per-step metrics, so per-event usage is lost through a slot ATIF has),
and no field the table calls unrepresentable comes back with any value intact.
`lost = 0` on the captured rollouts is now a result rather than a definition.

`arguments` joins `content[].raw` as an opaque path: both hold a mapping whose
keys come from a tool rather than from the IR, and without it the field's path
depended on its own contents.

Docs: the timeout costs five fields and the event carrying them, not six — the
sixth entry in H2's column is the source content block, which H1 loses too.

20 tests added, all of them failing on the code before this commit.
§8.10 described its own numbers as a machine measurement and not a human E2E.
That was accurate when written and is no longer: the loop's output has since
been checked against the raw artifacts by hand on both rollouts — parity, the
conversion read in both directions, every fabricated value confirmed present in
the ATIF document and absent from the capture, every unrepresentable one
confirmed the other way round, and the negative control.

The declared limits are unchanged: oracle rollouts and non-text content blocks
remain test-only, no artifact here carries a cost, the representability table is
checked against our converters rather than against a vendored ATIF schema, and
two rollouts from one agent stay a demonstration rather than a survey.
@Galius5136

Copy link
Copy Markdown
Author

Slice E — ATIF → IR, and what a trip through the format costs

Four commits, 4ec9aaea..56ecab6f. Still Draft, still unwired: nothing under
src/benchflow imports any of it, export_atif.py is untouched and remains the
only writer of trainer/atif.json.

What landed

  • src/benchflow/trajectories/ir_from_atif.py — the inbound ATIF edge, closing
    the pair with Slice D.
  • src/benchflow/trajectories/ir_round_trip.py — the ACP → IR → ATIF → IR′
    measurement.
  • 102 tests; docs/trace-interop.md §8.3 gains the mapping table, §8.10 the
    measurement.

The rule the converter is built on: read what the document says, never what it
probably meant.
Several values in an ATIF document were fabricated by the
converter that wrote it — agent.version: "unknown", arguments: {},
message: "" — and nothing marks them as such. Reading them back as absences
would be guessing which ones were invented, and would make the measurement below
report a preservation that did not happen.

What the measurement found, on the two real rollouts of §5.2:

  • Within the declared IR ↔ ATIF field mapping, nothing representable is lost
    on either rollout
    . Everything the loop drops is dropped because ATIF has no
    slot for it. That is a result rather than a definition: on a trace populating
    every IR field the same measurement reports two representable losses —
    per-event usage, which ATIF does have a step-metrics slot for and
    ir_to_atif does not write. A gap in our own edge, and visible as one.
  • The trace comes back with more values than it left with — 46 in, 56 out on
    H1 — while having lost information. arguments leaves declared SYNTHESIZED
    and returns unmarked, so the reconstructed trace asserts the tool was
    observed
    to be called with none. The information was not so much lost as
    overwritten with a plausible value of the same shape, and only the pair of
    loss reports still carries the difference.
  • A timeout costs five fields and the event carrying them — the reason, the
    run's outcome status, and the three fields the marker carried. §5 loss Readme suggestion #4,
    measured instead of asserted.

The report separates what it observed (preserved / transformed / lost /
fabricated, from comparing the two traces and never the loss reports) from what
is declared (whether ATIF has a slot at all), because a loss with a slot is a
bug to fix and a loss without one is a property of the format, and a single
percentage would merge them.

Verification. Machine: 481 tests in the trajectories lane, ruff, ty, plus
20 targeted mutations of both converters, all caught. Human, on H1 and H2:
document parity, the conversion read step by step in both directions, every
fabricated value confirmed present in trainer/atif.json and absent from the
ACP capture, every unrepresentable value confirmed the other way round, and a
negative control confirming the comparison notices a corrupted value.

Limits, stated rather than implied: oracle rollouts and non-text content
blocks appear only in constructed test input; no artifact on hand carries a
non-null cost, so usage.cost_usd round-trips in tests only; the
representability table is checked against our own converters, not against a
vendored ATIF schema; and two rollouts from one agent are a demonstration, not a
survey.

Not in this slice, deliberately: any wiring into a run path, and
OpenTelemetry — which stays blocked on open question 2 (ingest, emit, or both).
That is the one thing here that needs a maintainer decision rather than more
code.

Adds the inbound OpenTelemetry edge, plus the AnyValue decoder it is built on.
Inbound only: no `IR -> OTel` emitter, no wiring into a run path, no on-disk
artifact, and no existing format, exporter or artifact changes because it
exists. `uv.lock` is untouched.

§4.2 is FACT — this repository has no OTel code and no OTel dependency — so the
mapping is written against the two artifacts `uv.lock` already pins rather than
against recollection: `opentelemetry-proto` 1.41.1 for the wire shape,
`opentelemetry-semantic-conventions` 0.62b1 for the `gen_ai.*` vocabulary. Both
versions are recorded in code, both wheel hashes were checked against the lock,
and neither package is imported — the edge reads JSON dictionaries.

Reading them settled four things a mapping written from memory gets wrong, and
the collector deleted in e84795d got three of them wrong: `intValue` is a JSON
string in the canonical encoding; an `AnyValue` writes `""`, `0` and `false`
explicitly, so its `stringValue or intValue or ...` read observed values as
absent; `gen_ai.usage.total_tokens` does not exist at 0.62b1 and the cache
counters are spelled `cache_read.input_tokens`; enums serialize as member names
by default and as integers under a flag, and both parse.

The rule the edge is built on: a span is evidence of an operation, not a
statement about an agent. Exactly one span shape becomes a typed IR kind — the
`execute_tool` operation the pinned vocabulary defines as that — and every other
span becomes UNKNOWN with its whole content carried in `extensions.otel`. So of
the IR's seven EventKind members this edge reaches two. For four of the eight
`gen_ai.operation.name` values there is no IR candidate at all; for `chat`,
`generate_content` and `text_completion` there is one, and the blocker is
checkable rather than a matter of taste — filling it means reading
`gen_ai.input.messages`, whose structure the pinned package defines by reference
to a JSON schema it does not ship.

Order is preserved and never sorted by time: siblings overlap, a batch may omit
a parent, and two spans can share a start instant. The `parentSpanId` edge set
is preserved per span instead, together with the envelope coordinates, so the
`resourceSpans`/`scopeSpans` partition survives flattening — two spans batched
separately under an equal `scope` stay distinguishable. Identifiers are carried
exactly as written and never re-encoded: a 32-character hex trace id fed to the
pinned JSON parser is accepted as base64 and silently yields 24 bytes, so hex
and base64 are not reliably distinguishable and normalizing would make identity
depend on a heuristic.

Two losses are structural and both are declared: nanoseconds do not fit a
`datetime`, and OTLP models `name`, the timestamps, `parentSpanId`, `kind` and
the `dropped*Count`s as scalars with no presence, so absent and default are the
same document. A third is a distinction worth keeping — decoding `AnyValue`
wrappers into a map preserves the values and loses the wire form, which is
`normalized`, not `preserved`.

Trace-level `started_at`, `finished_at` and `usage` are left empty on purpose.
The class says why: NORMALIZED when the spans carry the information per span and
aggregating it would assume the payload holds the whole run, UNSUPPORTED when
there was nothing to aggregate. An OTLP export request is a batch.

129 tests. The contract guard derives the IR field list from the models and
requires every field to be filled or declared **per instance** — per event, per
content block — with two exemptions that are properties of the IR rather than of
a converter. It found two real undeclared absences while the edge was being
written, and a structural review of the finished slice found four more, one of
them in the guard itself: it had been field-level, so a field the fixture
happens to fill passed on every payload. 33 targeted mutations, all caught.

The suite's fixture is not hand-written: it is `MessageToJson` output from the
pinned library, so its encoding is the library's rather than this suite's, and
`e2e-f/regenerate_fixture.py` rebuilds it from a throwaway venv and diffs.

HUMAN E2E VERIFIED 2026-08-18, F1-F6 all PASS, recorded in §8.11 together with
the limits it does not extend past: the fixture's encoding is authoritative and
its content is constructed; no OTLP payload from a real agent has ever been
read, because nothing in BenchFlow emits spans; the mapping is checked against
two pinned packages, not the specification; the GenAI conventions are still
`_incubating`; there is no `IR -> OTel`; and there is therefore no OTel round
trip and none is measured. `0 dropped` on the fixture is a property of that
payload, not of the edge — two fully conformant inputs do produce DROPPED
records.
…ision

Adds §8.12. No code changes: the edge it describes is the one already committed
in 796e436, and `IR -> OTel` is not implemented here or anywhere.

For this implementation OpenTelemetry is an ingest boundary. `OTLP/JSON -> IR`
exists; `IR -> OTel` is deferred. What the section is careful about is *whose*
decision that is: it is ours, it is current, and it is reversible. No maintainer
has said the emitter is out of scope, and none has said it is required — open
question 5 is untouched and stays on the list in §8.11.

The reasoning is five things checkable in the tree rather than a reading of
anyone's intent: no outbound OTel consumer exists, no exporter or backend is
integrated, the OTel support that did exist was a receiver, the consumer named
by the task's own issue body reads ACP, and there is no contract to build an
emitter against. That last one is the load-bearing reason — without a consumer,
the span tree for a rollout, the id encoding and the commitment to an
`_incubating` vocabulary would all be decided by this implementation alone, and
the hub is the one place a wrong answer propagates to every format.

Three things the section refuses to claim, because each would be an overreach in
a different direction: that `IR -> OTel` will never be needed; that the `<->` in
the task title has been resolved (it has not, and this decision is not evidence
about what it meant); and that the task has been narrowed — if the answer comes
back "both", the work is an additional edge, not a redesign.

The reversal condition is a consumer or a contract, and one constraint holds
until then: the IR must not grow a span-tree field, an id-minting policy or an
OTel-shaped slot in anticipation. The hub is a superset of what BenchFlow can
observe, and a field added for a converter that does not exist has no evidence
behind it. The emitter, when written, takes the IR as it finds it and declares
what it cannot express — the contract every outbound edge already has.
@Galius5136

Galius5136 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Slice F — OTLP/JSON → IR, and a scope decision on the OTel direction

Two commits, 56ecab6f..d4a105f6. Still Draft, still unwired: no module under
src/benchflow imports any of it, no artifact changes, and uv.lock is
untouched.

What landed

  • src/benchflow/trajectories/ir_from_otel.py — the inbound OTel edge.
  • src/benchflow/trajectories/_otlp_anyvalue.py — the AnyValue/KeyValue
    decoder it is built on, split out as a pure move; it imports nothing from
    benchflow.
  • 129 tests; docs/trace-interop.md §4.2 and §8.3 updated, §8.11 and §8.12 new.

Built against the lock file, not against recollection

§4.2 is still FACT — no OTel code, no OTel dependency. But uv.lock already
pins opentelemetry-proto==1.41.1 and opentelemetry-semantic-conventions==0.62b1
transitively via daytona, so the two questions an OTel reader has to answer
have checkable answers. Both versions are recorded in code, both wheel hashes
were checked against the lock, and neither package is imported — the edge
reads JSON dictionaries, so the lock stays untouched.

Reading them corrected four things, three of which the collector removed in
e84795d7 got wrong: intValue is a JSON string in the canonical encoding;
an AnyValue writes "", 0 and false explicitly, so its
stringValue or intValue or ... read observed values as absent;
gen_ai.usage.total_tokens does not exist at 0.62b1 and the cache counters are
spelled cache_read.input_tokens; and enums serialize as member names by
default, integers under a flag, with both accepted on parse.

The rule: a span is evidence of an operation, not a statement about an agent

Exactly one span shape becomes a typed IR kind — the execute_tool operation
the pinned vocabulary defines as that. Everything else becomes UNKNOWN with its
whole content carried in extensions.otel. So this edge reaches two of the
IR's seven EventKind members.
For four of the eight gen_ai.operation.name
values there is no IR candidate at all; for chat, generate_content and
text_completion there is one, and the blocker is checkable rather than a
matter of taste — filling it means reading gen_ai.input.messages, whose
structure the pinned package defines by reference to a JSON schema it does not
ship (129 entries in the wheel, zero .json files).

Two more consequences worth naming:

  • Identifiers are carried exactly as written and never re-encoded. A
    32-character hex trace id fed to the pinned JSON parser is accepted as base64
    and silently yields 24 bytes, so hex and base64 are not reliably
    distinguishable and normalizing would make identity depend on a heuristic.
  • Document order is preserved and never sorted by time. Siblings overlap, a
    batch may omit a parent, and two spans can share a start instant. The
    parentSpanId edge set is preserved per span instead, along with the envelope
    coordinates, so the resourceSpans/scopeSpans partition survives flattening.

What the loss report costs

31 records on the fixture: 19 systemic, 12 per-event, 19 + 2.4n as the trace
grows, so only the per-span half grows. 0 synthesized is structural — the
class has no site, because the IR's only required fields are derived from input
and there is no slot an inbound edge could be forced to invent. 0 dropped is
a property of that payload, not of the edge: two fully conformant inputs do
produce DROPPED records — spans disagreeing about gen_ai.request.model, and
a document carrying both gen_ai.usage.input_tokens and the deprecated
gen_ai.usage.prompt_tokens with different values.

The contract guard derives the IR field list from the models and requires every
field to be filled or declared per instance. It found two real undeclared
absences while the edge was written, and a review of the finished slice found
four more — one of them in the guard itself, which had been field-level, so a
field the fixture happens to fill passed on every payload. 33 targeted
mutations, all caught.

The suite's fixture is not hand-written: it is MessageToJson output from the
pinned library, and a script rebuilds it in a throwaway venv and diffs.

Scope: ingest-only for now

For this implementation OTel is an ingest boundary, and IR → OTel is
deferred, not rejected. §8.12 records that as a decision of ours — current
and reversible — rather than as something settled.

The reasoning is five things checkable in the tree: no outbound OTel consumer
exists; no exporter or backend is integrated; the OTel support that did exist
was a receiver; the consumer named by the task's own issue body reads
acp_trajectory.jsonl; and there is no contract to build an emitter against.
That last one is the load-bearing reason — without a consumer, the span tree for
a rollout, the id encoding and the commitment to an _incubating vocabulary
would be decided by this implementation alone, and the hub is the one place a
wrong answer propagates to every format.

Three things §8.12 deliberately does not claim: that IR → OTel will never
be needed; that the <-> in the issue title means one direction rather than the
other; or that the task has been narrowed — if emitting is wanted, the work is an
additional edge rather than a redesign. Until a consumer or a contract exists,
the IR gets no span-tree field and no id-minting policy added in anticipation.

Happy to add the emitter whenever it is useful — from here it is one module and
one loss report, the same shape as ir_to_atif.py.

Verification

Human E2E verified against the procedure, F1–F6 all pass — fixture provenance
regenerated from the pinned wheel, the mapping read span by span, all 31 loss
records read individually, the negative controls, the mutation harness, and the
unwired check. Machine: 610 trajectories green, ruff/format/ty clean.

Declared limits, unchanged by that: the fixture's encoding is authoritative
and its content is constructed; no OTLP payload from a real agent has ever
been read
, because nothing in BenchFlow emits spans; the mapping is checked
against two pinned packages, not the specification; the GenAI conventions are
still _incubating; there is no IR → OTel; and therefore no OTel round trip
and none is measured.

Happy to split any of this into its own PR if it reviews better.

…halves

Two inconsistencies §8.7 was left with after Slice F.

"Not implemented, deliberately: `IR -> OTel`" was true but said nothing about
whose deliberation, which is the whole distinction §8.12 exists to draw. The
status now reads as one state — the OTel direction is implemented inbound and
deferred outbound, by a decision of ours, reversibly — instead of as an
implemented edge sitting next to an unexplained absence.

The isolation paragraph still claimed "each converter imports one benchflow
module — the IR", which stopped being true when `_otlp_anyvalue` was split out
of the OTel edge. It now says what each converter actually imports, and keeps
the property that was the point: every converter reads or writes its format as
data rather than through the module that already handles it.

Documentation only. No code, no test, and no `IR -> OTel`.
…nwired

The fifth edge of the hub, and the first one whose target refuses partial
output. Nothing imports it, nothing writes its result to disk, `_capture.py` is
untouched and still the only producer of the format, and `uv.lock` is intact.

The target is the **ACP-session capture event format** — the three record shapes
Slice A's schema pins — and deliberately not the `acp_trajectory.jsonl`
artifact. That file also holds oracle records, `hosted_env` records and
session-factory `steps`, and §2.1 already recorded that no artifact-level
contract exists in this repository. An edge cannot target a format nobody has
defined; this one targets the part that is defined and says so.

Every record shape has `additionalProperties: false` and six required fields on
a tool call, so this is the first outbound edge that can be *unable* to write.
It fails closed: `ir_to_acp_capture_events` either represents every event or
raises `AcpCaptureNotRepresentable` — a `ValueError` carrying the blocking
`LossRecord`s, following `PrimeSftTrajectoryJsonlError` and the `ValueError`
`ir_to_atif` already raises. A partial event list is indistinguishable from a
complete one once it leaves the function: the target has no envelope, no count
and no marker for "some events are missing". `acp_capture_blockers` answers the
same question without an exception, through the same pass, so asking and
converting cannot disagree.

Nothing is invented. `status` is never synthesized — ACP's vocabulary is closed,
every member asserts a lifecycle state, and a fabricated one would reach the
viewer and be shown to a person as an observation. `ORACLE` and `UNKNOWN` have
no record shape in the contract, so they are refused rather than dropped, and
the schema is not widened to admit them. A content block with no source block is
refused: ACP stores wire blocks verbatim, two shapes are known to be consumed,
and choosing one would be inventing structure. `title` is the single exception,
written as `""` only because the contract documents that as its representation
of an absent title, and declared SYNTHESIZED because a reader cannot tell it
from an observed empty one. `tool_call_id` carries identical documentation and
is deliberately not treated the same way: an empty title is a missing label, an
empty id would turn an absent identity into an observable one.

**`name_semantics` gates the `kind` slot**, and a review is what put it there.
An ACP `kind` is a *category* — `ToolKind` calls itself "Category tag for tool
calls" — while ATIF's `function_name` and OTel's `gen_ai.tool.name` name
particular tools. An earlier version wrote the name through regardless, so an
ATIF document with a usable status exported `kind="read_file"`: a tool name in a
category slot, with only a DROPPED record to show for it. That is not a
normalization a reader can undo, it is a reinterpretation. Only
`name_semantics == "acp_kind"` is writable now, and the string is never
inspected — a `function_name` of `read` collides with a real ToolKind member and
is refused anyway.

Representability is decided by the data and never by the lineage. `Provenance`
is not read, and a test asserts the module never references it: an OTel-derived
trace carrying every required value would export, and an ACP-derived trace
missing one does not.

`ACP → IR → ACP` reproduces its input exactly — structural equality and
key-order preservation — on the two captured rollouts of §5.2 and on the suite's
fixture with its drop-one subsets. Three corpora, not a universality claim.

77 tests, every emitted record validated against the published schema.
Human-verified end to end, G1-G10, against `e2e-g/PROCEDURE.md`; §8.3 records
the limits that sign-off does not extend to — no artifact-level support, no
exportable oracle or unknown events, no general ATIF/OTel tool results, no
`OTel → Viewer`, and no wiring.
@Galius5136

Copy link
Copy Markdown
Author

Slice G — IR → ACP capture events, and the contract question underneath it

Two commits, d4a105f6..67ccd12f. Still Draft, still unwired: nothing under
src/benchflow imports it, _capture.py is untouched and still the only
producer of the format, no artifact changes, uv.lock intact.

What landed

  • src/benchflow/trajectories/ir_to_acp.py — the outbound ACP edge.
  • 67 tests, every emitted record validated against Slice A's published schema.
  • docs/trace-interop.md §2.4 corrected (below), §8.3 gains the mapping and the
    verification record.

The recon that changed the target

The obvious reading of this slice is "write acp_trajectory.jsonl". That turns
out to be the wrong target, and the evidence is in the schema we already
shipped: Slice A's own description says "SCOPE IS NARROWER THAN THE FILE —
this schema does not describe the complete acp_trajectory.jsonl artifact"

and "no document defines the artifact-level contract, and this schema does not
create one"
.

Checking that against the tree: four producers write records into that file,
and three of them produce records the schema rejects.
The ACP-session emitter
is valid. _run_oracle is not. hosted_env._row_to_acp_events is not — it
reuses the ACP type strings while putting the text under content rather than
text, adds ts and example_index, and introduces a fourth type, reward.
Session-factory steps pass through unmodelled. A fifth record family
(session_meta / response_item / …) is read by the viewer and by
traj_report.py and written by nothing here — cli/traj.py identifies it as
Codex.

So the edge targets the part that is defined: the capture event format. §2.4
is updated to name hosted_env (it listed two other sources; there are three)
and to describe the Codex family as consumer-recognized rather than as a
producer. Whether the artifact should acquire a contract is a separate question
this slice does not open.

Fail closed

Every record shape is additionalProperties: false, with six required fields on
a tool call. This is the first outbound edge that can be unable to write, so
it either represents every event or raises AcpCaptureNotRepresentable — a
ValueError carrying the blocking LossRecords, following
PrimeSftTrajectoryJsonlError and the ValueError ir_to_atif already raises.
A partial event list is indistinguishable from a complete one once it leaves the
function: the target has no envelope, no count, and no marker for "some events
are missing". acp_capture_blockers() answers the same question without an
exception, through the same pass, so asking and converting cannot disagree.

Refusals, rather than inventions: a tool call with no ACP status (the
vocabulary is closed, every member asserts a lifecycle state, and a fabricated
one would reach the viewer and be shown to a person as an observation); no
tool_call_id; no kind; a content block with no source block (ACP stores wire
blocks verbatim and two shapes are known to be consumed — choosing one would be
inventing structure); a text event with no text; an incomplete timeout; and
ORACLE / UNKNOWN, which have no record shape in the contract at all.

title is the single value written for an absence, as "", and only because
the contract documents that as its own representation of an absent title. It is
declared SYNTHESIZED anyway, since a reader cannot tell it from an observed
empty one. tool_call_id carries identical documentation and deliberately does
not get the same treatment: an empty title is a missing label, an empty id
would turn an absent identity into an observable one and could collapse several
id-less calls onto the same value.

The defect a review caught, worth reporting because it was semantic

An ACP kind is a categoryToolKind calls itself "Category tag for tool
calls", _canonical_tool_kind defaults an absent one to other, and the
production values (execute, edit, fetch, think) are categories. ATIF's
function_name and OTel's gen_ai.tool.name name particular tools.

The first working version wrote tool_call.name into kind regardless, so an
ATIF document with a usable status exported kind="read_file" — a tool name in
a category slot, with only a DROPPED record on name_semantics to show for
it. The IR carries name_semantics precisely to stop that, and the outbound
edge was ignoring it.

Now only name_semantics == "acp_kind" is writable, and the string is never
inspected
: a function_name of read collides with a real ToolKind member
and is refused anyway, because matching a vocabulary by accident is not being
drawn from it. The record moved DROPPEDNORMALIZED, since only acp_kind
survives and the target's own convention carries it.

Representability is decided by the data, never by the lineage. Provenance is
not read, and a test asserts the module never references it.

Verification

Human-verified end to end, G1–G10, against e2e-g/PROCEDURE.md. Unlike the
OTel slice, this one has a real producer and real artifacts: the two captured
rollouts of §5.2 are still on disk, with trajectory_source: "acp" in their
result.json. ACP → IR → ACP reproduces both exactly — structural equality
and key-order preservation — on H1 (5 records) and H2 (4 records, ending in
a real wall-clock timeout).

Also checked by hand: every loss path resolves, no unexpected synthesis, the
laundering path closed including the colliding values, fail-closed atomicity
with no partial prefix, both title cases, provenance independence, 12 records
schema-validated with all four negative controls rejected, and 27/27 mutations
caught with the files restored.

What this does not claim

  • Not that every conformant ACP document round-trips. Three corpora were
    measured — H1, H2, and the suite fixture with its drop-one subsets. There is
    no property test.
  • Not that the acp_trajectory.jsonl artifact is supported.
  • Not that ORACLE or UNKNOWN events are exportable.
  • Not that ATIF or OTel tool results are generally exportable — their
    content blocks carry text and no raw, and their names are not ACP kinds.
  • Not that OTel → Viewer works. An OTel trace is four independent kinds of
    refusal away from exporting, and the procedure prints them so this cannot be
    inferred.
  • Not that anything is wired. This edge returns records; it writes no file.

Happy to split any of this into its own PR if it reviews better.

The round-trip harness measures what changed (D) and reads only the traces.
Each converter separately declares what it could not carry, or had to invent
(L). Nothing joined the two, so a divergence no edge had declared looked
exactly like one that was.

ir_conformance.py is that join, kept out of both halves so the measurement
stays independent of the declarations it is supposed to be checked against.

Two things the naive D subset-of L misses, and what the gate does instead:

- The sides address different path spaces. ir_to_atif writes schema_version
  and step_id into the ATIF document and can only declare them in TARGET
  space: a hub path would address a node the input trace does not contain,
  which ir.py's resolution invariant correctly rejects. ir_from_atif then
  reads them back into the hub, so the round trip observes values the input
  never had, without either edge having lied. TARGET_TO_HUB relates the two,
  and a test checks every entry against real values rather than asserting it.
  It is a bridge and not an exemption: remove the SYNTHESIZED record and the
  gate fails anyway.

- Events are fused away, so some divergence is structural rather than a
  mishandled field. structure_explained requires every value that went missing
  to be one a vanished event was holding - per value, not per path - so an
  edit to an event that survived the trip is still caught.

ir_to_atif now declares schema_version and steps[].step_id as SYNTHESIZED
instead of leaving them undeclared. SYNTHESIZED and not NORMALIZED, because
neither has a source value being reshaped: the dialect string is not an
observation about the run, and step positions are not the IR's event index
renumbered.

The rules: an undeclared fabrication is always a violation, with no allowlist
for structural metadata; a loss at a path the target can hold is a violation;
a transformed path must account for both the values that left and the values
that arrived, since TRANSFORMED conflates the two.

Mutation testing found a real gap - the suite would have accepted a NORMALIZED
record where the rule asks for a SYNTHESIZED one - and a test now covers it.

Still unwired. ir_conformance joins the IR family in the isolation test,
nothing on a run path imports it, and no artifact changes. ir.py,
ir_round_trip.py, the viewer and every runtime module are untouched.

tests/trajectories: 699 passed. Mutation harness: 11/11 applied and caught.
Human verification H1-H8 recorded in docs/trace-interop.md section 8.13.
ir_to_view.py converts a trace into the step list a viewer page renders. It
produces steps and deliberately not a whole payload: of a payload's five
fields, only the steps are a function of the trace. rollout_name is a directory
name, verifier is four sidecar files, and meta comes from result.json and
timing.json - the IR has no slot for task_name, skill_mode, reward,
partial_trajectory or trajectory_source at all. Returning a payload here would
mean synthesizing run metadata to fill a shape rather than converting a trace,
so the trace-level fields the IR does carry are declared UNSUPPORTED: outside
this edge's codomain, not lost by it. Assembly belongs to the wiring slice,
which has the directory.

The wire shape is read from the viewer package proposed in benchflow-ai#1034, at the commit
recorded in VIEW_SCHEMA_ORIGIN. Nothing is imported, vendored or fetched from
that branch - it is unmerged, and the family rule is the one ir_to_atif already
follows for ATIF: read the target format as data, pin what matters by test,
never reach into the module that handles it. That pin protects this edge from
drifting; it cannot notice benchflow-ai#1034 changing.

tool.name_semantics is a seventh key on an object benchflow-ai#1034 defines with six, and
it is here because the alternative is inference. benchflow-ai#1034 derives a tool's display
category with tool_hue(kind, title), which scans both strings for needles: an
ATIF function_name of 'execute' becomes the execute category, and an OTel
gen_ai.tool.name of 'read_file' becomes 'read' because the word appears in it.
This edge emits a hue only when the semantics say acp_kind and the value is
already a member of the display vocabulary - membership, never inference - and
otherwise the neutral 'other', which the renderer maps to the secondary tokens
and which therefore asserts nothing. On the corpora that means 'execute' is
spelled identically in the ACP and ATIF rows and gets a category in one only.

Unlike the ACP edge this one never raises. A viewer is a display: an event it
cannot type must still reach the page. Every event becomes exactly one step,
UNKNOWN and ORACLE included, and what the shape cannot hold goes to the loss
report. ORACLE has no member in the step vocabulary, so it lands on 'unknown'
with its identity in the type slot, and both untyped kinds carry a
serialization of the canonical IR event - not a raw source record, since the IR
does not hold one, and the loss record says so.

Sentinels are declared per slot and only where the target has no null to write.
An absent title and an observed empty one both render as "", so the report is
the only place that difference survives; a block the IR holds with no text
contributes no string rather than having its raw form rendered as JSON.

Still unwired. ir_to_view joins the IR family in the isolation test, nothing on
a run path imports it, no page is rendered and no artifact changes. ir.py,
ir_round_trip.py and the existing viewer.py are untouched.

tests/trajectories: 752 passed (699 + 53). Mutation harness: 13/13 applied and
caught. Human verification V1-V10 recorded in docs/trace-interop.md section
8.14, along with what it does not cover.
Base update only, taken before building the viewer seam against the current
renderer rather than against the one this branch forked from (43 commits back,
including the viewer restyle benchflow-ai#1019/benchflow-ai#1020, the --confirm bar benchflow-ai#1021, redaction
transparency benchflow-ai#1022 and the header-badge fix benchflow-ai#1023).

No conflicts: the two sides overlap only on pyproject.toml and uv.lock, and
there the merge is main's file plus the two jsonschema dev-dependency lines
this branch adds. Nothing else was touched in this commit.
Pure extraction, no behaviour change: `_page`, `_prompt_block`,
`_message_block`, `_thought_block` and `_result_block` come out of
`_render_acp_events` so a second producer of blocks can emit the same page
without copying its markup.

The block builders take text the caller has already escaped and truncated.
That is what keeps the extraction pure: the two prompt sites do those two
things in opposite orders (`prompts.json` slices then escapes, an inline
`user_message` escapes then slices), and a helper that picked one order would
have changed the other site's output. The asymmetry is pinned by a test and
left for its own commit.

`tests/trajectories/test_viewer_primitives.py` freezes each card's markup and
the assembled body of a whole ACP page, plus three properties of the current
renderer that are facts rather than bugs: an `agent_timeout` and an
unrecognized record reach no card, and a tool card carries kind/title/status
but not the tool's output.

Verified byte-identical on six pages rendered from the two captured rollouts of
Slice A2 and from raw ACP and Codex session files — same SHA-256 before and
after, ruff format included.
`ir_to_view_html` closes the chain `ir_to_view` opened: a viewer step list
becomes one HTML page, built from the card builders `viewer.py` already renders
its ACP page with. The dependency runs one way — this module imports the
viewer, the viewer imports no part of the IR family — so what crosses the
boundary is a plain step document and the renderer stays a renderer.

It is written against the viewer on main. benchflow-ai#1034 stays a design reference for
the step vocabulary and nothing else: no code from that branch is imported,
vendored or fetched, and this edge works whether or not it lands.

It does not rebuild steps into ACP capture events. The IR holds records ACP has
no type for, a status it cannot spell, and tool names whose semantics are the
point; forging capture events would launder all three back into the assumption
the hub exists to remove.

Measured against `_render_acp_events` on the two captured rollouts: H2's four
events render three cards there and the word "timeout" appears nowhere, an
unrecognized record reaches no card, and a tool card carries kind/title/status
but not the tool's output. Those are that renderer's four branches, not bugs;
this edge has six step kinds to place, places them, and declares the
difference. `test_viewer_primitives.py` pins the legacy behaviour as a fact so
the day a branch is added, this edge is revisited with it.

Classification is a table over the eight display hues, never a substring:
`viewer._tool_accent_class` is not imported and an AST test asserts the name
never appears here. The same `execute` gets `acc-bash` as an ACP kind and stays
neutral as an ATIF `function_name` in the same run; `read_file` from OTel stays
neutral too. `think` resolves to the neutral accent because the stylesheet has
none for it, and that one is declared DROPPED rather than hidden.

`name_semantics` rides in the metrics line and in `data-name-semantics`; a step
with no typed slot is labelled `Canonical IR representation` so the page never
passes a reconstructed event off as a source record; cuts carry an explicit
marker and a NORMALIZED record. `render_trace` returns both reports without
merging them, and this edge never addresses the hub — it has the IR on neither
side.

Unwired: nothing under src/benchflow calls it. `python -m
benchflow.trajectories.ir_to_view_html <path> [out.html]` is how a person looks
at a page. Docs in §8.15, family list and status in §8.7.
…ace IR

One branch in `render_rollout`, guarded by BENCHFLOW_VIEWER_TRACE_IR and
delegating to `_trace_ir_page` — the whole wiring surface. The switch off is
the default and returns None, so the branch order below it is the branch order
that was there before; a captured page is byte-identical to the one
`_render_acp_trajectory` produced.

The import of `ir_to_view_html` is lazy and inside that function: importing the
viewer never imports the IR, deleting the family cannot stop this file from
importing, and reverting this commit unwires everything. A conversion that
raises falls back to the ACP page with a line on stderr — a viewer that stops
showing a run because a converter broke is worse than one that says so.

With the switch on, an ACP rollout goes source -> CanonicalTrace ->
ir_to_view_steps -> ir_to_view_html, and never through `_render_acp_events`: a
test patches that function to raise and the canonical page still renders, then
proves the patch bites by rendering the same rollout with the switch off. An
ATIF-only rollout, which the ACP path answers with the no-trajectories
sentinel, gets a page; a stream-json rollout and a directory with no readable
trajectory are untouched either way.

`rollout_to_trace` and `render_rollout_page` move the directory reading out of
the adapter's __main__ so the branch and the module entry point answer the same
question the same way.

The isolation guard narrows rather than ends: `WIRING_SITES` names viewer.py
and nothing else, one test asserts every listed site really does import the
family, and another asserts the viewer holds no module-level import of it.
Docs in §8.15 and §8.7.
…othing else leaks

A human browser check of the ATIF page found a silent loss. `export_atif`
writes an `agent_thought` as `reasoning_content` on the agent step it precedes,
so a faithful reading of a real H1 export gives a TOOL_CALL event carrying
`reasoning` — and this edge read `reasoning` only under AGENT_REASONING. The
value reached no step key and no loss record, which is the one thing the loss
model exists to make impossible. The suite was green throughout: on the ACP
corpus a thought is its own event, so the hole never opened.

`steps[].reasoning` is the fix, and it is a second additive key beside
`tool.name_semantics`. Two other placements were refused: a second `thought`
step would invent an event boundary and an ordering the source never declared,
and `steps[].text` would keep the string while losing that it is reasoning.
Strings are never joined to fit a slot. A reasoning event keeps exactly the
shape it already had.

Two remaining values with no honest slot now get a per-event record instead of
silence: a reasoning event that also carries user-visible text (the one text
slot is already holding the reasoning — declared unless the two strings are
equal, in which case the value is on the page), and a terminal signal on an
event that is not the timeout.

The renderer shows the value inside the card of the step that carried it, above
that step's own content, in the stylesheet's existing `.thinking` style. Not a
second card: inventing an event boundary one layer down is the same
fabrication. A test pins the with-reasoning cards against the viewer primitives
they copy.

What keeps this from recurring is the guard, not the three lines: every
string-bearing field of TraceEvent, on every EventKind, must reach the emitted
step or be named by a record — with the field list derived from the model, so a
new string field fails until given a disposition. The guard's predicate is a
function with its own negative control, because a predicate only ever asserted
in the positive direction is one nobody has tested.

The ATIF document's two identical `user` steps are deliberately untouched:
they are `export_atif`'s, documented in §5.2, and the page's job is to show the
document it was given. Docs in §8.14 and §8.15.
V1-V12 and V4b, all PASS, 2026-08-19, in a browser against the four corpora.
The table says what a person confirmed, and the paragraph after it says what
the sign-off does not extend to: four corpora on one machine in one browser,
the OTLP payload's content still a construction, oracle events and non-text
content blocks still test-only.

The verification found the reasoning loss fixed in the previous commit, which
is the third time in this work that a green suite was not evidence a document
was right. Recorded as such.

Also states, in one place, what this slice depends on and what it does not:
benchflow-ai#984 does not depend on benchflow-ai#1034 (design reference for the step vocabulary, no
code imported, every commit targets viewer.py as it stands on upstream/main);
the wiring is opt-in and the switch off is byte-identical; IR -> OTel stays
deferred on the terms of section 8.12; and the ATIF document's two identical
user steps are the production exporter's, documented in section 5.2, shown
faithfully rather than repaired here.

A BrokenPipeError seen from the stdlib server during V10 is recorded as an
incidental observation about serve(), not as a finding about this edge.
@Galius5136

Copy link
Copy Markdown
Author

Slices H and I: the viewer path, built against the viewer that exists, verified in a browser

Head 22239d15. Six commits: upstream/main merged as a base update (43 behind
before it, 0 after), a pure refactor of viewer.py, the adapter, one opt-in
wiring branch, a fix the human verification turned up, and the verification
record.

<format> → ir_from_* → CanonicalTrace → ir_to_view_steps → ir_to_view_html → page

The last edge renders a step list through the card builders viewer.py already
emits its ACP page with, so an ACP capture, an ATIF document and an OTLP payload
all reach the same renderer without a second viewer existing anywhere.

Built against main, not against #1034

benchflow-ai/benchflow#1034 is a design reference for the step vocabulary and
nothing else
: no code is imported, vendored or fetched from it, and every
commit here targets src/benchflow/trajectories/viewer.py as it stands on
main. If that PR never lands this works unchanged; if it lands, the content
transfers to its typed payload — category by membership, name_semantics,
reasoning, labelled diagnostics, timeout and unknown events — because none of
it is HTML-specific. This PR does not depend on that one.

Opt-in, and byte-identical when off

One branch in render_rollout, guarded by BENCHFLOW_VIEWER_TRACE_IR, with the
import inside the guarded function. Switch off — the default — is the ACP
path unchanged: six pages rendered from two captured rollouts, a raw ACP session
file and a Codex session file have the same SHA-256 before and after the
whole series. Switch on is the canonical path, and it never routes through
_render_acp_events: a test patches that function to raise, renders the
canonical page, and then proves the patch bites by rendering the same rollout
with the switch off.

Steps are never rebuilt into ACP capture events. The IR holds records ACP has
no type for (oracle, anything unknown), a status it cannot spell, and tool
names whose semantics are the point; forging capture events would launder all
three back into the assumption the hub exists to remove.

export_atif.py is untouched, no artifact changes, and
test_only_the_ir_family_imports_the_ir now carries a WIRING_SITES allowlist
naming viewer.py and nothing else — a second importer anywhere fails the
suite, and reverting the wiring commit unwires the family completely.

What the page gains, measured against the current one

On the two captured rollouts of §5.2, _render_acp_events and this edge, same
directories:

current page through the IR
the run that ends in a real wall-clock timeout 3 cards from 4 events; the word "timeout" appears nowhere 4 cards, one a typed timeout
an unrecognized record reaches no card a card labelled Canonical IR representation, body = the canonical event
tool output not on the page at all shown when the block carried text
an ATIF-only rollout directory <p>No trajectory files found</p> rendered

None of that is a bug being fixed — those are the renderer's four branches. This
edge has six step kinds to place, places them, and declares the difference. Three
tests pin the current behaviour as a fact so that if a branch is ever added, this
edge's reason for existing is revisited with it.

Classification is a table, never a substring

_tool_accent_class scans the tool kind and then the human title for needles.
It is not imported here, and an AST test asserts the name never appears in the
module. The hue arrives already decided — a category only when the source said
the string is one — and is mapped through a table over the eight display hues.

The same run makes the point: execute arrives as an ACP kind and gets the
execute accent; the identical string in that run's own trainer/atif.json
arrives as a function_name and stays neutral. OTel's read_file and
write_file stay neutral for the same reason.

One incidental observation about the current renderer, from a real capture
rather than a constructed example: a think tool call whose title is
Update topic to: "Executing System Commands" is painted with the bash/execute
accent, because the classifier falls back from the kind to the title and finds
the word Commands. Not touched here — flagging it because it is the same
mechanism, in your code, on a real rollout.

Human browser verification

V1–V12 and V4b, all PASS, in a browser, 2026-08-19. Legacy-vs-canonical on
the same directories, the timeout, the unrecognized record, the ATIF colour
result, the OTel neutrality, the untyped spans, tool output, the diagnostic
label, the byte-identical switch-off page, bench eval view on both paths, and
one request in the Network panel — the document itself, nothing external.

The verification found a real defect, and the suite had not

CanonicalEvent.reasoning was lost with no loss record whenever it
accompanied an event kind other than AGENT_REASONING. That is exactly what a
real ATIF export produces: acp_events_to_atif_steps writes an agent_thought
as reasoning_content on the agent step it precedes, so reading one back gives
a TOOL_CALL event carrying reasoning. On the ACP corpus a thought is its own
event, so the hole never opened and the suite stayed green throughout.

Fixed before the sign-off: an additive steps[].reasoning, rendered inside the
card of the step that carried it rather than as an invented second event, plus a
guard that is the real fix — every string-bearing field of TraceEvent, on
every EventKind, must reach the emitted step or be named by a record
, with
the field list derived from the model so a new string field fails until given a
disposition. The guard's predicate has its own negative control, and five
mutations (emission, the normalization record, the loss record, the rendering,
the guard) are all caught.

Third time in this work that a green suite was not evidence a document was
right. It keeps being the artifact-reading step that finds these.

Not claimed

Four corpora, one machine, one browser. The OTLP payload's encoding is
authoritative — it comes from opentelemetry-proto's own MessageToJson — but
its content is a construction: nothing in BenchFlow emits spans. Oracle
events and non-text content blocks stay test-only, as they have been since §8.3.
IR → OTel remains deferred on the terms of §8.12, a decision of ours that a
consumer or a contract would end.

The ATIF document's two identical user steps are not touched: one comes
from the prompts argument and one from the captured user_message, they are
acp_events_to_atif_steps's doing (§5.2 records the over-count), and the page's
job is to show the document it was given. Our own ir_to_atif does not write
them.

Docs: docs/trace-interop.md §8.14 and §8.15.

@Galius5136
Galius5136 marked this pull request as ready for review August 19, 2026 07:27
@ljr145733

Copy link
Copy Markdown

Hey @Galius5136. Viewer-side notes from #1034, since slice I is built against viewer.py as it stands on main and #1034 moves that ground:

  • viewer.py becomes a viewer/ package. render_rollout, _render_acp_events, _VIEWER_CSS, _WORDMARK_HTML all move verbatim to viewer/legacy.py, with the import surface re-exported from the package __init__ — so viewer.render_rollout(...) keeps working, but your _trace_ir_page branch and the factored page primitives won't apply as a clean diff. Whoever merges second rebases by hand; the port itself is mechanical.
  • One free win after rebase: _VIEWER_CSS is now composed from a shared theme (viewer/assets/theme.css, the skillsbench.ai tokens), so ir_to_view_html pages pick up the new look without changes.
  • The bigger integration question: in #1034 the ACP branch of render_rollout no longer emits the static card page — it renders an interactive page (tabs, in-trace search, timeline, browse catalog) from a typed payload contract (viewer/models.py: Step / ViewerPayload / RunSummary). Your if not turn_files: branch sits in front of that, so with BENCHFLOW_VIEWER_TRACE_IR=1 an ACP rollout would drop back to a static page. Suggest pointing IR → view at the payload contract instead of the HTML primitives — then ATIF/OTel sources get the interactive page for free, and the step list you build in slice H maps onto Step pretty directly. Can work through the field mapping whenever.

No preference on merge order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants