Skip to content

refactor: add source-neutral discovery graph contract and shared lineage core - #84

Open
matthewmoorcroft wants to merge 19 commits into
mainfrom
discovery/shared-ast-standard
Open

matthewmoorcroft wants to merge 19 commits into
mainfrom
discovery/shared-ast-standard

Conversation

@matthewmoorcroft

@matthewmoorcroft matthewmoorcroft commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Intent

Define one source-neutral discovery graph contract (SourceGraph) that ADF and Airflow can both project into before any Databricks IR. It is persisted as a versioned, hashed source_graphs.json, and one shared inventory.json builder serves every source. This is Phase 1 groundwork for lineage-aware routing. It is additive, and nothing in discover, convert or package calls it yet.

What changed

  • models/discovery.py: SourceGraph, SourceNode, ContainerNode and GapNode, plus schedule, parameter, policy and dependency specs. A graph carries raw, properties and extensions; a node carries raw and properties.
  • models/lineage.py, ir_serde.py: source-neutral lineage types (DataAsset, ControlEdge, DataEdge, MotifAnnotation, Lineage), shared by the discovery graph and the IR, and their serialisers. models/ir.py re-exports them.
  • lineage.py, discovery_lineage.py: shared lineage derivation.
    • Control edges have no self-edges, are deduplicated, and keep unresolved callees with resolved=False.
    • Data edges match on a resolved physical identity first, then on signature; a signature-only match carries no identity.
    • Container branches are walked fully, including Switch defaults and ForEach and Until bodies.
  • discovery_serde.py: the SourceGraph JSON round trip.
    • Node kinds are kept, and an empty PolicySpec comes back as PolicySpec().
    • The source_graphs.json envelope has a contract version, a hash per graph and a document hash, all checked on read.
  • discovery_inventory.py: build_source_inventory, the single source-agnostic inventory.json emitter. It writes the source, each activity's task_key, dependencies and raw, a per-pipeline lineage block, detected motifs (members left in place) and an optional source_graphs_sha256.
  • Greg's review: a named callee missing from the export is now unresolved when the caller passes known_workflows; the source_graphs.json reader requires both hashes, and both reader and writer refuse graphs from another source; an unknown node_type raises; data edges are documented as matching assets, not run order.

How it was tested

  • Unit tests for the model, the serde (round trip and hash verification), both lineage tiers and the inventory emitter. The full unit suite, ruff and mypy pass in CI.
  • ADF discover, convert and package on the 40 fixture pipelines: bundles and translation reports are byte-identical before and after.
  • Codex cross-review, read-only, twice. Round 1 found two issues, the signature-tier identity and the empty PolicySpec round trip. Both are fixed in bbd00f1 with regression tests that fail on the previous code. Round 2 found none.

Merge order

Merge #84 first, then #94, which then retargets to main. #95, #96 and #97 follow in order.

Peter's #108 rebases onto main afterwards. Its only conflicts are the expected add/add on discovery_inventory.py and tests/unit/test_discovery_inventory.py. Take #84's copy of discovery_inventory.py. In the test file, take #84's copy but keep #108's test_emitter_has_no_source_specific_imports, which #84 does not carry.

This pull request and its description were written by Isaac.

PR A of a two-PR split: the DISCOVERY side only. Defines the shared,
source-neutral standard that both ADF and Airflow map onto, with zero
convert/IR-Activity coupling. The convert->package lineage plumbing is deferred
to the stacked follow-up PR.

- models/ir.py: the five standalone, source-neutral lineage TYPES only --
  DataAsset, ControlEdge, DataEdge, MotifAnnotation, Lineage. The Activity-base
  fields (data_reads/data_writes/motif_id), the MotifActivity base-field change,
  and Pipeline.lineage are NOT here (deferred). MotifAnnotation's docstring no
  longer references the deferred Activity.motif_id.
- lineage.py: only the neutral primitive cores control_edges_from_calls,
  _match_assets, data_edges_from_endpoints (task_key strings + DataAsset only,
  no Activity/Pipeline import). The IR-Activity-facing build_* layer is deferred.
- ir_serde.py: only the neutral helpers data_asset_to_dict /
  data_asset_from_dict / lineage_to_dict. No Pipeline.lineage or Activity-base
  emission and no MotifActivity change (deferred).
- models/discovery.py, discovery_lineage.py, discovery_serde.py: the full shared
  discovery AST -- SourceGraph/SourceNode/ContainerNode/GapNode + ScheduleSpec/
  ParameterSpec/PolicySpec/SourceDependency, the neutrality fields
  (run_condition, default_policy, run_timeout_seconds, open best-effort
  data-asset vocabulary), source-neutral graph lineage derivation, and serde.
- tests: test_discovery_model.py + test_discovery_lineage.py only.

No changes to bundler/dab_writer.py. Rebased onto main so the airflow-loader
split (#54/#55) is not spuriously reverted.

Co-authored-by: Isaac <no-reply@databricks.com>
…ineage (#61)

with_graph_lineage's docstring referenced :func:`flowx.lineage.with_lineage`,
which lives only in the stacked conversion PR -- the discovery-only PR's reduced
lineage.py has just the neutral cores. Reword to describe the behaviour in place
(pure; attaches via dataclasses.replace) so this PR references nothing external
to it. Docstring only; no import, call, or behaviour change.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 6 commits October 5, 2026 11:52
…ract

Rename the "shared discovery AST" wording in docstrings to "discovery graph
contract" and say plainly that each source keeps its own parser and projects
into SourceGraph. Wording only; no symbols change.

Co-authored-by: Isaac <no-reply@databricks.com>
source_graphs_document / write_source_graphs build metadata/source_graphs.json
with the same {contract_version, source, graphs} keys Airflow discover already
writes, plus graph_sha256 and document_sha256 over canonical JSON.
read_source_graphs accepts any contract_version "1" file, including one
without hashes, and fails closed when a recorded hash does not match.

Co-authored-by: Isaac <no-reply@databricks.com>
Move the source-agnostic inventory projection into the base PR so ADF and
Airflow use one module: motifs_by_pipeline from the ADF discovery work, the
inventory_visible filter from the Airflow source-graph work (same signature),
and task_key on every activity entry so source layers and motif members can
join on it. Carries both test sets.

Co-authored-by: Isaac <no-reply@databricks.com>
… inventory

Motifs are deterministic facts about the source, so a source now records them
on the graph (SourceGraph.lineage.motifs) and they are saved and hashed with
it in source_graphs.json. The shared emitter reads them from there instead of
a separate argument, keeps the inventory's own lineage block motif slot empty
as before, and records the source_graphs document hash as a top-level
source_graphs_sha256 when the caller passes it.

MotifAnnotation gains an optional source_type_hint, written only when set, so
existing IR lineage output stays byte-identical.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft matthewmoorcroft changed the title Shared source AST standard (discovery + lineage types) Source-neutral discovery graph contract: SourceGraph, lineage, persisted source graphs and one inventory emitter Oct 5, 2026
matthewmoorcroft added a commit that referenced this pull request Oct 5, 2026
…to stack/routing-agentic

Co-authored-by: Isaac <no-reply@databricks.com>
A signature-tier data edge no longer borrows the one resolved endpoint's identity,
so a weak match never looks physically proven. An empty PolicySpec, serialised as
{}, now comes back as PolicySpec() instead of None, for graph default policies and
node policies alike. Both have regression tests that fail on the previous code.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft matthewmoorcroft changed the title Source-neutral discovery graph contract: SourceGraph, lineage, persisted source graphs and one inventory emitter refactor: add the source-neutral discovery graph contract and shared lineage core Oct 7, 2026
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
… stack/adf-discovery

Carries #84's Codex fixes forward: signature-tier data edges claim no identity, and
empty policies survive the source-graph round trip.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…ntic-insights

Carries #84's and #94's Codex fixes forward: identity-free signature matches, empty
policy round trips, grammar-based ARM detection, parameterised file locations, no
identity for run-time source or target overrides, and the run-now control-edge skip.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…agentic-component

Carries #84's, #94's and #95's Codex fixes forward, including the authored_against binding
for enrich and the paired source_insights.json and inventory.json write.

Co-authored-by: Isaac <no-reply@databricks.com>

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, requested a few changes. The contract and shared lineage core are clean and well tested. Two things should be fixed before merge: a named but missing callee still gets marked as resolved, and the hash check can be skipped by deleting the hash fields. The other comments are smaller.

Comment thread src/flowx/lineage.py Outdated
target_workflow=target_name,
via_task_key=via_task_key,
wait_for_completion=wait,
resolved=bool(target_name),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any call with a name gets resolved=True, even when that pipeline isn't in the export. So a call to a missing pipeline looks resolved. Could the caller pass the set of known workflows?

resolved=target_name in known_workflows,

Comment thread src/flowx/discovery_serde.py Outdated
if not isinstance(graphs, list):
raise ValueError("source_graphs document has no 'graphs' list")
recorded_graph_hashes = document.get("graph_sha256")
if recorded_graph_hashes is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both hash checks are skipped when the field is missing, so deleting graph_sha256 and document_sha256 gets an edited file past verification. Since v1 always writes them, I think the reader should require them:

if recorded_graph_hashes is None:
    raise ValueError("source_graphs document is missing graph_sha256")

Comment thread src/flowx/lineage.py

edges: list[DataEdge] = []
seen: set[tuple[str, str, str, str]] = set()
for producer_key, producer_asset in producer_list:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this pairs every writer with every reader of the same asset, no matter the run order. So two parallel writers, or a writer that runs after the reader, still get a "proven" hand-off. It's worth a short comment saying so, plus tests for multiple-writer and reversed-order cases.

if concept:
common["concept"] = concept

node_type = raw.get("node_type", "SourceNode")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: an unknown node_type quietly becomes a SourceNode and drops any extra fields. Raising a ValueError for anything other than the three known kinds would be safer.

raise ValueError(
f"Unsupported source_graphs contract_version {version!r}; expected {SOURCE_GRAPHS_CONTRACT_VERSION!r}"
)
graphs = document.get("graphs")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small suggestion: check that each graph's source matches the document's source. Right now a document labeled adf can hold Airflow graphs and still pass every hash check.

matthewmoorcroft and others added 3 commits October 15, 2026 09:57
…ode types

- control_edges_from_calls / build_graph_control_edges / build_graph_lineage take a
  keyword-only known_workflows; a named callee missing from the export stays
  unresolved (same signatures and rule as the ADF discovery layer above).
- source_graphs reader requires graph_sha256 and document_sha256 and refuses a
  graph whose source differs from the document's.
- An unknown node_type raises instead of becoming a plain SourceNode.
- Note that data hand-offs ignore run order, with tests for parallel writers and
  a writer that runs after its reader.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft matthewmoorcroft changed the title refactor: add the source-neutral discovery graph contract and shared lineage core refactor: add source-neutral discovery graph contract and shared lineage core Oct 15, 2026
matthewmoorcroft and others added 2 commits October 15, 2026 10:35
source_graphs_document (and so write_source_graphs) now raises the same
ValueError the reader raises when a graph's source differs from the
document's, so discover cannot save a file a later phase would refuse.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

The requested changes are applied at fb38c14 (fix commit 6d41efb, plus follow-ups):

  1. Missing callee: control_edges_from_calls, build_graph_control_edges, build_graph_lineage and with_graph_lineage take a keyword-only known_workflows. A named callee not in that set comes back with resolved=False. The signature and rule match the one feat(adf): save source graphs with dataset lineage at ADF discover #94 uses.
  2. Hash bypass: the source_graphs.json reader now requires both graph_sha256 and document_sha256 and raises when either is missing, using your suggested check.
  3. Run order: added a comment in data_edges_from_endpoints and on DataEdge saying the pairing matches assets, not run order. Added tests for two parallel writers and for a writer that runs after its reader.
  4. Unknown node_type: anything other than SourceNode, ContainerNode or GapNode now raises ValueError.
  5. Source mismatch: the reader raises when a graph's source differs from the document's source. The writer (source_graphs_document / write_source_graphs) now raises the same error, so a mismatched document cannot be saved in the first place.

Each fix has a test that failed on the previous code, except point 3, whose tests pin the existing behaviour as you asked.

matthewmoorcroft added a commit that referenced this pull request Oct 15, 2026
The only conflict was the lineage.py import block; the merged file keeps
both sides' imports. The shared core (control_edges_from_calls,
data_edges_from_endpoints, _match_assets) matches #84's code.

Co-authored-by: Isaac <no-reply@databricks.com>

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. Found a few additional items we should address before merging.

Comment thread src/flowx/lineage.py Outdated
target_workflow=target_name,
via_task_key=via_task_key,
wait_for_completion=wait,
resolved=bool(target_name) and (known_workflows is None or target_name in known_workflows),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this works when known_workflows is passed, but it still defaults to None, and then any named call counts as resolved again. Nothing in src/ passes it yet, so that's the path real code takes. Could we make it required, or treat None as "unknown"?

resolved=bool(target_name) and known_workflows is not None and target_name in known_workflows,

}
_check_graph_sources(document)
document["graph_sha256"] = [_canonical_sha256(graph) for graph in document["graphs"]]
document["document_sha256"] = _canonical_sha256({key: document[key] for key in _HASHED_DOCUMENT_KEYS})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says document_sha256 covers the whole envelope, but it only hashes contract_version, source and graphs. Extra top-level keys get through without being hashed or rejected. Either reject unknown keys on read or fix the docstring.

