Skip to content

Output Lineage Authoring

render_native_sheet_cell_explanation answers “why is this output cell that value?” for an output sheet view cell. Without any extra work, an uninstrumented run’s answer is honest but minimal: the cell is a literal value recorded when the sheet was generated, with no dependency graph behind it. This guide shows a model author or agent how to opt in to a richer answer — a real input → logic → output trace — by writing /outputs/output_lineage.json during the run.

A run that never writes output_lineage.json is just as valid as one that does. Add tracing where it earns its keep — headline outputs, anything an analyst is likely to ask “why is this number what it is?” about — not to every intermediate calculation.

The authoring surface is OutputLineageBuilder, an ergonomic Python builder that emits a valid output_lineage.json artifact. It has no dependency on the Bridge Town server package — only the Python standard library and pandas, both available inside the sandbox model-execution environment.

Copy services/mcp_server/output_lineage_helpers.py into your model repo’s lib/ directory (the same shared-helper-code convention the default run.py scaffold uses) as lib/output_lineage.py, then import it from your model file:

from lib.output_lineage import OutputLineageBuilder

Every trace belongs to one run. Use OutputLineageBuilder.from_environment rather than constructing the builder with your own run_id — the sandbox exposes the platform’s run id for the current execution as the BT_RUN_ID environment variable, and a trace with the wrong (or a hard-coded) run id fails the run rather than silently mismatching:

from lib.output_lineage import OutputLineageBuilder
builder = OutputLineageBuilder.from_environment(model_name="revenue_model")

from_environment raises LineageBuildError immediately if BT_RUN_ID is unset — for example if the model was invoked outside the sandbox — instead of writing an artifact that only fails later, at run validation.

  1. Create one OutputLineageBuilder for the run.
  2. Call .output(...) once per model output file you want to trace — this returns an OutputTraceBuilder scoped to that one output.
  3. On the output builder, declare nodes (input_value, assumption, logic_step, output_node) as you compute values, threading precedent nodes through so the builder wires the dependency edges for you.
  4. Register which output-sheet cell each output node explains (output_node does this in one call; use .coordinate() for extra cells that reuse an existing node, e.g. every column of one table row).
  5. Call builder.write() at the end of your model — it writes /outputs/output_lineage.json.

Node ids are generated deterministically from each node’s label, so running the same model twice produces byte-identical trace graphs. Every builder call validates as it goes — a bad cell reference, an unknown precedent, or a graph past the platform’s node/edge limits raises LineageBuildError immediately, with a live traceback into your model code.

Example: driver inputs → revenue build → margin

Section titled “Example: driver inputs → revenue build → margin”
from lib.output_lineage import OutputLineageBuilder
builder = OutputLineageBuilder.from_environment(model_name="revenue_model")
out = builder.output("output.json")
price = out.input_value("Unit price", 42.0, group="Pricing")
units = out.input_value("Units sold", 1_000, group="Volume")
revenue = out.logic_step("Revenue = price * units", price, units, value=42_000.0)
out.output_node("B2", "Total revenue", revenue)
cogs = out.input_value("Cost of goods sold", 25_000.0, group="Costs")
gross_profit = out.logic_step("Gross profit = revenue - COGS", revenue, cogs, value=17_000.0)
margin = out.logic_step(
"Gross margin = gross profit / revenue", gross_profit, revenue, value=0.405
)
out.output_node("C3", "Gross margin %", margin)
builder.write()

table_rows traces a whole rendered DataFrame table without requiring a call per cell — one node per row, with every column in that row mapped to it:

import pandas as pd
df = pd.DataFrame({
"product": ["Widgets", "Gadgets", "Gizmos"],
"revenue": [10_000.0, 20_000.0, 15_000.0],
})
pricing_driver = out.input_value("Pricing driver", 1.0, group="Pricing")
out.table_rows(
df,
anchor="A2", # top-left cell of the rendered table
row_label="product", # column to use as each row's node label
row_value="revenue", # column to use as each row's node value
row_precedents=lambda index, row: (pricing_driver,),
)

This maps A2/B2 to the Widgets row’s node, A3/B3 to Gadgets, and A4/B4 to Gizmos — three nodes total, not six.

sum_of and weighted_sum are convenience wrappers over logic_step for SUM/SUMPRODUCT-style calculations that auto-compute a node’s value from its precedents’ recorded values. XNPV/XIRR-style discounted cash-flow calculations don’t need a dedicated builder method — wire them the same way as any other logic step, passing the assumption and each cash-flow input as precedents.

Tag input_value/assumption nodes with group= to cluster related drivers (e.g. group="Pricing", group="Regions"). Explain renders at most five input groups per output; if your traced inputs span more, the builder automatically collapses the lowest-materiality groups into a single “Other” node when you call builder.build()/.write(). Pass materiality= (default 1.0) to weight which groups survive the collapse — a node referenced directly by an output coordinate is never collapsed away.

source_ref on input_value (and assumption) additionally lets you identify which input cell a value came from. By default it’s only a free-text display string:

price = out.input_value(
"Unit price", 42.0, source_ref=("input_sheet", "Drivers!B2")
)

If your model’s own input-loading code already resolved a real sheet_id/tab_id, pass an InputSourceRef instead of the plain tuple to attach canonical identity — this is what lets Explain jump straight to the one exact source cell instead of a best-effort text match:

from lib.output_lineage import InputSourceRef
price = out.input_value(
"Unit price",
42.0,
source_ref=InputSourceRef(
"input_sheet", "Drivers!B2", sheet_id="sht_abc123", tab_id="tab_drivers", cell_ref="B2"
),
)

ref is still required and still shown — the canonical fields are additive. sheet_id/tab_id must be real, canonical platform ids you already have (not the tab’s display name, not something you invent); if you don’t have them, omit them entirely and get a best-effort, never-exact match at explain time, same as today.

  • Pass sheet_id=/tab_id= to .output(...) — omit them; trace matching is by output_name plus cell coordinate.
  • Invent node ids — one is generated from the label unless you pass node_id= for a specific, stable id you want to reference from two different code paths.
  • Record a code reference by hand — logic_step and output_node capture your model file’s own call-site file/line automatically.
  • Build the “Other” grouping node yourself, or worry about the platform’s size/node/edge limits ahead of time — the builder raises immediately, at the offending call, if a graph would exceed them (512 KB serialized artifact, 25 outputs per artifact, 60 nodes and 150 edges per output). These are deliberately conservative — the artifact renders as a small DAG inside an iframe — so trace what earns its keep, not every intermediate calculation.