From be5bb145468e16b4d4a2f009f028d5fd60c492d1 Mon Sep 17 00:00:00 2001 From: Javier Sagredo Date: Thu, 7 May 2026 00:36:11 +0200 Subject: [PATCH] Document predicate-node dedup caveats in INTERNALS.md New section between the resolution-chain determinism notes and the "viewer is not doing" wrap-up, covering the structural-hash dedup, the context-vs-superclass reduction asymmetry, tyvar-index aliasing across instances, the role-sticky-to-context behaviour, and the fact that predicate nodes are view-scoped (not persisted). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INTERNALS.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index 4a5b747..573c16d 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -16,6 +16,7 @@ or an inferred (possibly fragile) reconstruction. - [Type family instances](#type-family-instances) - [Type-family resolution in the viewer](#type-family-resolution-in-the-viewer) - [Are the resolution chains deterministic?](#are-the-resolution-chains-deterministic) +- [Predicate nodes and how they dedup](#predicate-nodes-and-how-they-dedup) - [What the viewer is *not* doing](#what-the-viewer-is-not-doing) ## Pipeline at a glance @@ -343,6 +344,71 @@ instance view are hints; the underlying transcribed data (`pdInstances`, `pdFamInstances`, `tfEquations`, etc.) is ground truth. +## Predicate nodes and how they dedup + +The instance view introduces a synthetic node type — `kind: 'predicate'` +— that doesn't appear in the schema. Two cases produce one: + +- **Context constraint** (the predicate sits in `iiContext`). Edge + from focused instance → predicate node, labelled `instance context`. +- **Unmatched superclass** (a `ciSuperclasses` entry whose substituted + args don't match any instance in `pdInstances`). Edge from focused + instance → predicate node, labelled `superclass constraint`. If the + predicate happens to be structurally identical to a context + predicate the same node receives both edges; otherwise a fresh node + with `role: 'extern'` is created (rendered grey). + +This is a viewer-only abstraction. The Haskell side knows nothing about +it; predicate nodes have no on-disk representation. The dedup is what +makes the "context AND unmatched superclass" case collapse into a +single node — which is what a typical reader expects to see when the +same constraint is required twice. + +A few things to be aware of: + +- **Dedup key is a structural hash.** Two predicates collapse to one + node when their class `QualName` and the JSON-stringified + `[TypeArg]` are byte-identical. The hash sees through tyvar names + (TyVarRef carries an integer index, not a name), so two instances + with `[a, b]` vs `[x, y]` produce the same id structurally. Their + *labels* differ (rendered against each instance's own + `iiTyVars`) — first call wins on the label. + +- **Reduction asymmetry between context and superclass.** Context + predicates carry `piArgs` as written. Superclass requirements go + through `reduceTypeArg` so we can match them against + `pdInstances`. If a fam-instance reduction changes the arg shape — + the context says `Foo (F a)`, the reduced superclass says + `Foo Int` — the dedup id differs and the user sees two predicate + nodes for what is morally one constraint. Realistic but rare in + practice. + +- **Tyvar index aliasing across instances.** When the focused class + has many instances and they happen to produce structurally + identical context predicates (different tyvar names, same indices), + the predicate node is shared across all of them. That's *probably* + what the user wants — fewer noisy duplicates — but means the + arrows from several instance nodes land on one shared predicate. + No information is lost; the labels just match the first instance + that contributed. + +- **'extern' role is sticky to the first observation.** If a + predicate is observed first as a context constraint (`role: + 'context'`) and later as an unmatched superclass, the node keeps + the `'context'` role and just gains a second edge. If observed in + the reverse order — superclass first, context second — same thing + happens because context predicates are processed before superclass + requirements in `buildInstanceView`. This is intentional; the + styling shouldn't downgrade once we know the predicate is also a + legitimate context constraint. + +- **Predicate nodes don't survive view changes.** The set is rebuilt + from scratch every time the user enters an instance view; nothing + is persisted. The same predicate, viewed from a different focused + class, gets a different node id (because the focused-instance + arg/tyvar context differs). That's fine — predicate nodes are + scoped to "this view". + ## What the viewer is *not* doing A few things we deliberately don't do, in case you wonder: -- 2.54.0