doc["extra"] = {"injected": True}
source_graphs_from_document(doc)  # still passes

Comment thread src/flowx/discovery_inventory.py Outdated
node for node in _flatten_nodes(graph.tasks) if node.properties.get(INVENTORY_VISIBLE_PROPERTY, True)
]
for node in flattened:
strategy = node.properties.get(STRATEGY_PROPERTY)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A node with no strategy, or one we don't recognize, counts toward unsupported_count, but its activity entry still shows the raw value (None or whatever it was). So the summary and the activity list disagree. Could the entry say "unsupported" too?

Comment thread src/flowx/discovery_lineage.py Outdated
Returns:
A shallow copy of *graph* with :attr:`SourceGraph.lineage` populated.
"""
return dataclasses.replace(graph, lineage=build_graph_lineage(graph, known_workflows=known_workflows))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_graph_lineage swaps in a brand-new Lineage, so any motifs already on the graph are quietly lost. Worth keeping them, plus a test that calls it after motifs are attached.

motifs = graph.lineage.motifs if graph.lineage else []

Comment thread src/flowx/discovery_inventory.py Outdated
return inventory


def _flatten_nodes(nodes: list[SourceNode]) -> list[SourceNode]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: _flatten_nodes does the same walk as walk_nodes in discovery_lineage.py. Could this just be list(walk_nodes(nodes)), so the two can't drift apart?

…y strategy

- control_edges_from_calls treats known_workflows=None as unknown, so a named
  callee is resolved only when the export's workflow names confirm it.
- The source_graphs reader rejects top-level keys outside the envelope and its
  two hashes, and the docstrings say exactly what document_sha256 covers.
- A node with a missing or unrecognised strategy is listed as "unsupported" in
  its inventory entry, matching the summary count.
- with_graph_lineage keeps motifs already recorded on the graph.
- The inventory walks nodes with discovery_lineage.walk_nodes instead of its own
  copy of the same walk.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 2 commits October 18, 2026 21:48
Stack-wide review pass against the reviewer's checklist:
- an empty identity no longer joins on the identity tier, and a signature
  match needs the same asset_type when both sides name one;
- a non-string invoke target is an empty, unresolved callee, and a
  non-boolean wait flag is unknown;
- source_graphs.json reads a missing or non-boolean resolved as unresolved,
  rejects branches or reason on the wrong node kind, and raises ValueError
  for a non-object document or a malformed graph.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

The second round of changes is applied at bec41c5:

  1. known_workflows=None now means unknown, using your line: a named callee is resolved only when known_workflows is given and contains it. I kept the argument optional rather than required, since nothing reads resolved yet and the one real caller already passes the export's workflow names; without them every edge is now unresolved.
  2. The source_graphs.json reader now rejects any top-level key outside contract_version, source, graphs and the two hashes, so nothing can ride along unhashed. No writer adds other keys, so this breaks no compatibility path. The docstring now says document_sha256 covers contract_version, source and graphs.
  3. A node with a missing or unrecognised strategy is now listed as unsupported in its activity entry, matching the summary count. Both read the same helper.
  4. with_graph_lineage keeps motifs already on the graph, with a test that attaches motifs first.
  5. The inventory now walks nodes with walk_nodes; _flatten_nodes is gone.

Points 1 to 4 each have a test that failed on the previous code. Point 5 is a refactor covered by the existing ordering test.

I also went over the whole diff again, against the themes from both of your reviews here and on #94, and fixed a few more of the same kind:

  • an empty identity no longer joins two assets;
  • a weak name match needs the same asset_type when both sides set one;
  • a non-string callee marker is an empty, unresolved target, not str(value);
  • reading back a missing or non-boolean resolved gives unresolved, and a non-boolean wait_for_completion gives None;
  • inventory_visible hides a node only when it is exactly False;
  • branches or reason on the wrong kind of node raises;
  • a malformed source_graphs.json raises ValueError instead of crashing.

Each has a test that failed before the change.

matthewmoorcroft added a commit that referenced this pull request Oct 19, 2026
Brings in #84's second review round: fail-closed known_workflows, empty
identities as unresolved, the exact asset_type rule, and the strict envelope.

discovery_serde conflict: take #94's side, which reads lineage through the
shared ir_serde.lineage_from_dict, and keep #84's new asset_type sentence in
the module docstring. #84's wait_for_completion rule moves into ir_serde in
the next commit.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 19, 2026
…workflows

#84's final head reads a control edge's wait_for_completion as None unless it
is a real boolean. #94 reads lineage through the shared
ir_serde.lineage_from_dict, so the rule now lives there, covered by a direct
test that fails without it and by #84's source-graph tests.

#84 also makes an omitted known_workflows fail closed, so the IR test for a
RunJobActivity control edge passes the workflows it expects to resolve.

Co-authored-by: Isaac <no-reply@databricks.com>

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good groundwork. The lineage rules and hash checks are solid and well tested, and I didn't find any bugs. Add the nested-container tests before the source PRs build on this.

Comment thread src/flowx/models/discovery.py Outdated
Comment on lines +15 to +20
lost. Every graph and node carries:

* ``raw`` -- the verbatim source dict (the ADF activity/pipeline JSON, the
Airflow capture), so a mapper can always fall back to the original; and
* ``properties`` / ``extensions`` -- a free-form bag for platform-specific
attributes that have no shared typed field yet.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says every graph and node has properties / extensions, but SourceNode only has properties and raw. Please fix the wording here and in the PR description so later mappers don't go looking for a node extensions field.

Comment thread src/flowx/lineage.py
Comment on lines +91 to +99
if producer.identity and consumer.identity:
if producer.identity == consumer.identity:
return "identity", producer.identity, producer.identity
return None
if producer.asset_type and consumer.asset_type and producer.asset_type != consumer.asset_type:
return None
if producer.signature and producer.signature == consumer.signature:
# A signature match proves nothing physical, so it never claims either side's identity.
return "signature", producer.signature, None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An identity match skips the asset_type check that the signature match does, so a "table" and a "file" with the same identity still get linked. Either check the type here too or note why it's fine.

Comment thread src/flowx/lineage.py Outdated
Comment on lines +58 to +59
key = (source_workflow, target_name, via_task_key)
if key in seen:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The dedupe key leaves out wait_for_completion, so two edges that differ only in that setting quietly become one, and the first one wins. Fine for now, but add wait to the key or say that the first one wins.

Comment on lines +150 to +181
def test_data_edges_recurse_into_switch_and_foreach_branches() -> None:
"""A writer buried in a Switch case hands off to a reader in a ForEach body."""
writer = _node("writer", writes=[DataAsset(signature="ds", identity="x.y")])
reader = _node("reader", reads=[DataAsset(signature="ds", identity="x.y")])
switch = ContainerNode(
source_id="sw",
task_key="sw",
concept="switch",
source=SOURCE_ADF,
branches={"caseA": [writer], "default": []},
)
loop = ContainerNode(
source_id="fe",
task_key="fe",
concept="loop",
source=SOURCE_ADF,
branches={"body": [reader]},
)
edges = build_graph_lineage(_graph(switch, loop)).data_edges
assert [(edge.source_task_key, edge.target_task_key) for edge in edges] == [("writer", "reader")]


def test_walk_nodes_visits_every_branch_including_default() -> None:
switch = ContainerNode(
source_id="sw",
task_key="sw",
concept="switch",
source=SOURCE_ADF,
branches={"caseA": [_node("in_case")], "default": [_node("in_default")]},
)
keys = [node.task_key for node in walk_nodes([switch])]
assert keys == ["sw", "in_case", "in_default"]

@ghanse ghanse Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests for an invoke inside a nested container, or a container inside a container. The code handles both, so a test should validate the behavior.

Comment thread src/flowx/discovery_inventory.py Outdated
Comment on lines +1 to +60
"""Source-agnostic projection of the discovery graph contract to ``inventory.json``.

