Semantic model AST
The normalized shape every source format parses into. Power BI facts that the type definitions cannot state on their own; read this before changing the AST or adding an ingestion format.
What is modeled, and what is not
Modeled: tables, columns, measures, partitions, relationships, hierarchies, RLS roles,
calculation groups, KPIs, and model-level shared M expressions. Columns also carry their
variations (TOM variation) — the declaration a report's date-hierarchy binding resolves
through — and tables carry the engine's auto date/time identity flags (is_private,
is_local_date_table, is_template_date_table).
Deliberately absent: data sources, perspectives, cultures and translations, role
memberships, annotations, linguistic metadata. None of them consume model objects, so
none can keep an object alive, so none affect reachability. Adding them later is additive
and breaks nothing — but do not add them speculatively. The single exception proves the
rule: the two engine annotations __PBI_LocalDateTable and __PBI_TemplateDateTable are
consumed, because they describe an object (which tables are auto date/time machinery)
and that description is load-bearing for the linter's verdict — not because they
reference anything.
Also absent for the same reason: data types, display folders, source column names, calculation-item precedence. They describe objects; they never reference them.
Power BI semantics the types don't show
A calculated table is not a flag. It is a table whose partition source is DAX rather
than M. Table::is_calculated() derives it from the partition; there is no boolean to
set, and an ingestion format that invents one is wrong.
A calculation group is a property of a table, not a top-level object. A calculation
group table also carries synthetic columns — the group's field column and an ordinal
column — in columns like any other table. Nothing distinguishes them structurally, and
DAX can reference them ('Time Intelligence'[Period] = "YTD"), so ingestion must map
them or those references will not resolve.
Measure names are unique across the whole model, not per table. A measure's home table is provenance for display, not part of its identity for lookup. See name-resolution.md.
Relationships default to active. TMDL omits the flag for active relationships, so
Relationship::default() sets is_active: true. An inactive relationship still keeps its
key columns alive — USERELATIONSHIP can switch it on at query time — so the flag is for
reporting and linting, never for liveness.
A column's sort-by column is a liveness edge. A used column keeps the column it sorts by alive, even when nothing else references it.
Auto date/time is identity, not liveness. The engine generates a hidden
LocalDateTable_* per date column (plus one DateTableTemplate_*), and the model says
which tables those are: the __PBI_*DateTable annotations, isPrivate, and — for formats
that carry neither — the LocalDateTable_/DateTableTemplate_ name prefixes. The flags
are display-only metadata; they never confer or remove liveness. Two things read them:
the graph's hierarchy-binding resolution (a visual's date hierarchy over a varied column
lands on the related LocalDateTable_* through Column::variations), and
DependencyGraph::auto_date_time_tables — the provenance-based verdict (in use /
unused by reports / dead) that scan renders in its own section. That verdict is
deliberately not reachability: the engine's own relationship keeps the machinery alive
for as long as the user's date column is used, so "alive" says nothing about whether a
report binds it.
Partition sources are four, not two. M (Power Query), DAX (calculated table), a legacy
native query in the data source's own dialect, and Other for DirectLake entity
partitions, inferred partitions, and kinds Microsoft has not shipped yet. Other and
Query yield no DAX and no M, and must never be guessed at — an unrecognized source
lands in Other, which is why its Default is not derived.
Expressions hide in unobvious places
Missing one expression site means the objects it references get no edges and are reported unused. That is a false positive, and the scan design forbids them. Beyond the obvious measure and calculated-column expressions, DAX also lives in:
- dynamic format strings, on measures and on calculation items
- KPI target, status, and trend expressions
- detail-rows (drillthrough) definitions, on measures and on tables
- RLS filters, one per role per table
- calculation item expressions
TabularDatabase::dax_expressions() and m_expressions() are the only enumeration of
these sites. The graph layer consumes those two functions instead of walking the AST, so
a new expression-bearing field cannot be silently omitted from reachability analysis.
Adding an expression field to the AST means adding it to the enumeration — the tests
assert every kind is produced exactly once from a fixture that exercises all of them.
Enumerated expressions borrow their owner's names rather than carrying an owned
ObjectId, so walking every expression in a model allocates nothing. The graph layer
calls to_object_id() once per node it actually creates, instead of once per
expression — a measure with a format string, detail rows, and a KPI would otherwise
pay for six identical owner keys. A test pins both views as Copy to keep it that way.
Each enumerated expression carries a home table: the row-context table used to resolve unqualified references inside it. For an RLS filter that is the permission's target table, not anything belonging to the role. For a calculated table's partition it is the calculated table itself, which is deliberately conservative — unqualified columns in such an expression usually belong to the source table, so this can only add candidate edges, never drop them.