Deterministic lineage & dependency tracking in the discover inventory (#23) - #36
Open
matthewmoorcroft wants to merge 2 commits into
Open
Deterministic lineage & dependency tracking in the discover inventory (#23)#36matthewmoorcroft wants to merge 2 commits into
matthewmoorcroft wants to merge 2 commits into
Conversation
matthewmoorcroft
marked this pull request as ready for review
September 1, 2026 10:44
matthewmoorcroft
force-pushed
the
pr/discover-lineage
branch
from
September 1, 2026 10:44
efa1962 to
dc90650
Compare
Rewrite internal package-proxy URLs (pypi-proxy.dev.databricks.com) to pypi.org / files.pythonhosted.org so public CI resolves deps. Same pinned versions and hashes; matches main. Co-authored-by: Isaac <no-reply@databricks.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #23.
What
Adds a purely deterministic (no LLM)
lineageblock to the discover-phase inventory (metadata/inventory.json), assembled from the already-parsedAdfDefinitions:ExecutePipelinecaller→callee call edges (recursing intoForEach/Ifcontainers; unresolved callees from partial exports are recorded, not dropped).schema.tableor storage path), falling back to dataset name. Identity isnullwhen it can't be resolved deterministically — never a guess.Design: compose, don't duplicate
The physical-identity resolvers already existed in
translator/activity_translators/copy.pybut ran only at convert time. Rather than duplicate them, they are extracted into a shared parser-layer moduleparser/dataset_resolvers.pythat both convert (copy.py) and discover (parser/lineage.py) import. The relocation is behavior-preserving —copy.py's call sites are unchanged apart from imports, and the existing convert/copy tests are the safety net. A tiny sharedread_execute_pipeline_refhelper keeps the parser and the convert-time translator reading theExecutePipelinefields identically.Example output
The block always emits
control_edges/data_edgesas lists (empty, nevernull) for stable golden diffs.Testing
tests/unit/test_lineage.py(control fan-out, nested-in-ForEach recursion, unresolved callee, data-edge direction, identity join across differently-named datasets,identity: nullfallback, no self-edge, no duplicate edges) andtests/unit/test_dataset_resolvers.py(identity resolution incl. parameterized→null).test_adf_loader.py;reporting/coverage.pyreads onlypipelines/summaryand is unaffected.make fmt(ruff + mypy) clean.discoverrun over the test fixtures emits thelineageblock with 6 control edges and 5 data edges.Scope / non-goals
Deterministic extraction only. Explicitly out of scope, tracked separately:
This pull request and its description were written by Isaac.
Validated against a real factory + a limitation this surfaced
Ran the extractor against a real customer ADF factory (Factory A — 6 pipelines, 163 activities, the one in #23's evidence):
ExecutePipelinefan-out calls from the master orchestrator to its 5 child pipelines (nested inside the orchestrator's control flow), 0 unresolved callees. Exactly the documented shape.@dataset()folderPath/fileName, build tokens) with no literal table/path, so all resolve toidentity: null. Initial name-fallback join produced 416 data edges, 396 of them spurious: one dummy dataset is reused by ~40 activities that each point it at a different physical file via parameters — so joining on the shared ADF name manufactured hand-offs that don't exist.Resolution (in this PR): data edges now join only on resolved physical identity (
schema.table/ concrete storage path), never on dataset name. An edge therefore always means a provable physical hand-off. Factory A data edges: 416 → 0 (all were false); control edges unchanged.Limitation (by design)
Data lineage forms only where a dataset's physical identity is statically resolvable. Parameterized/expression-driven paths — common in real factories (Factory A is 100% this) — yield
identity: nulland therefore no data edge; the true coupling (e.g. the per-entity watermark CSV hand-off) is knowable only after runtime substitution, which the "never guess" rule declines to fake. Recovering those runtime-only couplings is exactly the remit of the agentic enrichment follow-up (#25), which can read the@concat(...)expressions and annotate the logical hand-off. Control lineage has no such limitation (it keys off staticExecutePipelinereferences).Update: data edges now use a two-tier deterministic join (recovers parameterized hand-offs)
Validation on Factory A showed identity-only join missed a real, common pattern: the CDC watermark hand-off (a per-entity version CSV written by one activity and read back by another) uses fully parameterized paths, so identity is
nulland no edge formed. This is exactly the coupling #23's evidence said "get wrong and the CDC increment breaks silently."Data edges now join on two deterministic tiers, tagged per edge via
match_kind+match_key:identity(high confidence) — both ends resolve to the same physical asset (schema.table/ concrete storage path).expression(structural) — when identity is unresolvable, both ends build the same normalized path signature: literal path segments kept, runtime references (pipeline().parameters.X,item().X,variables/dataset/activityrefs) collapsed to<P>slots. Anchored on a literal folder segment (a file extension alone is too weak), so it recovers genuine hand-offs without over-matching reused dummy datasets.Each
DataEdgecarriesmatch_kind("identity"/"expression") andmatch_key(the identity or the signature) so consumers and the #25 enrichment know the confidence tier and what coupled the two activities.Re-validated on both real factories:
expression— 24 on the watermark path (the [FEATURE]: Deterministic lineage & dependency tracking, surfaced in the inventory #23 hand-off, recovered), 1 reference-data lookup. No dummy-dataset explosion (the 396 false name-joins stay gone; the dummy logging writes split off by their distinct literal path).A residual limitation remains:
expressionedges are a structural match (same literal folder skeleton + slot shape), not a proven physical identity — they can in principle coincide, which is why they're tagged as a distinct, lower-confidence tier for #25 to weigh. Fully resolving the runtime value (e.g. which entity) is still out of scope.