The discover phase writes ``metadata/inventory.json`` and the reporting layer
(:mod:`flowx.reporting.coverage`) and MCP surface (:mod:`flowx.mcp.runner`) read
it back. Historically each source built that JSON straight from its own AST, so
the shape drifted per source. This module is the single place that turns the
discovery graph contract (:mod:`flowx.models.discovery`) into the inventory shape, so
every source that maps onto :class:`~flowx.models.discovery.SourceGraph` emits the
*same* top-level document -- ``{source, source_dir, pipelines, summary}`` -- from
one code path.

The projection is deliberately small and additive over the historical ADF shape:

* top level gains a ``source`` discriminator (``"adf"`` / ``"airflow"``);
* each activity keeps its byte-compatible ``name`` / ``type`` / ``strategy`` (and
``depends_on`` names when present) and gains the standardised ``task_key``
(the node's unique key, which motif ``member_task_keys`` and source-specific
layers join on), ``original_type``, ``dependencies`` (upstream **with
conditions**), and the verbatim per-node ``raw``;
* a node whose ``properties`` mark it ``inventory_visible: False`` (for example an
Airflow TaskGroup kept only for structure) is left out of the activity list and
the counts, while its children are still listed; any value other than exactly
``False`` keeps the node listed and counted;
* each pipeline entry gains an additive ``lineage`` block (control + data edges)
when its :class:`~flowx.models.discovery.SourceGraph` carries derived lineage;
* each pipeline entry gains an additive ``motifs`` list -- the multi-activity
patterns a source *detects* at discover time and records on the graph itself
(``SourceGraph.lineage.motifs``, so they sit inside the hashed source graph),
surfaced here **without** collapsing the member activities. In the inventory
they stay **decoupled from the lineage block**: the inventory's
``lineage.motifs`` is always empty, as it always has been; the key is omitted
when a pipeline has no detected motif. Collapse remains a *convert* decision
(:mod:`flowx.motifs.collapser`), never a discover one, so every member activity
still appears as its own entry in ``activities``;
* the ``summary`` keeps the historical count block, and a top-level
``source_graphs_sha256`` records which persisted ``source_graphs.json`` the
inventory was projected from, when the caller passes it.

