[DJ Semantic Fingerprint 1] Add semantic node fingerprints - #2482
Conversation
Derive node digests and canonical comparisons from existing change tiers and equality rules so semantic classification remains server-owned.
✅ Deploy Preview for thriving-cassata-78ae72 canceled.
|
Use a versioned Merkle-style digest so persisted object metadata changes whenever any semantic ancestor changes.
Require explicit list-order tiers, dispatch immutable fingerprint versions, and route link equality through the shared comparison key.
Reflect that NONE-tier filter ordering is omitted from the deployment changelog while the dimension reorder remains minor.
Move canonicalization and structural SQL serialization behind a dedicated package while preserving version 1 digests and aligning metric field semantics.
Reserve canonical for final JSON serialization and name comparison helpers by behavior so the fingerprint API is easier to read.
shangyian
left a comment
There was a problem hiding this comment.
This looks great -- liking the shift to having the logic on NodeSpec with ChangeTier.
|
In the example: source_hash = source.semantic_fingerprint()
original = compact.semantic_fingerprint(parent_fingerprints=[source_hash])
format_only = formatted.semantic_fingerprint(parent_fingerprints=[source_hash])How hard would it be to not require source_hash = source.semantic_fingerprint()
original = compact.semantic_fingerprint()
format_only = formatted.semantic_fingerprint()This way we don't have the risk of missing a fingerprint and generating an incorrect fingerprint. (Unless this is supposed to be an internal API that the machinery from the next PRs is going to use.) |
Good question! The argless implementation is actually what I have done in the past, but I think unfortunately due to DJ's data model it doesn't work. I ran into the following failure case: orders_v1 = SourceSpec(
namespace="sales",
name="orders",
catalog="warehouse",
schema_="raw",
table="orders_v1",
)
orders_v2 = orders_v1.model_copy(update={"table": "orders_v2"})
revenue = TransformSpec(
namespace="sales",
name="revenue",
query="SELECT amount FROM ${prefix}orders",
)
current = {"sales.orders": orders_v1, "sales.revenue": revenue}
proposed = {"sales.orders": orders_v2, "sales.revenue": revenue}
assert (
current["sales.revenue"].semantic_fingerprint()
== proposed["sales.revenue"].semantic_fingerprint()
) # Incorrect: the parent changedThe argless call never receives current or proposed, so it doesn't know which orders definition to traverse. |
|
Hmm- ok I've thought about the issue some more. I think a better API might be: or something instead like The key insight here is that the hash depends on the graph existing already, making it a property of an already computed (and frozen) graph. When it's on the node, you essentially have to supply the whole graph context anyways. If you're wrong, you can footgun and get the wrong hashes. Requiring the full graph first prevents omitted-parent bugs, memoizes traversal, and handles cycles/ missing parents consistently. |
Fingerprint values depend on graph context, so keep local and composition builders internal until the graph evaluator owns parent resolution.
DJ already classifies semantic and cosmetic node changes, but that decision is not exposed as a reusable node identity. Consumers otherwise have to duplicate field selection and SQL normalization, which can drift from the server.
This PR adds:
ChangeTierfield definitionssemantic_fingerprintssql/parsing, independent of render settingslocal_node_fingerprint()hashes one node definition. The lower-levelcompose_node_fingerprint()requires explicit parent fingerprints. These are construction primitives, andNodeSpechas no fingerprint method. #2488 addsSemanticFingerprintGraph, which owns graph snapshots, parent resolution, and graph-bound evaluation.Fingerprint composition example
For each node,
f_nodeis its intrinsic semantic fingerprint andh_nodeis its Merkle object hash:h_node = H(f_node, sorted parent h values)flowchart LR S["source: orders<br/>fingerprint: f_orders<br/>object hash: h_orders = H(f_orders)"] D["dimension: customer<br/>fingerprint: f_customer<br/>object hash: h_customer = H(f_customer)"] T["transform: clean_orders<br/>fingerprint: f_clean_orders<br/>object hash: h_clean_orders = H(f_clean_orders, sort(h_orders, h_customer))"] M["metric: order_count<br/>fingerprint: f_order_count<br/>object hash: h_order_count = H(f_order_count, h_clean_orders)"] C["cube: orders_cube<br/>fingerprint: f_orders_cube<br/>object hash: h_orders_cube = H(f_orders_cube, sort(h_order_count, h_customer))"] S -->|SQL parent| T D -.->|dimension link| T T -->|SQL parent| M M -->|cube metric| C D -->|cube dimension| C classDef source fill:#fff1d6,stroke:#c2410c,color:#431407 classDef dimension fill:#ecfdf5,stroke:#059669,color:#064e3b classDef derived fill:#dbeafe,stroke:#2563eb,color:#172554 class S source class D dimension class T,M,C derivedVerification:
Observed:
Semantic fingerprint stack
This is PR 1 of 4: