Skip to content

Output & Input Lineage Authoring

render_native_sheet_cell_explanation answers two questions with the same underlying trace: “why is this output cell that value?” on an output sheet view cell (the precedents direction), and “what does this input cell drive?” on an input sheet cell (the dependents direction). Without any extra work, an uninstrumented run’s answer is honest but minimal: an output cell is a literal value with no dependency graph behind it, and an input cell’s dependents are inferred from a legacy text match rather than a canonical cell reference. This guide shows a model author or agent how to opt in to a richer answer for both directions — 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.

Download the immutable output_lineage.py v1.1.0 source from Bridge Town’s public GitHub release. Its SHA-256 digest is a7e5bbb4eaff046b642e9c49b29e40ce530e85ab33869335d57e0ff2d503967e. The release also publishes the complete SHA256SUMS file, source at the immutable tag, example, changelog, license, and ownership record.

Verify the file outside the network-isolated model sandbox:

Terminal window
curl -fL -o output_lineage.py \
https://github.com/Bridge-Town/financial-modeling-mcp/releases/download/model-authoring-helpers-v1.1.0/output_lineage.py
printf '%s %s\n' \
a7e5bbb4eaff046b642e9c49b29e40ce530e85ab33869335d57e0ff2d503967e \
output_lineage.py | sha256sum --check

Copy the verified file byte-for-byte into the model repository as lib/output_lineage.py using create_file or a one-entry commit_files update, then import it from the model file:

from lib.output_lineage import OutputLineageBuilder

Don’t reconstruct OutputLineageBuilder from the examples in this guide — a near-miss implementation can look right and still fail run validation, or silently emit a malformed trace. The examples illustrate usage; the tagged, hash-verified helper is the implementation source of truth. The file imports only Python’s standard library and pandas, which the model sandbox already provides, and it makes no network calls.

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.

Canonical input-cell identity powers both directions

Section titled “Canonical input-cell identity powers both directions”

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 one call is what upgrades both directions from an inferred, best-effort match to an exact one:

  • Backward (precedents, on the output cell): lets Explain jump straight to the one exact source cell instead of a best-effort text match.
  • Forward (dependents, on the input cell): lets render_native_sheet_cell_explanation answer “what does this cell drive?” with an exact, run-scoped list of the output cells it feeds — see What does it drive? below.
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 does it drive? (forward lineage on an input sheet)

Section titled “What does it drive? (forward lineage on an input sheet)”

Once an output trace records a source_ref with a canonical sheet_id/ tab_id/cell_ref (as in the InputSourceRef example above), the same recorded identity answers the reverse question directly from the input sheet: “what does this cell drive?” Call render_native_sheet_cell_explanation on the input sheet’s cell — direction defaults to dependents there, so you don’t need to pass it explicitly:

{
"name": "render_native_sheet_cell_explanation",
"arguments": {
"model_name": "revenue-forecast",
"sheet_id": "sht_abc123",
"tab_id": "tab_drivers",
"cell_ref": "B2"
}
}

With canonical identity recorded, the response’s identity_state is exact and dependent_output_count/dependent_model_count report a real, run-scoped count of the output cells this input feeds — bounded to the latest eligible run on the current branch, never inferred or invented.

Without canonical identity, identity_state degrades to partial, inferred, or ambiguous depending on how much the legacy text match could resolve, and the response’s warning field names this guide so the model author or agent knows exactly how to fix it: record an InputSourceRef with sheet_id/tab_id/cell_ref on the corresponding input_value (or assumption) call, as shown above. There is no separate opt-in for the forward direction — the same output-trace instrumentation drives both.

  • .output(...) does not accept output sheet_id or tab_id; trace matching uses output_name plus cell coordinate. Stored artifacts containing those fields remain readable, but new authoring code cannot emit them.
  • 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.