Lineage is placed per pipeline -- one block beside that pipeline's ``activities``
-- to mirror the shared discovery serde, where lineage is a per-graph field
(:func:`flowx.discovery_serde.source_graph_to_dict`). The block is produced by the
one shared serialiser (:func:`flowx.ir_serde.lineage_to_dict`, the same one the
serde consumes), so its edges have the same shape as the serde's. Unlike the
serde's block, its ``motifs`` is always empty: motifs appear only under the
pipeline's own ``motifs`` key, so reading the inventory block back through
:func:`flowx.discovery_serde.source_graph_from_dict` recovers the edges but not the
motifs. It is a new key only: a graph with no derived lineage (``graph.lineage is None``) omits it
entirely, so the historical consumer keys (``source`` / ``pipelines`` /
``activities`` / ``summary``) are untouched.

A node's translation ``strategy`` is a Databricks-*target* classification rather
than a source concept, so it is not a typed field on the discovery graph. By
convention a mapper stashes it under ``node.properties["strategy"]`` (see
:data:`STRATEGY_PROPERTY`); this module reads it there. Detected motifs are
different: they are deterministic facts about the source, so a source records
them on the graph as :class:`~flowx.models.ir.MotifAnnotation` entries in
``lineage.motifs`` and this module projects them. Anything else a source wants to layer on -- Airflow's
audited-count block, findings, reconciliation status -- rides additively on top
of this base and is out of scope here.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This module docstring reads like a design doc. A short summary would do, since the details already live on the functions (same goes for the top of models/discovery.py).

