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.
Tracing is opt-in
Section titled “Tracing is opt-in”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.
Getting the helper into your model repo
Section titled “Getting the helper into your model repo”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 OutputLineageBuilderThe run id
Section titled “The run id”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.
The mental model
Section titled “The mental model”- Create one
OutputLineageBuilderfor the run. - Call
.output(...)once per model output file you want to trace — this returns anOutputTraceBuilderscoped to that one output. - 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. - Register which output-sheet cell each output node explains
(
output_nodedoes this in one call; use.coordinate()for extra cells that reuse an existing node, e.g. every column of one table row). - 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()Example: table-row outputs (pandas)
Section titled “Example: table-row outputs (pandas)”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.
Grouping and the five-group limit
Section titled “Grouping and the five-group limit”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.
Canonical input-cell identity
Section titled “Canonical input-cell identity”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.
What you don’t need to do
Section titled “What you don’t need to do”- Pass
sheet_id=/tab_id=to.output(...)— omit them; trace matching is byoutput_nameplus 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_stepandoutput_nodecapture 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.
What’s next
Section titled “What’s next”render_native_sheet_cell_explanation— the tool that reads the trace you write here.- Agent Workflow Cookbook — the end-to-end create/run/output/dashboard workflow this guide slots into after Step 3 (run the model).
- Native Sheets formula reference — what native sheet formulas can express, if the number you’re tracing itself lives in a sheet formula rather than model code.