Comment thread src/flowx/models/discovery.py Outdated
from dataclasses import dataclass, field
from typing import Any

from flowx.models.ir import DataAsset, Lineage

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Discovery runs before IR but imports DataAsset / Lineage from models.ir. Reusing them is right, but maybe move them to a small shared module so "IR" doesn't end up meaning two things.

matthewmoorcroft and others added 2 commits October 20, 2026 05:44
… lineage types

- Tests for an invoke inside a container inside a container, a data edge
  between nodes nested in different containers, nested walk order and
  nested inventory flattening.
- The identity tier refuses a match when both sides name different
  asset_type values, as the signature tier already did.
- wait_for_completion is part of the control-edge dedupe key, so calls that
  differ only in waiting are no longer merged.
- DataAsset, ControlEdge, DataEdge, MotifAnnotation and Lineage move to
  flowx.models.lineage; flowx.models.ir re-exports them.
- The discovery model and inventory module docstrings are short summaries,
  with the details on the functions, and say a node carries raw and
  properties (only a graph also has extensions).

Co-authored-by: Isaac <no-reply@databricks.com>
…raphs

Cross-review of the third round: the DataAsset and serde docstrings still
said only the signature tier checks asset_type, and Lineage was described
as attached to a translated pipeline only.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

Thanks. All six are addressed at 5ebdfcb:

  1. Nested containers (r4321169393): added tests for an invoke two containers deep, a data edge between nodes nested in different containers, walk order through a container inside a container, and nested inventory flattening. They pass on the old code, as expected, since the code already handled both. 78763b2.
  2. Identity match and asset_type (r4321169384): the identity tier now refuses a match when both sides set asset_type and the types differ, the same as the signature tier, with a test that failed before. One side unknown still matches. 78763b2, docstrings in 5ebdfcb.
  3. Node fields (r4321169381): the docstring now says a graph carries raw, properties and extensions, and a node carries raw and properties. The PR description says the same. 78763b2.
  4. Dedupe key (r4321169388): wait_for_completion is now part of the key, so calls that differ only in waiting stay separate edges; exact repeats still collapse. Test added. 78763b2.
  5. Module docstrings (r4321169397): discovery_inventory.py and models/discovery.py now open with a short summary, and the details moved to build_source_inventory. 78763b2.
  6. Shared module (r4321169402): DataAsset, ControlEdge, DataEdge, MotifAnnotation and Lineage now live in flowx/models/lineage.py. flowx.models.ir re-exports them, so the stacked PRs keep working unchanged, with a test that both names point at the same classes. 78763b2.

With these in, could you approve #84 when you have a moment? Then we merge the stack in order: #84 first, then #94 to #97, each with its own round-three fixes.

matthewmoorcroft added a commit that referenced this pull request Oct 20, 2026
Brings in #84's third review round: nested containers, the asset_type check
on both lineage tiers, the wait-aware control-edge dedupe, and the lineage
types moved to flowx.models.lineage.

Resolution, as in #84's carry trial: discovery_serde.py and lineage.py take
#94's side (lineage reads back through ir_serde.lineage_from_dict, and the IR
builders stay), with #84's new asset_type wording kept and docstring
references pointed at flowx.models.lineage. models/ir.py takes #84's side,
which drops the moved classes and keeps the re-exports; #94's MotifAnnotation
sentence about Activity.motif_id moves into models/lineage.py.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 20, 2026
Brings in #96's round three with #95 6ed0b4b, #94 337d2d6 and #84 5ebdfcb
merged in. No conflicts.

Co-authored-by: Isaac <no-reply@databricks.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants