001package gudusoft.gsqlparser.slpc.adapter;
002
003import gudusoft.gsqlparser.dlineage.dataflow.model.ResultSetType;
004import gudusoft.gsqlparser.dlineage.dataflow.model.xml.column;
005import gudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow;
006import gudusoft.gsqlparser.dlineage.dataflow.model.xml.argument;
007import gudusoft.gsqlparser.dlineage.dataflow.model.xml.oraclePackage;
008import gudusoft.gsqlparser.dlineage.dataflow.model.xml.procedure;
009import gudusoft.gsqlparser.dlineage.dataflow.model.xml.relationship;
010import gudusoft.gsqlparser.dlineage.dataflow.model.xml.sourceColumn;
011import gudusoft.gsqlparser.dlineage.dataflow.model.xml.table;
012import gudusoft.gsqlparser.dlineage.dataflow.model.xml.targetColumn;
013import gudusoft.gsqlparser.dlineage.dataflow.model.xml.transform;
014import gudusoft.gsqlparser.slpc.identity.SlpcIdentity;
015import gudusoft.gsqlparser.slpc.model.SlpcDocument;
016import gudusoft.gsqlparser.slpc.model.SlpcObject;
017import gudusoft.gsqlparser.slpc.validation.SlpcSemanticValidator;
018
019import java.nio.charset.StandardCharsets;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.security.MessageDigest;
023import java.security.NoSuchAlgorithmException;
024import java.util.ArrayDeque;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.Comparator;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.IdentityHashMap;
031import java.util.LinkedHashMap;
032import java.util.LinkedHashSet;
033import java.util.List;
034import java.util.Map;
035import java.util.Set;
036import java.util.TreeMap;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039
040/**
041 * Projects one already-completed legacy {@link dataflow} POJO to SLPC v0.2.
042 * This class never receives or parses SQL and never interprets display text as
043 * identity.  Semantics absent from the POJO are quarantined or diagnosed.
044 */
045public final class LegacyDlineageAdapter {
046
047    public static final String ADAPTER_NAME = "LegacyDlineageAdapter";
048    public static final String ADAPTER_VERSION = "0.2.0";
049    private static final String URI_POLICY = "SLPC-URI-OPAQUE-LOCATOR-V1";
050    private static final Pattern SECRET_LOCATOR = Pattern.compile(
051            "(?i)(?:password|passwd|secret|token|access[_-]?key|credential)\\s*[:=]");
052    private static final Pattern LEGACY_COORDINATE = Pattern.compile(
053            "\\[\\s*(-?\\d+)\\s*,\\s*(-?\\d+)\\s*,\\s*(-?\\d+)\\s*\\]\\s*,\\s*"
054                    + "\\[\\s*(-?\\d+)\\s*,\\s*(-?\\d+)\\s*,\\s*(-?\\d+)\\s*\\]");
055
056    public LegacyAdaptation adapt(dataflow legacy, LegacyAdapterContext context) {
057        if (legacy == null) throw new IllegalArgumentException("completed dlineage result must not be null");
058        if (context == null) throw new IllegalArgumentException("adapter context must not be null");
059
060        State state = new State(context);
061        state.legacyInputCount = countLegacyRecords(legacy);
062        state.artifact = artifact(context);
063        state.statement = statement(state.artifact, context, legacy);
064        state.statementId = state.statement.string("statementId");
065        state.hasSourceLocation = state.statement.get("sourceLocation") != null;
066        indexLegacySourceLocations(legacy, state);
067        buildEndpoints(legacy, state);
068        buildRoutineParameterFlows(state);
069        buildObjectRelationships(legacy, state);
070        buildStatementOperation(legacy, state);
071        buildLineage(legacy, state);
072        buildJoinSemantics(legacy, state);
073        buildIntermediateGraph(legacy, state);
074        materializeFactsAndOccurrences(state, state.factOccurrenceDrafts);
075        materializeObjectRelationships(state);
076        accountErrors(legacy, state);
077        return state.finish();
078    }
079
080    private void buildEndpoints(dataflow legacy, State state) {
081        List<EndpointDraft> drafts = new ArrayList<EndpointDraft>();
082        List<SystemFieldDraft> systemFields = new ArrayList<SystemFieldDraft>();
083        for (table relation : dataflow.getAllTables(legacy)) {
084            EndpointDraft relationDraft = endpoint(relation, null, state, true, "OBJECT", relation.getId());
085            drafts.add(relationDraft);
086            state.tableDrafts.put(relation, relationDraft);
087            for (column field : relation.getColumns()) {
088                if (isRelationRows(field)) {
089                    systemFields.add(new SystemFieldDraft(relation, field));
090                    continue;
091                }
092                EndpointDraft fieldDraft = endpoint(relation, field, state, true, "FIELD", field.getId());
093                drafts.add(fieldDraft);
094                state.columnDrafts.put(field, fieldDraft);
095            }
096        }
097        for (procedure routine : allProcedures(legacy)) {
098            table synthetic = routineTable(routine);
099            LegacyAdapterContext.RoutineBinding binding = state.context.routineBindingResolver().resolve(routine);
100            if (binding != null && !validRoutineBinding(routine, binding, state)) binding = null;
101            if (binding != null && hasReturnParameter(binding)) synthetic.setType("function");
102            if (binding != null) state.routineBindings.put(routine, binding);
103            EndpointDraft routineDraft = endpoint(synthetic, null, state, false, "PROCEDURE", routine.getId(),
104                    binding == null ? null : binding.routineIdentity());
105            drafts.add(routineDraft); state.routineDrafts.put(routine, routineDraft);
106            if (binding != null) {
107                Map<String, EndpointDraft> parameters = new LinkedHashMap<String, EndpointDraft>();
108                for (LegacyAdapterContext.RoutineParameterBinding parameter : binding.parameters()) {
109                    EndpointDraft parameterDraft = parameterEndpoint(routine, parameter, routineDraft, state);
110                    drafts.add(parameterDraft); parameters.put(parameter.bindingKey(), parameterDraft);
111                    state.parameterBindingByDraft.put(parameterDraft, parameter);
112                }
113                state.parameterDraftsByRoutine.put(routine, parameters);
114            }
115        }
116        Collections.sort(drafts, new Comparator<EndpointDraft>() {
117            @Override public int compare(EndpointDraft left, EndpointDraft right) {
118                int rank = left.canonical == null ? 1 : 0;
119                int otherRank = right.canonical == null ? 1 : 0;
120                if (rank != otherRank) return rank - otherRank;
121                int key = utf8Compare(left.sortKey, right.sortKey);
122                if (key != 0) return key;
123                return left.stableTieBreaker.compareTo(right.stableTieBreaker);
124            }
125        });
126        List<EndpointDraft> uniqueDrafts = new ArrayList<EndpointDraft>();
127        Map<String, EndpointDraft> representativeByKey = new LinkedHashMap<String, EndpointDraft>();
128        for (EndpointDraft draft : drafts) {
129            String key = (draft.canonical == null ? "1\u0000" : "0\u0000") + draft.sortKey;
130            EndpointDraft representative = representativeByKey.get(key);
131            if (representative == null) { representativeByKey.put(key, draft); uniqueDrafts.add(draft); }
132            else if (!representative.sameSemanticIdentity(draft)) throw new IllegalStateException("SLPC endpoint identity collision: " + draft.sortKey);
133            draft.representative = representative == null ? draft : representative;
134        }
135        for (int index = 0; index < uniqueDrafts.size(); index++) {
136            EndpointDraft draft = uniqueDrafts.get(index); draft.endpointRef = local("ep-", index + 1);
137            SlpcObject endpoint = draft.withRef();
138            state.endpoints.add(endpoint);
139            state.endpointByRef.put(draft.endpointRef, endpoint);
140        }
141        for (EndpointDraft draft : drafts) {
142            draft.endpointRef = draft.representative.endpointRef;
143            LegacyAdapterContext.RoutineParameterBinding parameter = state.parameterBindingByDraft.get(draft);
144            if (parameter != null) {
145                state.parameterRefByKey.put(parameterKey(draft.routine, parameter.bindingKey()), draft.endpointRef);
146                if (parameter.legacyArgumentId() != null) {
147                    state.mappedArgumentIds.add(parameter.legacyArgumentId());
148                    state.coverage.add(new LegacyCoverageReport.Entry("ARGUMENT", parameter.legacyArgumentId(),
149                            draft.canonical == null ? LegacyCoverageReport.Outcome.QUARANTINED
150                                    : LegacyCoverageReport.Outcome.EXACT,
151                            draft.endpointRef, draft.canonical == null ? "parameter identity unavailable"
152                                    : "routine parameter endpoint"));
153                }
154            } else if (draft.field == null) {
155                state.tableRefs.put(draft.relation, draft.endpointRef);
156                putLegacyRef(state.endpointByLegacyId, draft.relation.getId(), draft.endpointRef);
157                state.coverage.add(new LegacyCoverageReport.Entry(draft.legacyKind, safeId(draft.legacyId, draft.stableTieBreaker),
158                        draft.canonical == null ? LegacyCoverageReport.Outcome.QUARANTINED : LegacyCoverageReport.Outcome.EXACT,
159                        draft.endpointRef, draft.canonical == null ? "identity unavailable" : "canonical endpoint"));
160            } else {
161                state.columnRefs.put(draft.field, draft.endpointRef);
162                putLegacyRef(state.endpointByLegacyId, draft.field.getId(), draft.endpointRef);
163                state.coverage.add(new LegacyCoverageReport.Entry(draft.legacyKind, safeId(draft.legacyId, draft.stableTieBreaker),
164                        draft.canonical == null ? LegacyCoverageReport.Outcome.QUARANTINED : LegacyCoverageReport.Outcome.EXACT,
165                        draft.endpointRef, draft.canonical == null ? "identity unavailable" : "canonical endpoint"));
166            }
167        }
168        for (Map.Entry<EndpointDraft, LegacyAdapterContext.RoutineParameterBinding> entry
169                : state.parameterBindingByDraft.entrySet()) {
170            EndpointDraft draft = entry.getKey();
171            LegacyAdapterContext.RoutineParameterBinding parameter = entry.getValue();
172            if (parameter.legacyArgumentId() != null) {
173                state.endpointByLegacyId.put(parameter.legacyArgumentId(), draft.endpointRef);
174            }
175            if (parameter.boundLegacyEndpointId() != null) {
176                String previous = state.endpointByLegacyId.get(parameter.boundLegacyEndpointId());
177                if (previous == null) {
178                    routineBindingLoss(state, parameter.bindingKey(),
179                            "Bound legacy endpoint does not exist in the completed POJO: "
180                                    + parameter.boundLegacyEndpointId());
181                } else {
182                    state.endpointByLegacyId.put(parameter.boundLegacyEndpointId(), draft.endpointRef);
183                }
184            }
185        }
186        for (SystemFieldDraft systemField : systemFields) {
187            String holderRef = state.tableRefs.get(systemField.relation);
188            putLegacyRef(state.endpointByLegacyId, systemField.field.getId(), holderRef);
189            state.coverage.add(new LegacyCoverageReport.Entry("FIELD", safeId(systemField.field.getId(), "RelationRows"),
190                    LegacyCoverageReport.Outcome.CONDITIONAL, holderRef,
191                    "SLPC_PSEUDO_COLUMN_REJECTED: normalized to the holder relation"));
192            state.differences.add(new IntentionalDiffLedger.Entry(
193                    IntentionalDiffLedger.Category.EXPECTED_SEMANTIC_CORRECTION,
194                    safeId(systemField.field.getId(), "RelationRows"), "SLPC_PSEUDO_COLUMN_REJECTED",
195                    "Legacy RelationRows is normalized to its holder relation and is not published as a field endpoint."));
196        }
197        if (!systemFields.isEmpty()) {
198            state.diagnostics.add(diagnostic("SLPC_PSEUDO_COLUMN_REJECTED", "INFO", "DOCUMENT", null,
199                    "Legacy RelationRows system columns were normalized to holder relations instead of field endpoints."));
200        }
201        if (hasUnavailableIdentity(drafts)) {
202            state.diagnostics.add(diagnostic("SLPC_IDENTITY_UNAVAILABLE", "INFO", "DOCUMENT", null,
203                    "Legacy display names are not treated as persistent identity; unresolved endpoints remain candidates."));
204        }
205        if (state.hasLegacyEndpointAmbiguity) {
206            state.diagnostics.add(diagnostic("SLPC_ENDPOINT_AMBIGUOUS", "WARN", "DOCUMENT", null,
207                    "At least one unresolved legacy relation carries multiple candidate tables; no candidate name was promoted to identity."));
208        }
209        if (state.hasStorageIdentityUnavailable) {
210            state.diagnostics.add(diagnostic("SLPC_STORAGE_IDENTITY_UNAVAILABLE", "WARN", "DOCUMENT", null,
211                    "At least one storage locator was absent, malformed, credential-bearing, or contained query/fragment data; it was redacted and quarantined."));
212        }
213        accountNonEndpointObjects(legacy, state);
214    }
215
216    private EndpointDraft endpoint(table relation, column field, State state, boolean allowIdentity,
217            String legacyKind, String legacyId) {
218        return endpoint(relation, field, state, allowIdentity, legacyKind, legacyId, null);
219    }
220
221    private EndpointDraft endpoint(table relation, column field, State state, boolean allowIdentity,
222            String legacyKind, String legacyId, LegacyAdapterContext.ResolvedIdentity resolvedOverride) {
223        String objectKind = "PROCEDURE".equals(legacyKind) ? routineObjectKind(relation) : objectKind(relation, state);
224        String level = field == null ? endpointLevel(relation, legacyKind) : "FIELD";
225        String storageLocator = field == null && "FILE".equals(objectKind) ? normalizedStorageLocator(relation) : null;
226        List<Object> display = storageLocator == null ? displaySegments(relation, field)
227                : Collections.<Object>singletonList(display("OBJECT", storageLocator));
228        if (field == null && "FILE".equals(objectKind) && storageLocator == null) {
229            state.hasStorageIdentityUnavailable = true;
230            display = Collections.<Object>singletonList(display("OBJECT", "<redacted-storage-locator>"));
231        }
232        boolean intermediate = !"PROCEDURE".equals(legacyKind) && isIntermediate(relation);
233        LegacyAdapterContext.ResolvedIdentity resolved = resolvedOverride != null ? resolvedOverride
234                : intermediate || !allowIdentity || storageLocator != null || isScopedObjectKind(objectKind) ? null
235                : state.context.identityResolver().resolve(relation, field);
236        SlpcObject identity;
237        String resolution, publication, canonical = null;
238        List<Object> extensions = Collections.emptyList();
239        if (storageLocator != null) {
240            String scheme = storageScheme(storageLocator);
241            identity = SlpcObject.builder().put("kind", "URI_EXACT").put("strength", "EXACT")
242                    .put("normalizationPolicyId", URI_POLICY).put("scheme", scheme)
243                    .put("locatorDigest", uriLocatorDigest(URI_POLICY, scheme, storageLocator)).build();
244            resolution = "RESOLVED"; publication = "ELIGIBLE";
245            extensions = Collections.<Object>singletonList(SlpcObject.builder()
246                    .put("extensionType", "gsp.storage-asset.v1").put("schemaVersion", "1.0.0")
247                    .put("criticality", "OPTIONAL").put("payload", SlpcObject.builder()
248                            .put("secretFreeNormalizedLocator", storageLocator).build()).build());
249        } else if (resolved == null) {
250            boolean ambiguous = hasCandidateTables(relation);
251            if (ambiguous) state.hasLegacyEndpointAmbiguity = true;
252            String reason = intermediate ? "UNBOUND_RESULT_SET" : ambiguous ? "AMBIGUOUS"
253                    : isScopedObjectKind(objectKind) ? "SCOPE_UNAVAILABLE" : "OTHER";
254            identity = SlpcObject.builder().put("kind", "UNAVAILABLE").put("strength", "UNAVAILABLE").put("reason", reason).build();
255            resolution = ambiguous ? "AMBIGUOUS" : "UNRESOLVED"; publication = "INELIGIBLE";
256        } else {
257            identity = resolved.identity(); resolution = resolved.resolutionStatus(); publication = resolved.publicationState();
258        }
259        SlpcObject provisional = SlpcObject.builder().put("endpointRef", "ep-000001").putNull("canonicalEndpointId")
260                .put("endpointLevel", level).put("objectKind", objectKind).put("displaySegments", display)
261                .put("identity", identity).put("resolutionStatus", resolution).put("publicationState", publication)
262                .putNull("scope").put("extensions", extensions).build();
263        if ((resolved != null || storageLocator != null) && isCanonicalIdentity(identity)) {
264            canonical = SlpcIdentity.endpointId(provisional);
265            provisional = provisional.toBuilder().put("canonicalEndpointId", canonical).build();
266        }
267        String sortKey = canonical == null ? SlpcIdentity.candidateEndpointKey(provisional) : canonical;
268        String tie = safe(relation.getId()) + "\u0000" + safe(field == null ? null : field.getId()) + "\u0000"
269                + safe(relation.getName()) + "\u0000" + safe(field == null ? null : field.getName());
270        return new EndpointDraft(relation, field, provisional, canonical, sortKey, tie, legacyKind, legacyId);
271    }
272
273    private String normalizedStorageLocator(table relation) {
274        String raw = nonEmpty(relation.getUri());
275        if (raw == null) raw = nonEmpty(relation.getLocation());
276        if (raw == null) raw = nonEmpty(relation.getName());
277        if (raw == null) return null;
278        raw = raw.trim();
279        if (raw.length() >= 2 && ((raw.charAt(0) == '\'' && raw.charAt(raw.length() - 1) == '\'')
280                || (raw.charAt(0) == '"' && raw.charAt(raw.length() - 1) == '"'))) {
281            raw = raw.substring(1, raw.length() - 1);
282        }
283        if (SECRET_LOCATOR.matcher(raw).find()) return null;
284        try {
285            URI uri = new URI(raw).normalize();
286            if (uri.getScheme() == null || uri.getScheme().isEmpty() || uri.getUserInfo() != null
287                    || uri.getQuery() != null || uri.getFragment() != null) return null;
288            String scheme = uri.getScheme().toLowerCase(java.util.Locale.ROOT);
289            String ascii = uri.toASCIIString();
290            return scheme + ascii.substring(uri.getScheme().length());
291        } catch (URISyntaxException invalid) {
292            return null;
293        }
294    }
295
296    private String storageScheme(String locator) {
297        int separator = locator.indexOf(':');
298        if (separator <= 0) throw new IllegalArgumentException("normalized storage locator has no scheme");
299        return locator.substring(0, separator);
300    }
301
302    private String uriLocatorDigest(String policy, String scheme, String locator) {
303        try {
304            MessageDigest digest = MessageDigest.getInstance("SHA-256");
305            digest.update("SLPC-URI-LOCATOR-DIGEST-V1".getBytes(StandardCharsets.US_ASCII));
306            digest.update((byte) 0); digest.update(SlpcIdentity.string(policy));
307            digest.update(SlpcIdentity.string(scheme)); digest.update(SlpcIdentity.string(locator));
308            StringBuilder result = new StringBuilder();
309            for (byte item : digest.digest()) result.append(String.format("%02x", item & 255));
310            return result.toString();
311        } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
312    }
313
314    private List<procedure> allProcedures(dataflow legacy) {
315        List<procedure> result = new ArrayList<procedure>(legacy.getProcedures());
316        for (oraclePackage pkg : legacy.getPackages()) result.addAll(pkg.getProcedures());
317        return result;
318    }
319
320    private table routineTable(procedure routine) {
321        table result = new table(); result.setId(routine.getId()); result.setName(safe(routine.getName()));
322        result.setServer(routine.getServer()); result.setDatabase(routine.getDatabase()); result.setSchema(routine.getSchema());
323        result.setType("function".equalsIgnoreCase(routine.getType()) ? "function" : "procedure");
324        return result;
325    }
326
327    private boolean validRoutineBinding(procedure routine, LegacyAdapterContext.RoutineBinding binding, State state) {
328        Set<String> argumentIds = new HashSet<String>();
329        for (argument item : routine.getArguments()) if (item.getId() != null) argumentIds.add(item.getId());
330        Set<String> usedArguments = new HashSet<String>(), keys = new HashSet<String>();
331        Set<Integer> ordinals = new HashSet<Integer>();
332        boolean valid = true;
333        for (LegacyAdapterContext.RoutineParameterBinding parameter : binding.parameters()) {
334            if (!keys.add(parameter.bindingKey()) || !ordinals.add(parameter.ordinal())
335                    || !equalsAny(parameter.role(), "IN", "OUT", "INOUT", "RETURN", "TABLE_RETURN_FIELD")) {
336                valid = false; break;
337            }
338            if (parameter.legacyArgumentId() == null) {
339                if (!equalsAny(parameter.role(), "RETURN", "TABLE_RETURN_FIELD")) { valid = false; break; }
340            } else if (!argumentIds.contains(parameter.legacyArgumentId())
341                    || !usedArguments.add(parameter.legacyArgumentId())) {
342                valid = false; break;
343            }
344        }
345        if (!valid) routineBindingLoss(state, safeId(routine.getId(), safe(routine.getName())),
346                "Routine binding contains an unknown/duplicate argument, ordinal, binding key, or parameter role.");
347        return valid;
348    }
349
350    private boolean hasReturnParameter(LegacyAdapterContext.RoutineBinding binding) {
351        for (LegacyAdapterContext.RoutineParameterBinding parameter : binding.parameters()) {
352            if (equalsAny(parameter.role(), "RETURN", "TABLE_RETURN_FIELD")) return true;
353        }
354        return false;
355    }
356
357    private EndpointDraft parameterEndpoint(procedure routine,
358            LegacyAdapterContext.RoutineParameterBinding parameter, EndpointDraft routineDraft, State state) {
359        table synthetic = routineTable(routine);
360        column field = new column(); field.setId(parameter.legacyArgumentId() == null
361                ? "return:" + parameter.bindingKey() : parameter.legacyArgumentId());
362        field.setName(parameter.displayName());
363        LegacyAdapterContext.ResolvedIdentity resolved = parameter.identity();
364        SlpcObject identity = resolved.identity(); String canonical = null;
365        String resolution = resolved.resolutionStatus(), publication = resolved.publicationState();
366        SlpcObject scope = null;
367        if (routineDraft.canonical != null) {
368            scope = SlpcObject.builder().put("scopeKind", "PROCEDURE").put("scopeId", routineDraft.canonical)
369                    .put("definedAtStatementId", state.statementId).build();
370        } else {
371            identity = SlpcObject.builder().put("kind", "UNAVAILABLE").put("strength", "UNAVAILABLE")
372                    .put("reason", "SCOPE_UNAVAILABLE").build();
373            resolution = "UNRESOLVED"; publication = "INELIGIBLE";
374        }
375        SlpcObject.Builder payload = SlpcObject.builder().put("parameterRole", parameter.role())
376                .put("parameterOrdinal", parameter.ordinal()).put("routineBindingKey", parameter.bindingKey());
377        if (parameter.declaredType() != null) payload.put("declaredType", parameter.declaredType());
378        List<Object> extensions = Collections.<Object>singletonList(SlpcObject.builder()
379                .put("extensionType", "gsp.routine-parameter.v1").put("schemaVersion", "1.0.0")
380                .put("criticality", "OPTIONAL").put("payload", payload.build()).build());
381        SlpcObject provisional = SlpcObject.builder().put("endpointRef", "ep-000001").putNull("canonicalEndpointId")
382                .put("endpointLevel", "VARIABLE").put("objectKind", "PARAMETER")
383                .put("displaySegments", displaySegments(synthetic, field)).put("identity", identity)
384                .put("resolutionStatus", resolution).put("publicationState", publication)
385                .put("scope", scope).put("extensions", extensions).build();
386        if (routineDraft.canonical != null && isCanonicalIdentity(identity)) {
387            canonical = SlpcIdentity.endpointId(provisional);
388            provisional = provisional.toBuilder().put("canonicalEndpointId", canonical).build();
389        }
390        String sortKey = canonical == null ? SlpcIdentity.candidateEndpointKey(provisional) : canonical;
391        String tie = safe(routine.getId()) + "\u0000parameter\u0000" + parameter.ordinal()
392                + "\u0000" + parameter.bindingKey();
393        EndpointDraft result = new EndpointDraft(synthetic, field, provisional, canonical, sortKey, tie,
394                parameter.legacyArgumentId() == null ? "RETURN_PARAMETER" : "ARGUMENT",
395                parameter.legacyArgumentId() == null ? parameter.bindingKey() : parameter.legacyArgumentId());
396        result.routine = routine;
397        return result;
398    }
399
400    private String parameterKey(procedure routine, String bindingKey) {
401        return safeId(routine.getId(), safe(routine.getName())) + "\u0000" + bindingKey;
402    }
403
404    private void routineBindingLoss(State state, String owner, String message) {
405        state.diagnostics.add(diagnostic("SLPC_ROUTINE_BINDING_UNPROVEN", "WARN", "DOCUMENT", null, message));
406        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
407                owner, "GSP_LEGACY_ROUTINE_ARGUMENT_BINDING_UNAVAILABLE", message));
408    }
409
410    private void accountNonEndpointObjects(dataflow legacy, State state) {
411        for (oraclePackage pkg : legacy.getPackages()) {
412            String owner = safeId(pkg.getId(), safe(pkg.getName()));
413            state.coverage.add(new LegacyCoverageReport.Entry("PACKAGE", owner,
414                    LegacyCoverageReport.Outcome.UNSUPPORTED_WITH_LOSS, null, "GSP_LEGACY_PACKAGE_ENDPOINT_UNSUPPORTED"));
415            state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
416                    owner, "GSP_LEGACY_PACKAGE_ENDPOINT_UNSUPPORTED", "SLPC v0.2 has no package endpoint kind; contained routines remain visible."));
417            for (argument item : pkg.getArguments()) accountArgument(item, state);
418            for (procedure routine : pkg.getProcedures()) for (argument item : routine.getArguments()) accountArgument(item, state);
419        }
420        for (procedure routine : legacy.getProcedures()) for (argument item : routine.getArguments()) accountArgument(item, state);
421        for (gudusoft.gsqlparser.dlineage.dataflow.model.xml.process process : legacy.getProcesses()) {
422            String owner = safeId(process.getId(), "process");
423            state.coverage.add(new LegacyCoverageReport.Entry("PROCESS", owner,
424                    LegacyCoverageReport.Outcome.CONDITIONAL, state.statementId,
425                    "Legacy process is represented by artifact/statement provenance, not a catalog endpoint."));
426            state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.EXPECTED_PRESENTATION_DIFFERENCE,
427                    owner, "GSP_LEGACY_PROCESS_AS_PROVENANCE", "Legacy process presentation maps to SLPC artifact/statement provenance."));
428        }
429    }
430
431    private void accountArgument(argument item, State state) {
432        if (item.getId() != null && state.mappedArgumentIds.contains(item.getId())) return;
433        String owner = safeId(item.getId(), "argument");
434        state.diagnostics.add(diagnostic("SLPC_ROUTINE_BINDING_UNPROVEN", "WARN", "DOCUMENT", null,
435                "A routine argument was retained only in loss accounting because no authoritative overload/formal binding was supplied."));
436        state.coverage.add(new LegacyCoverageReport.Entry("ARGUMENT", owner,
437                LegacyCoverageReport.Outcome.UNSUPPORTED_WITH_LOSS, null, "GSP_LEGACY_ROUTINE_ARGUMENT_BINDING_UNAVAILABLE"));
438        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
439                owner, "GSP_LEGACY_ROUTINE_ARGUMENT_BINDING_UNAVAILABLE", "An argument needs a proven routine/parameter binding before it can become an endpoint or fact."));
440    }
441
442    private void buildRoutineParameterFlows(State state) {
443        for (Map.Entry<procedure, LegacyAdapterContext.RoutineBinding> entry : state.routineBindings.entrySet()) {
444            procedure routine = entry.getKey(); LegacyAdapterContext.RoutineBinding binding = entry.getValue();
445            String routineRef = endpointAtLevel(state.endpointByLegacyId.get(routine.getId()), "ROUTINE", state);
446            if (routineRef == null || !canonicalEndpoint(routineRef, state)) {
447                routineBindingLoss(state, safeId(routine.getId(), safe(routine.getName())),
448                        "Routine parameters require a canonical overload-aware routine identity.");
449                continue;
450            }
451            state.hasRoutineBinding = true;
452            for (LegacyAdapterContext.RoutineValueFlow flow : binding.flows()) {
453                if (!equalsAny(flow.role(), "DIRECT", "TRANSFORM", "CONDITION", "AGGREGATE", "DERIVED")
454                        || !equalsAny(flow.operationKind(), "NONE", "GROUP_BY", "WINDOW", "SORT")
455                        || !equalsAny(flow.operationInputKind(), "NONE", "GROUP_KEY", "PARTITION_KEY",
456                                "ORDER_KEY", "FRAME_BOUND")) {
457                    routineBindingLoss(state, safeId(routine.getId(), safe(routine.getName())),
458                            "Routine flow returned an axis/role/operation code outside the SLPC v0.2 registry.");
459                    continue;
460                }
461                String sourceRef = routineValueRef(routine, flow.source(), state);
462                String targetRef = routineValueRef(routine, flow.target(), state);
463                if (sourceRef == null || targetRef == null || sourceRef.equals(targetRef)) {
464                    routineBindingLoss(state, safeId(routine.getId(), safe(routine.getName())),
465                            "Routine flow did not bind two distinct completed-POJO/parameter endpoints.");
466                    continue;
467                }
468                FactSemantics semantics = FactSemantics.publish("VALUE", flow.role(), flow.operationKind(),
469                        flow.operationInputKind(), false);
470                SlpcObject semantic = semanticRecord(sourceRef, targetRef, semantics, state);
471                if (state.parameterRefByKey.containsValue(sourceRef)
472                        || state.parameterRefByKey.containsValue(targetRef)) state.hasRoutineParameterFlow = true;
473                boolean canonical = isCanonicalFact(semantic, state.endpointByRef);
474                String key = canonical ? SlpcIdentity.factId(semantic, state.endpointByRef)
475                        : SlpcIdentity.factCandidateFingerprint(semantic, state.endpointByRef);
476                FactGroup group = state.factGroups.get(key);
477                if (group == null) {
478                    group = new FactGroup(semantic, canonical, key); state.factGroups.put(key, group);
479                } else if (!group.sameSemantic(semantic)) {
480                    throw new IllegalStateException("SLPC routine fact fingerprint collision: " + key);
481                }
482                FactOccurrenceDraft occurrence = new FactOccurrenceDraft(group, null, null,
483                        canonical ? "RESOLVED" : "UNRESOLVED", Collections.<Object>emptyList(),
484                        Collections.<Object>emptyList(), sourceLocations(routine.getCoordinate(), state), routineRef);
485                group.occurrences.add(occurrence); state.factOccurrenceDrafts.add(occurrence);
486                state.hasRoutineParameterFlow = true;
487            }
488        }
489    }
490
491    private String routineValueRef(procedure routine, LegacyAdapterContext.RoutineValueRef value, State state) {
492        String ref = value.parameterBindingKey() == null
493                ? state.endpointByLegacyId.get(value.legacyEndpointId())
494                : state.parameterRefByKey.get(parameterKey(routine, value.parameterBindingKey()));
495        SlpcObject endpoint = ref == null ? null : state.endpointByRef.get(ref);
496        if (endpoint == null || !("FIELD".equals(endpoint.string("endpointLevel"))
497                || "VARIABLE".equals(endpoint.string("endpointLevel")))) return null;
498        return ref;
499    }
500
501    private void buildObjectRelationships(dataflow legacy, State state) {
502        for (table relation : dataflow.getAllTables(legacy)) {
503            String relationRef = state.tableRefs.get(relation);
504            if (relationRef == null || isIntermediate(relation)) continue;
505            List<String> primary = new ArrayList<String>();
506            for (column field : relation.getColumns()) {
507                String fieldRef = state.columnRefs.get(field);
508                if (fieldRef == null) continue;
509                if (Boolean.TRUE.equals(field.isPrimaryKey())) primary.add(fieldRef);
510                if (Boolean.TRUE.equals(field.isUnqiueKey())) {
511                    addKeyRelationship("UNIQUE_KEY", relationRef,
512                            Collections.singletonList(fieldRef), "column:" + safeId(field.getId(), field.getName()), state);
513                }
514            }
515            if (!primary.isEmpty()) addKeyRelationship("PRIMARY_KEY", relationRef, primary,
516                    "table:" + safeId(relation.getId(), relation.getName()), state);
517        }
518
519        for (relationship relation : legacy.getRelationships()) {
520            String type = safe(relation.getType());
521            if ("call".equals(type)) {
522                mapCallRelationship(relation, state);
523            } else if ("fdd".equals(type)) {
524                mapNonFactObjectRelationship(relation, state);
525            }
526        }
527        mapForeignKeyRelationships(legacy, state);
528    }
529
530    private void addKeyRelationship(String kind, String relationRef, List<String> fields,
531            String legacyEvidence, State state) {
532        sortEndpointRefs(fields, state);
533        List<Object> participants = Collections.<Object>singletonList(
534                SlpcObject.builder().put("role", "KEY_RELATION").put("endpointRef", relationRef).build());
535        List<Object> keyFields = new ArrayList<Object>();
536        for (int index = 0; index < fields.size(); index++) {
537            keyFields.add(SlpcObject.builder().put("ordinal", index + 1)
538                    .put("fieldEndpointRef", fields.get(index)).build());
539        }
540        addObjectRelationship(kind, participants, keyFields, null, legacyEvidence, state);
541    }
542
543    private void mapForeignKeyRelationships(dataflow legacy, State state) {
544        Map<String, relationship> byId = new HashMap<String, relationship>();
545        List<relationship> foreignKeys = new ArrayList<relationship>();
546        for (relationship relation : legacy.getRelationships()) {
547            if (!("er".equals(relation.getType())
548                    || "fdd".equals(relation.getType()) && "foreign_key".equals(relation.getEffectType()))) continue;
549            foreignKeys.add(relation); byId.put(legacyId(relation), relation);
550        }
551        for (LegacyAdapterContext.ForeignKeyBinding binding
552                : state.context.compositeForeignKeyResolver().resolve(legacy)) {
553            if (!mapAuthoritativeForeignKey(binding, byId, state)) {
554                for (String evidenceId : binding.evidenceRelationshipIds()) {
555                    relationship relation = byId.get(evidenceId);
556                    if (relation != null) accountDedicatedLoss(relation, state, "SLPC_COMPOSITE_FK_PAIRING_UNPROVEN",
557                            "Caller-supplied composite FK pairing failed validation against the completed POJO.");
558                }
559            }
560        }
561        Map<String, List<relationship>> unresolvedFamilies = new LinkedHashMap<String, List<relationship>>();
562        for (relationship relation : foreignKeys) {
563            if (state.dedicatedRelationshipAccounting.contains(legacyId(relation))) continue;
564            String family = foreignKeyFamily(relation, state);
565            if (!unresolvedFamilies.containsKey(family)) unresolvedFamilies.put(family, new ArrayList<relationship>());
566            unresolvedFamilies.get(family).add(relation);
567        }
568        for (List<relationship> family : unresolvedFamilies.values()) {
569            Set<String> foreignFields = new HashSet<String>(), referencedFields = new HashSet<String>();
570            for (relationship relation : family) {
571                String target = relation.getTarget() == null ? null : resolveTargetField(relation.getTarget(), state);
572                if (target != null) foreignFields.add(target);
573                for (sourceColumn source : relation.getSources()) {
574                    String ref = resolveSourceField(source, state); if (ref != null) referencedFields.add(ref);
575                }
576            }
577            if (foreignFields.size() == 1 && referencedFields.size() == 1) {
578                for (relationship relation : family) mapSingleColumnForeignKey(relation, state);
579            } else {
580                for (relationship relation : family) accountDedicatedLoss(relation, state,
581                        "SLPC_COMPOSITE_FK_PAIRING_UNPROVEN",
582                        "Legacy FK/ER output does not retain declaration-order composite key pairs; authoritative AST/catalog pairing is required.");
583            }
584        }
585    }
586
587    private String foreignKeyFamily(relationship relation, State state) {
588        String foreign = relationEndpoint(relation.getTarget(), state), referenced = null;
589        for (sourceColumn source : relation.getSources()) {
590            String candidate = relationEndpoint(source, state);
591            if (referenced == null) referenced = candidate;
592            else if (!equalValues(referenced, candidate)) return "invalid:" + legacyId(relation);
593        }
594        return "fk:" + safe(foreign) + "\u0000" + safe(referenced);
595    }
596
597    private boolean mapAuthoritativeForeignKey(LegacyAdapterContext.ForeignKeyBinding binding,
598            Map<String, relationship> byId, State state) {
599        Set<String> evidenceIds = new LinkedHashSet<String>(binding.evidenceRelationshipIds());
600        if (evidenceIds.size() != binding.evidenceRelationshipIds().size()) return false;
601        List<relationship> evidence = new ArrayList<relationship>();
602        for (String id : evidenceIds) {
603            relationship relation = byId.get(id); if (relation == null) return false; evidence.add(relation);
604        }
605        String foreignRelation = null, referencedRelation = null;
606        Set<String> foreignFields = new HashSet<String>(), referencedFields = new HashSet<String>();
607        List<Object> pairs = new ArrayList<Object>();
608        int ordinal = 1;
609        for (LegacyAdapterContext.ForeignKeyPair pair : binding.keyPairs()) {
610            String foreignField = endpointAtLevel(state.endpointByLegacyId.get(pair.foreignKeyFieldLegacyId()), "FIELD", state);
611            String referencedField = endpointAtLevel(state.endpointByLegacyId.get(pair.referencedFieldLegacyId()), "FIELD", state);
612            if (foreignField == null || referencedField == null
613                    || !foreignFields.add(foreignField) || !referencedFields.add(referencedField)) return false;
614            String pairForeignRelation = null, pairReferencedRelation = null;
615            for (relationship relation : evidence) {
616                if (matches(relation.getTarget(), pair.foreignKeyFieldLegacyId())) {
617                    pairForeignRelation = relationEndpoint(relation.getTarget(), state);
618                    for (sourceColumn source : relation.getSources()) {
619                        if (matches(source, pair.referencedFieldLegacyId())) {
620                            pairReferencedRelation = relationEndpoint(source, state); break;
621                        }
622                    }
623                }
624                if (pairForeignRelation != null && pairReferencedRelation != null) break;
625            }
626            if (pairForeignRelation == null || pairReferencedRelation == null) return false;
627            if (foreignRelation == null) foreignRelation = pairForeignRelation;
628            if (referencedRelation == null) referencedRelation = pairReferencedRelation;
629            if (!foreignRelation.equals(pairForeignRelation) || !referencedRelation.equals(pairReferencedRelation)) return false;
630            pairs.add(SlpcObject.builder().put("ordinal", ordinal++)
631                    .put("foreignKeyFieldRef", foreignField).put("referencedFieldRef", referencedField).build());
632        }
633        if (foreignRelation == null || referencedRelation == null) return false;
634        List<Object> participants = new ArrayList<Object>();
635        participants.add(participant("FOREIGN_KEY_RELATION", foreignRelation));
636        participants.add(participant("REFERENCED_RELATION", referencedRelation));
637        for (relationship relation : evidence) {
638            addObjectRelationship("FOREIGN_KEY_REFERENCE", participants, null, pairs, legacyId(relation), state);
639            markForeignKeyMapped(relation, state);
640        }
641        state.diagnostics.add(diagnostic("SLPC_COMPOSITE_FK_PAIRING_PROVEN", "INFO", "DOCUMENT", null,
642                "Composite foreign-key keyPairs preserve caller-authoritative declaration order."));
643        return true;
644    }
645
646    private boolean matches(targetColumn value, String legacyFieldId) {
647        return value != null && (legacyFieldId.equals(value.getId()) || legacyFieldId.equals(value.getTarget_id()));
648    }
649
650    private boolean matches(sourceColumn value, String legacyFieldId) {
651        return value != null && (legacyFieldId.equals(value.getId()) || legacyFieldId.equals(value.getSource_id()));
652    }
653
654    private void mapSingleColumnForeignKey(relationship relation, State state) {
655        targetColumn target = relation.getTarget();
656        if (target == null || relation.getSources().isEmpty()) {
657            accountDedicatedLoss(relation, state, "SLPC_OBJECT_RELATIONSHIP_UNPUBLISHABLE",
658                    "Legacy FK/ER record has no target/source key pair.");
659            return;
660        }
661        String foreignRelation = relationEndpoint(target, state);
662        String foreignField = resolveTargetField(target, state);
663        sourceColumn source = relation.getSources().get(0);
664        String referencedRelation = relationEndpoint(source, state);
665        String referencedField = resolveSourceField(source, state);
666        boolean mapped = foreignRelation != null && foreignField != null
667                && referencedRelation != null && referencedField != null;
668        if (mapped) {
669            List<Object> participants = new ArrayList<Object>();
670            participants.add(participant("FOREIGN_KEY_RELATION", foreignRelation));
671            participants.add(participant("REFERENCED_RELATION", referencedRelation));
672            List<Object> pairs = Collections.<Object>singletonList(SlpcObject.builder().put("ordinal", 1)
673                    .put("foreignKeyFieldRef", foreignField).put("referencedFieldRef", referencedField).build());
674            addObjectRelationship("FOREIGN_KEY_REFERENCE", participants, null, pairs, legacyId(relation), state);
675        }
676        if (mapped) {
677            markForeignKeyMapped(relation, state);
678        } else {
679            accountDedicatedLoss(relation, state, "SLPC_OBJECT_RELATIONSHIP_UNPUBLISHABLE",
680                    "Legacy FK/ER participants could not be bound at relation and field levels.");
681        }
682    }
683
684    private void markForeignKeyMapped(relationship relation, State state) {
685        markDedicatedMapped(relation, state, "FOREIGN_KEY_REFERENCE");
686        state.diagnostics.add(diagnostic("SLPC_LEGACY_FK_DATAFLOW_RECLASSIFIED", "INFO", "DOCUMENT", null,
687                "Legacy FK/ER dataflow was reclassified as a structural FOREIGN_KEY_REFERENCE."));
688        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.EXPECTED_SEMANTIC_CORRECTION,
689                legacyId(relation), "SLPC_LEGACY_FK_DATAFLOW_RECLASSIFIED",
690                "A catalog foreign key is structural metadata and is not a runtime VALUE dependency."));
691    }
692
693    private void mapCallRelationship(relationship relation, State state) {
694        String caller = routineEndpoint(relation.getCaller(), relation.getProcedureId(), state);
695        boolean mapped = false;
696        boolean hasCallee = false;
697        for (sourceColumn calleeValue : relation.getCallees()) {
698            String callee = routineEndpoint(calleeValue, state);
699            if (callee != null) hasCallee = true;
700            if (caller == null || callee == null) continue;
701            List<Object> participants = new ArrayList<Object>();
702            participants.add(SlpcObject.builder().put("role", "CALLER_ROUTINE").put("endpointRef", caller).build());
703            participants.add(SlpcObject.builder().put("role", "CALLEE_ROUTINE").put("endpointRef", callee).build());
704            addObjectRelationship("CALLS", participants, null, null, legacyId(relation), state);
705            mapped = true;
706        }
707        if (mapped) markDedicatedMapped(relation, state, "CALLS");
708        else if (caller == null && hasCallee) {
709            // A top-level CALL has no caller routine by definition. It is
710            // accounted by the CALL_ROUTINE statement operation builder.
711        }
712        else accountDedicatedLoss(relation, state, "SLPC_OBJECT_RELATIONSHIP_UNPUBLISHABLE",
713                "A CALLS relationship requires both a proven caller routine and a cataloged callee routine; top-level calls remain statement operations.");
714    }
715
716    private void mapNonFactObjectRelationship(relationship relation, State state) {
717        targetColumn target = relation.getTarget();
718        if (target == null) return;
719        String targetRelation = relationEndpoint(target, state);
720        for (sourceColumn source : relation.getSources()) {
721            String sourceRelation = relationEndpoint(source, state);
722            if (targetRelation == null || sourceRelation == null) continue;
723            String targetKind = endpointKind(targetRelation, state);
724            String sourceKind = endpointKind(sourceRelation, state);
725            List<Object> participants = new ArrayList<Object>();
726            String relationshipKind = null;
727            if ("STREAM".equals(targetKind) && !isStorageKind(sourceKind)) {
728                relationshipKind = "STREAMS_FROM";
729                participants.add(participant("STREAM", targetRelation));
730                participants.add(participant("BASE_RELATION", sourceRelation));
731            } else if (isStorageKind(sourceKind) && !isStorageKind(targetKind)) {
732                relationshipKind = "READS_FROM";
733                participants.add(participant("CONSUMER_RELATION", targetRelation));
734                participants.add(participant("STORAGE_ASSET", sourceRelation));
735            } else if (!isStorageKind(sourceKind) && isStorageKind(targetKind)) {
736                relationshipKind = "WRITES_TO";
737                participants.add(participant("PRODUCER_RELATION", sourceRelation));
738                participants.add(participant("STORAGE_ASSET", targetRelation));
739            } else if (equalsAny(relation.getEffectType(), "synonym", "create_synonym")) {
740                relationshipKind = "SYNONYM_OF";
741                participants.add(participant("SYNONYM", targetRelation));
742                participants.add(participant("BASE_OBJECT", sourceRelation));
743            }
744            if (relationshipKind != null) addObjectRelationship(relationshipKind, participants, null, null,
745                    legacyId(relation), state);
746        }
747    }
748
749    private SlpcObject participant(String role, String endpointRef) {
750        return SlpcObject.builder().put("role", role).put("endpointRef", endpointRef).build();
751    }
752
753    private boolean isStorageKind(String kind) { return "FILE".equals(kind) || "STAGE".equals(kind); }
754
755    private boolean isScopedObjectKind(String kind) {
756        return equalsAny(kind, "TEMP_TABLE", "GLOBAL_TEMP_TABLE", "TABLE_VARIABLE", "SCALAR_VARIABLE", "CURSOR", "PARAMETER");
757    }
758
759    private String endpointKind(String ref, State state) {
760        SlpcObject endpoint = ref == null ? null : state.endpointByRef.get(ref);
761        return endpoint == null ? null : endpoint.string("objectKind");
762    }
763
764    private String routineEndpoint(targetColumn value, String fallbackId, State state) {
765        if (value == null) return endpointAtLevel(state.endpointByLegacyId.get(fallbackId), "ROUTINE", state);
766        String result = endpointAtLevel(state.endpointByLegacyId.get(value.getId()), "ROUTINE", state);
767        if (result == null) result = endpointAtLevel(state.endpointByLegacyId.get(value.getTarget_id()), "ROUTINE", state);
768        if (result == null) result = endpointAtLevel(state.endpointByLegacyId.get(value.getParent_id()), "ROUTINE", state);
769        if (result == null) result = endpointAtLevel(state.endpointByLegacyId.get(fallbackId), "ROUTINE", state);
770        return result;
771    }
772
773    private String routineEndpoint(sourceColumn value, State state) {
774        String result = endpointAtLevel(state.endpointByLegacyId.get(value.getId()), "ROUTINE", state);
775        if (result == null) result = endpointAtLevel(state.endpointByLegacyId.get(value.getSource_id()), "ROUTINE", state);
776        if (result == null) result = endpointAtLevel(state.endpointByLegacyId.get(value.getParent_id()), "ROUTINE", state);
777        return result;
778    }
779
780    private String routineEndpoint(String legacyProcedureId, State state) {
781        return endpointAtLevel(state.endpointByLegacyId.get(legacyProcedureId), "ROUTINE", state);
782    }
783
784    private void addObjectRelationship(String kind, List<Object> participants, List<Object> keyFields,
785            List<Object> keyPairs, String legacyEvidence, State state) {
786        SlpcObject.Builder semanticBuilder = SlpcObject.builder().put("relationshipKind", kind)
787                .put("participants", participants);
788        if (keyFields != null) semanticBuilder.put("keyFields", keyFields);
789        if (keyPairs != null) semanticBuilder.put("keyPairs", keyPairs);
790        SlpcObject semantic = semanticBuilder.build();
791        boolean canonical = relationshipCanonical(semantic, state);
792        String fingerprint = canonical ? SlpcIdentity.objectRelationshipId(semantic, state.endpointByRef)
793                : SlpcIdentity.objectRelationshipCandidateFingerprint(semantic, state.endpointByRef);
794        RelationshipGroup group = state.relationshipGroups.get(fingerprint);
795        if (group == null) {
796            group = new RelationshipGroup(semantic, canonical, fingerprint);
797            state.relationshipGroups.put(fingerprint, group);
798        } else if (!group.semantic.equals(semantic)) {
799            throw new IllegalStateException("SLPC object relationship fingerprint collision: " + fingerprint);
800        }
801        group.legacyEvidence.add(legacyEvidence);
802        List<Object> locations = state.sourceLocationsByLegacyEvidence.get(legacyEvidence);
803        if (locations != null) for (Object value : locations) {
804            SlpcObject location = (SlpcObject) value;
805            group.sourceLocations.put(sourceLocationKey(location), location);
806        }
807    }
808
809    private boolean relationshipCanonical(SlpcObject semantic, State state) {
810        for (Object value : semantic.array("participants")) {
811            if (!canonicalEndpoint(((SlpcObject) value).string("endpointRef"), state)) return false;
812        }
813        for (String details : new String[] {"keyFields", "keyPairs"}) {
814            List<Object> values = semantic.array(details);
815            if (values == null) continue;
816            for (Object value : values) {
817                SlpcObject item = (SlpcObject) value;
818                for (String field : new String[] {"fieldEndpointRef", "foreignKeyFieldRef", "referencedFieldRef"}) {
819                    if (item.string(field) != null && !canonicalEndpoint(item.string(field), state)) return false;
820                }
821            }
822        }
823        return true;
824    }
825
826    private boolean canonicalEndpoint(String ref, State state) {
827        SlpcObject endpoint = ref == null ? null : state.endpointByRef.get(ref);
828        return endpoint != null && endpoint.string("canonicalEndpointId") != null;
829    }
830
831    private void markDedicatedMapped(relationship relation, State state, String target) {
832        if (!state.dedicatedRelationshipAccounting.add(legacyId(relation))) return;
833        state.coverage.add(new LegacyCoverageReport.Entry("RELATIONSHIP", legacyId(relation),
834                LegacyCoverageReport.Outcome.CONDITIONAL, target,
835                "legacy record mapped to typed SLPC structural/operation semantics; identity controls publication"));
836    }
837
838    private void accountDedicatedLoss(relationship relation, State state, String code, String message) {
839        if (!state.dedicatedRelationshipAccounting.add(legacyId(relation))) return;
840        accountLoss(relation, state, code, message);
841    }
842
843    private void buildLineage(dataflow legacy, State state) {
844        for (relationship relation : legacy.getRelationships()) {
845            if (equalsAny(relation.getType(), "join", "call", "er", "crud", "fddi")) {
846                // Consumed by their dedicated structural, operation, join, or
847                // verified path-evidence mapping.
848                continue;
849            }
850            if (!"fdd".equals(relation.getType()) && !"fdr".equals(relation.getType())
851                    && !"frd".equals(relation.getType())) {
852                accountUnsupportedRelationship(relation, state);
853                continue;
854            }
855            if (state.dedicatedRelationshipAccounting.contains(legacyId(relation))) continue;
856            if (unsupportedFactEffect(relation.getEffectType())) {
857                accountLoss(relation, state, "SLPC_OPERATION_UNSUPPORTED",
858                        "Legacy effectType '" + safe(relation.getEffectType())
859                                + "' requires a typed operation/object relationship mapping and is not published as a VALUE/ROW fact in M1.");
860                continue;
861            }
862            targetColumn target = relation.getTarget();
863            if (target == null || relation.getSources().isEmpty()) {
864                accountLoss(relation, state, "SLPC_ENDPOINT_UNRESOLVED",
865                        "Dataflow relation has no target or source in the completed legacy POJO.");
866                continue;
867            }
868            List<String> owners = new ArrayList<String>();
869            boolean conditional = effectNeedsAdditionalMapping(relation.getEffectType());
870            if (conditional) {
871                accountSemanticGap(relation, "SLPC_OPERATION_UNSUPPORTED",
872                        "Legacy effectType '" + safe(relation.getEffectType())
873                                + "' retains its proven data dependency, but its additional statement/operation semantics are not mapped in M1.", state);
874            }
875            for (sourceColumn source : relation.getSources()) {
876                if (hasCandidateParents(source)) {
877                    conditional = true;
878                    accountSemanticGap(relation, "SLPC_ENDPOINT_AMBIGUOUS",
879                            "Legacy sourceColumn contains multiple candidate parents; the adapter will not select one as the lineage source.", state);
880                    continue;
881                }
882                FactSemantics semantics = semantics(relation, source);
883                if (semantics.diagnosticCode != null) {
884                    conditional = true;
885                    accountSemanticGap(relation, semantics.diagnosticCode, semantics.diagnosticMessage, state);
886                }
887                if (!semantics.publish) continue;
888                String targetRef = resolveTarget(target, semantics, state);
889                if (targetRef == null) {
890                    accountSemanticGap(relation, "SLPC_ENDPOINT_UNRESOLVED",
891                            "The legacy target cannot be bound at the endpoint level required by " + semantics.axis + ".", state);
892                    continue;
893                }
894                String sourceRef = semantics.derived ? null : resolveSource(source, semantics, state);
895                if (!semantics.derived && sourceRef == null) {
896                    accountSemanticGap(relation, "SLPC_ENDPOINT_UNRESOLVED",
897                            "A proven legacy relation source could not be bound without coarsening its endpoint level.", state);
898                    continue;
899                }
900                SlpcObject semantic = semanticRecord(sourceRef, targetRef, semantics, state);
901                boolean canonical = isCanonicalFact(semantic, state.endpointByRef);
902                String semanticKey = canonical ? SlpcIdentity.factId(semantic, state.endpointByRef)
903                        : SlpcIdentity.factCandidateFingerprint(semantic, state.endpointByRef);
904                FactGroup group = state.factGroups.get(semanticKey);
905                if (group == null) {
906                    group = new FactGroup(semantic, canonical, semanticKey);
907                    state.factGroups.put(semanticKey, group);
908                } else if (!group.sameSemantic(semantic)) {
909                    throw new IllegalStateException("SLPC fact fingerprint collision: " + semanticKey);
910                }
911                SlpcObject transformation = transformationEvidence(relation, source);
912                SlpcObject predicate = evidence(predicateText(relation, semantics));
913                List<Object> literalInputs = semantics.derived
914                        ? Collections.<Object>singletonList(redactedLiteral(state, state.nextRedactionOrdinal++))
915                        : Collections.<Object>emptyList();
916                FactOccurrenceDraft occurrence = new FactOccurrenceDraft(group, transformation, predicate,
917                        canonical ? "RESOLVED" : "UNRESOLVED", literalInputs, Collections.<Object>emptyList(),
918                        sourceLocations(relation, state), routineEndpoint(relation.getProcedureId(), state));
919                group.occurrences.add(occurrence); state.factOccurrenceDrafts.add(occurrence); owners.add(semanticKey);
920            }
921            if (owners.isEmpty()) {
922                accountLoss(relation, state, "SLPC_ENDPOINT_NOT_PUBLISHABLE",
923                        "No source could be represented without inventing an endpoint.");
924            } else {
925                state.coverage.add(new LegacyCoverageReport.Entry("RELATIONSHIP", legacyId(relation),
926                        conditional ? LegacyCoverageReport.Outcome.CONDITIONAL
927                                : allCanonical(owners, state) ? LegacyCoverageReport.Outcome.EXACT : LegacyCoverageReport.Outcome.QUARANTINED,
928                        join(owners), "VALUE/ROW semantics preserved; identity controls publication"));
929            }
930        }
931    }
932
933    private void buildStatementOperation(dataflow legacy, State state) {
934        String kind = operationKind(state.context.statementKind());
935        List<Object> participants = new ArrayList<Object>();
936        List<String> physicalSources = new ArrayList<String>(), targets = new ArrayList<String>(), outputs = new ArrayList<String>();
937        for (table relation : dataflow.getAllTables(legacy)) {
938            String ref = state.tableRefs.get(relation);
939            if (ref == null) continue;
940            if (isIntermediate(relation)) outputs.add(ref);
941            else if (relation.isTarget() || createdInSql(relation)) targets.add(ref);
942            else physicalSources.add(ref);
943        }
944        sortEndpointRefs(physicalSources, state); sortEndpointRefs(targets, state); sortEndpointRefs(outputs, state);
945        physicalSources = distinct(physicalSources); targets = distinct(targets); outputs = distinct(outputs);
946        if ("QUERY".equals(kind)) {
947            addParticipants(participants, "READ_RELATION", physicalSources);
948            if (!outputs.isEmpty()) {
949                operationLoss(state, "SLPC_ADAPTER_CAPABILITY_GAP",
950                        "Legacy result-set names and cleared isTarget flags do not prove which intermediate is the top-level QUERY output; OUTPUT_RELATION is omitted.");
951            }
952        } else if ("INSERT".equals(kind) || "UPDATE".equals(kind) || "DELETE".equals(kind)) {
953            List<String> writeTargets = physicalRelationshipTargets(legacy, state);
954            if (writeTargets.size() != 1) {
955                operationLoss(state, "SLPC_ENDPOINT_UNRESOLVED",
956                        "A " + kind + " operation requires exactly one proven legacy target; observed " + writeTargets.size() + ".");
957                return;
958            }
959            addParticipants(participants, "WRITE_TARGET", writeTargets);
960            physicalSources.removeAll(writeTargets);
961            addParticipants(participants, "READ_SOURCE", physicalSources);
962        } else if ("MERGE".equals(kind)) {
963            List<String> mergeTargets = physicalRelationshipTargets(legacy, state);
964            if (mergeTargets.size() != 1) {
965                operationLoss(state, "SLPC_ENDPOINT_UNRESOLVED",
966                        "A MERGE operation requires exactly one proven legacy target; observed " + mergeTargets.size() + ".");
967                return;
968            }
969            addParticipants(participants, "MERGE_TARGET", mergeTargets);
970            physicalSources.removeAll(mergeTargets);
971            addParticipants(participants, "MERGE_SOURCE", physicalSources);
972        } else if (equalsAny(kind, "CREATE_TABLE", "CREATE_TABLE_AS", "CREATE_VIEW", "CREATE_MATERIALIZED_VIEW")) {
973            List<String> createdObjects = "CREATE_VIEW".equals(kind) ? refsByKind(legacy, state, "VIEW")
974                    : "CREATE_MATERIALIZED_VIEW".equals(kind) ? refsByKind(legacy, state, "MATERIALIZED_VIEW")
975                    : targets;
976            String created = singleOrLoss(createdObjects, kind + " CREATED_OBJECT", state);
977            if (created == null) return;
978            addParticipants(participants, "CREATED_OBJECT", Collections.singletonList(created));
979            if (!"CREATE_TABLE".equals(kind)) addParticipants(participants, "READ_SOURCE", physicalSources);
980        } else if ("CREATE_EXTERNAL_TABLE".equals(kind)) {
981            List<String> storage = refsByStorage(legacy, state, true), relations = refsByStorage(legacy, state, false);
982            relations.retainAll(targets);
983            String created = singleOrLoss(relations, "CREATE_EXTERNAL_TABLE CREATED_OBJECT", state);
984            if (created == null) return;
985            addParticipants(participants, "CREATED_OBJECT", Collections.singletonList(created));
986            addParticipants(participants, "STORAGE_SOURCE", storage);
987        } else if ("CREATE_STAGE".equals(kind)) {
988            List<String> stages = refsByKind(legacy, state, "STAGE");
989            String created = singleOrLoss(stages, "CREATE_STAGE CREATED_OBJECT", state);
990            if (created == null) return;
991            addParticipants(participants, "CREATED_OBJECT", Collections.singletonList(created));
992            List<String> files = refsByKind(legacy, state, "FILE");
993            addParticipants(participants, "STORAGE_SOURCE", files);
994        } else if ("CREATE_STREAM".equals(kind)) {
995            String[] pair = firstRelationshipPair(state.relationshipGroups, "STREAMS_FROM");
996            if (pair == null || !canonicalEndpoint(pair[0], state) || !canonicalEndpoint(pair[1], state)) {
997                operationLoss(state, "SLPC_ENDPOINT_UNRESOLVED",
998                        "CREATE_STREAM requires one canonical STREAMS_FROM pair; candidate-only structure is retained without an invalid operation.");
999                return;
1000            }
1001            addParticipants(participants, "CREATED_STREAM", Collections.singletonList(pair[0]));
1002            addParticipants(participants, "BASE_RELATION", Collections.singletonList(pair[1]));
1003        } else if ("CREATE_ROUTINE".equals(kind)) {
1004            String routine = singleOrLoss(refsAtLevel(state, "ROUTINE"), "CREATE_ROUTINE CREATED_ROUTINE", state);
1005            if (routine == null) return;
1006            addParticipants(participants, "CREATED_ROUTINE", Collections.singletonList(routine));
1007        } else if ("CALL_ROUTINE".equals(kind)) {
1008            List<String> callees = new ArrayList<String>();
1009            for (relationship relation : legacy.getRelationships()) if ("call".equals(relation.getType())) {
1010                for (sourceColumn callee : relation.getCallees()) {
1011                    String ref = routineEndpoint(callee, state); if (ref != null) callees.add(ref);
1012                }
1013            }
1014            sortEndpointRefs(callees, state); callees = distinct(callees);
1015            String callee = singleOrLoss(callees, "CALL_ROUTINE CALLEE_ROUTINE", state);
1016            if (callee == null) return;
1017            addParticipants(participants, "CALLEE_ROUTINE", Collections.singletonList(callee));
1018            for (relationship relation : legacy.getRelationships()) if ("call".equals(relation.getType())) {
1019                markDedicatedMapped(relation, state, "CALL_ROUTINE");
1020            }
1021        } else if ("RENAME".equals(kind) || "SWAP".equals(kind) || "CLONE".equals(kind)) {
1022            String effect = "RENAME".equals(kind) ? "rename_table" : "SWAP".equals(kind) ? "swap_table" : "clone_table";
1023            String[] pair = operationPair(legacy, effect, state);
1024            if (pair == null) { operationLoss(state, "SLPC_ENDPOINT_UNRESOLVED", kind + " requires one proven source/target object pair."); return; }
1025            if ("RENAME".equals(kind)) {
1026                addParticipants(participants, "BEFORE_OBJECT", Collections.singletonList(pair[0]));
1027                addParticipants(participants, "AFTER_OBJECT", Collections.singletonList(pair[1]));
1028            } else if ("SWAP".equals(kind)) {
1029                addParticipants(participants, "SWAP_LEFT_OBJECT", Collections.singletonList(pair[1]));
1030                addParticipants(participants, "SWAP_RIGHT_OBJECT", Collections.singletonList(pair[0]));
1031            } else {
1032                addParticipants(participants, "CLONE_SOURCE", Collections.singletonList(pair[0]));
1033                addParticipants(participants, "CLONE_TARGET", Collections.singletonList(pair[1]));
1034            }
1035        } else if ("LOAD".equals(kind)) {
1036            List<String> storage = refsByStorage(legacy, state, true);
1037            List<String> writes = nonStorageTargets(legacy, state);
1038            String target = singleOrLoss(writes, "LOAD WRITE_TARGET", state); if (target == null) return;
1039            addParticipants(participants, "STORAGE_SOURCE", storage);
1040            addParticipants(participants, "WRITE_TARGET", Collections.singletonList(target));
1041            markEffects(legacy, state, "LOAD", "copy", "load");
1042        } else if ("UNLOAD".equals(kind) || "INSERT_OVERWRITE_DIRECTORY".equals(kind)) {
1043            List<String> storage = refsByStorage(legacy, state, true);
1044            String target = singleOrLoss(storage, kind + " STORAGE_TARGET", state); if (target == null) return;
1045            addParticipants(participants, "READ_SOURCE", nonStorageSources(legacy, state));
1046            addParticipants(participants, "STORAGE_TARGET", Collections.singletonList(target));
1047            markEffects(legacy, state, kind, "unload", "copy");
1048        } else if (equalsAny(kind, "ALTER", "DROP", "TRUNCATE")) {
1049            List<String> affected = affectedObjects(legacy, state);
1050            String target = singleOrLoss(affected, kind + " AFFECTED_OBJECT", state); if (target == null) return;
1051            addParticipants(participants, "AFFECTED_OBJECT", Collections.singletonList(target));
1052            for (relationship relation : legacy.getRelationships()) if ("crud".equals(relation.getType())
1053                    || isOperationEffect(relation.getEffectType())) markDedicatedMapped(relation, state, kind);
1054        } else {
1055            List<String> affected = new ArrayList<String>(); affected.addAll(targets); affected.addAll(physicalSources);
1056            sortEndpointRefs(affected, state); addParticipants(participants, "AFFECTED_OBJECT", distinct(affected));
1057        }
1058        String content = contentSemantics(kind);
1059        String resolution = participantsCanonical(participants, state) ? "RESOLVED" : "UNRESOLVED";
1060        SlpcObject provisional = SlpcObject.builder().put("statementOperationId", "slso1-" + zeros(64))
1061                .put("statementId", state.statementId).put("operationOrdinal", 1).put("operationKind", kind)
1062                .put("participants", participants).put("contentSemantics", content).put("quality", occurrenceQuality(resolution))
1063                .put("sourceLocations", statementSourceLocations(state.statement))
1064                .put("extensions", Collections.emptyList()).build();
1065        state.statementOperations.add(provisional.toBuilder()
1066                .put("statementOperationId", SlpcIdentity.statementOperationId(provisional, state.endpointByRef)).build());
1067    }
1068
1069    private void operationLoss(State state, String code, String message) {
1070        state.diagnostics.add(diagnostic(code, "WARN", "STATEMENT", state.statementId, message));
1071        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
1072                state.statementId, code, message));
1073    }
1074
1075    private String operationKind(String statementKind) {
1076        String normalized = safe(statementKind).trim().toUpperCase(java.util.Locale.ROOT)
1077                .replace('-', '_').replace(' ', '_');
1078        if ("SELECT".equals(normalized)) return "QUERY";
1079        if ("CREATE_PROCEDURE".equals(normalized) || "CREATE_FUNCTION".equals(normalized)) return "CREATE_ROUTINE";
1080        if ("CALL".equals(normalized) || "EXECUTE".equals(normalized) || "EXEC".equals(normalized)) return "CALL_ROUTINE";
1081        if ("ALTER_TABLE".equals(normalized)) return "ALTER";
1082        if ("DROP_TABLE".equals(normalized)) return "DROP";
1083        if ("TRUNCATE_TABLE".equals(normalized)) return "TRUNCATE";
1084        if (equalsAny(normalized, "QUERY", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE_TABLE",
1085                "CREATE_TABLE_AS", "CREATE_VIEW", "CREATE_MATERIALIZED_VIEW", "CREATE_EXTERNAL_TABLE",
1086                "CREATE_STAGE", "CREATE_STREAM", "CREATE_ROUTINE", "CREATE_SYNONYM", "CREATE_INDEX",
1087                "RENAME", "SWAP", "CLONE", "LOAD", "UNLOAD", "INSERT_OVERWRITE_DIRECTORY",
1088                "CALL_ROUTINE", "ALTER", "DROP", "TRUNCATE", "OTHER")) return normalized;
1089        return "OTHER";
1090    }
1091
1092    private String contentSemantics(String operationKind) {
1093        if ("QUERY".equals(operationKind)) return "CONTENT_READ";
1094        if ("INSERT".equals(operationKind) || "LOAD".equals(operationKind)) return "CONTENT_WRITE";
1095        if (equalsAny(operationKind, "UPDATE", "DELETE", "MERGE", "ALTER")) return "MUTATION";
1096        if (equalsAny(operationKind, "CREATE_TABLE", "CREATE_VIEW", "CREATE_EXTERNAL_TABLE", "CREATE_STAGE",
1097                "CREATE_STREAM", "CREATE_ROUTINE", "CREATE_SYNONYM", "CREATE_INDEX")) return "DEFINITION_ONLY";
1098        if (equalsAny(operationKind, "CREATE_TABLE_AS", "CREATE_MATERIALIZED_VIEW", "CLONE", "UNLOAD",
1099                "INSERT_OVERWRITE_DIRECTORY")) return "CONTENT_COPY";
1100        if ("RENAME".equals(operationKind)) return "IDENTITY_CONTINUITY";
1101        if ("SWAP".equals(operationKind)) return "CONTENT_EXCHANGE";
1102        if ("CALL_ROUTINE".equals(operationKind)) return "NOT_APPLICABLE";
1103        if (equalsAny(operationKind, "DROP", "TRUNCATE")) return "DELETION";
1104        return "UNKNOWN";
1105    }
1106
1107    private String singleOrLoss(List<String> values, String label, State state) {
1108        sortEndpointRefs(values, state); values = distinct(values);
1109        if (values.size() == 1) return values.get(0);
1110        operationLoss(state, "SLPC_ENDPOINT_UNRESOLVED", label + " requires exactly one endpoint; observed " + values.size() + ".");
1111        return null;
1112    }
1113
1114    private List<String> refsAtLevel(State state, String level) {
1115        List<String> result = new ArrayList<String>();
1116        for (Map.Entry<String, SlpcObject> entry : state.endpointByRef.entrySet()) {
1117            if (level.equals(entry.getValue().string("endpointLevel"))) result.add(entry.getKey());
1118        }
1119        sortEndpointRefs(result, state); return distinct(result);
1120    }
1121
1122    private List<String> refsByKind(dataflow legacy, State state, String kind) {
1123        List<String> result = new ArrayList<String>();
1124        for (table relation : dataflow.getAllTables(legacy)) {
1125            String ref = state.tableRefs.get(relation);
1126            if (ref != null && kind.equals(endpointKind(ref, state))) result.add(ref);
1127        }
1128        sortEndpointRefs(result, state); return distinct(result);
1129    }
1130
1131    private List<String> refsByStorage(dataflow legacy, State state, boolean storage) {
1132        List<String> result = new ArrayList<String>();
1133        for (table relation : dataflow.getAllTables(legacy)) {
1134            String ref = state.tableRefs.get(relation); if (ref == null || isIntermediate(relation)) continue;
1135            if (isStorageKind(endpointKind(ref, state)) == storage) result.add(ref);
1136        }
1137        sortEndpointRefs(result, state); return distinct(result);
1138    }
1139
1140    private List<String> nonStorageTargets(dataflow legacy, State state) {
1141        List<String> result = new ArrayList<String>();
1142        for (relationship relation : legacy.getRelationships()) {
1143            if (relation.getTarget() == null) continue;
1144            String ref = relationEndpoint(relation.getTarget(), state);
1145            if (ref != null && !isStorageKind(endpointKind(ref, state))) result.add(ref);
1146        }
1147        sortEndpointRefs(result, state); return distinct(result);
1148    }
1149
1150    private List<String> physicalRelationshipTargets(dataflow legacy, State state) {
1151        List<String> physical = new ArrayList<String>();
1152        for (relationship relation : legacy.getRelationships()) {
1153            if (!"fdd".equals(relation.getType()) || "foreign_key".equals(relation.getEffectType())
1154                    || relation.getTarget() == null) continue;
1155            String ref = relationEndpoint(relation.getTarget(), state);
1156            if (ref != null && !isStorageKind(endpointKind(ref, state))
1157                    && !isIntermediateEndpoint(ref, state)) physical.add(ref);
1158        }
1159        sortEndpointRefs(physical, state); return distinct(physical);
1160    }
1161
1162    private List<String> nonStorageSources(dataflow legacy, State state) {
1163        List<String> result = new ArrayList<String>();
1164        for (relationship relation : legacy.getRelationships()) for (sourceColumn source : relation.getSources()) {
1165            String ref = relationEndpoint(source, state);
1166            if (ref != null && !isStorageKind(endpointKind(ref, state)) && !isIntermediateEndpoint(ref, state)) result.add(ref);
1167        }
1168        sortEndpointRefs(result, state); return distinct(result);
1169    }
1170
1171    private boolean isIntermediateEndpoint(String ref, State state) {
1172        String kind = endpointKind(ref, state);
1173        return equalsAny(kind, "RESULT_SET", "CTE", "DERIVED_TABLE");
1174    }
1175
1176    private String[] operationPair(dataflow legacy, String effect, State state) {
1177        for (relationship relation : legacy.getRelationships()) {
1178            if (!effect.equals(relation.getEffectType()) || relation.getTarget() == null || relation.getSources().size() != 1) continue;
1179            String before = relationEndpoint(relation.getSources().get(0), state);
1180            String after = relationEndpoint(relation.getTarget(), state);
1181            if (before == null || after == null) continue;
1182            markDedicatedMapped(relation, state, effect);
1183            return new String[] {before, after};
1184        }
1185        return null;
1186    }
1187
1188    private String[] firstRelationshipPair(Map<String, RelationshipGroup> groups, String kind) {
1189        for (RelationshipGroup group : groups.values()) {
1190            if (!kind.equals(group.semantic.string("relationshipKind"))) continue;
1191            List<Object> participants = group.semantic.array("participants");
1192            if (participants.size() == 2) return new String[] {
1193                    ((SlpcObject) participants.get(0)).string("endpointRef"),
1194                    ((SlpcObject) participants.get(1)).string("endpointRef")};
1195        }
1196        return null;
1197    }
1198
1199    private List<String> affectedObjects(dataflow legacy, State state) {
1200        List<String> result = new ArrayList<String>();
1201        for (relationship relation : legacy.getRelationships()) {
1202            if (!"crud".equals(relation.getType()) && !isOperationEffect(relation.getEffectType())) continue;
1203            if (relation.getTarget() != null) {
1204                String ref = relationEndpoint(relation.getTarget(), state);
1205                if (ref == null) ref = endpointAtLevel(state.endpointByLegacyId.get(relation.getTarget().getId()), "RELATION", state);
1206                if (ref == null) ref = endpointAtLevel(state.endpointByLegacyId.get(relation.getTarget().getTarget_id()), "RELATION", state);
1207                if (ref != null) result.add(ref);
1208            }
1209            for (sourceColumn source : relation.getSources()) {
1210                String ref = relationEndpoint(source, state); if (ref != null) result.add(ref);
1211            }
1212        }
1213        if (result.isEmpty()) {
1214            for (table relation : dataflow.getAllTables(legacy)) if (relation.isTarget() && !isIntermediate(relation)) {
1215                String ref = state.tableRefs.get(relation); if (ref != null) result.add(ref);
1216            }
1217        }
1218        sortEndpointRefs(result, state); return distinct(result);
1219    }
1220
1221    private boolean isOperationEffect(String effect) {
1222        return equalsAny(effect, "rename_table", "swap_table", "clone_table", "clone_schema", "clone_database",
1223                "unload", "copy", "drop_table", "drop_table_column", "add_table_column", "truncate_table");
1224    }
1225
1226    private boolean createdInSql(table relation) {
1227        return "true".equalsIgnoreCase(nonEmpty(relation.getCreatedInSql()));
1228    }
1229
1230    private void markEffects(dataflow legacy, State state, String target, String... effects) {
1231        for (relationship relation : legacy.getRelationships()) {
1232            if (equalsAny(relation.getEffectType(), effects)) markDedicatedMapped(relation, state, target);
1233        }
1234    }
1235
1236    private void addParticipants(List<Object> target, String role, List<String> endpointRefs) {
1237        for (String ref : endpointRefs) target.add(SlpcObject.builder().put("role", role).put("endpointRef", ref).build());
1238    }
1239
1240    private void sortEndpointRefs(List<String> refs, final State state) {
1241        Collections.sort(refs, new Comparator<String>() { @Override public int compare(String left, String right) {
1242            return utf8Compare(SlpcIdentity.candidateEndpointKey(state.endpointByRef.get(left)),
1243                    SlpcIdentity.candidateEndpointKey(state.endpointByRef.get(right)));
1244        }});
1245    }
1246
1247    private List<String> distinct(List<String> values) {
1248        List<String> result = new ArrayList<String>();
1249        for (String value : values) if (!result.contains(value)) result.add(value);
1250        return result;
1251    }
1252
1253    private boolean participantsCanonical(List<Object> participants, State state) {
1254        for (Object value : participants) {
1255            SlpcObject participant = (SlpcObject) value;
1256            if (state.endpointByRef.get(participant.string("endpointRef")).string("canonicalEndpointId") == null) return false;
1257        }
1258        return true;
1259    }
1260
1261    private void materializeFactsAndOccurrences(State state, List<FactOccurrenceDraft> occurrenceDrafts) {
1262        List<FactGroup> canonical = new ArrayList<FactGroup>(), candidates = new ArrayList<FactGroup>();
1263        for (FactGroup group : state.factGroups.values()) { if (group.canonical) canonical.add(group); else candidates.add(group); }
1264        Collections.sort(canonical, factGroupComparator()); Collections.sort(candidates, factGroupComparator());
1265        for (FactGroup group : canonical) { group.ownerKind = "FACT"; group.ownerId = group.semanticKey; }
1266        for (int index = 0; index < candidates.size(); index++) { FactGroup group = candidates.get(index); group.ownerKind = "FACT_CANDIDATE"; group.ownerId = local("fact-cand-", index + 1); }
1267
1268        Map<String, String> ownerSemanticKeys = new HashMap<String, String>();
1269        for (FactGroup group : canonical) ownerSemanticKeys.put(group.ownerKind + "\u0000" + group.ownerId, group.semanticKey);
1270        for (FactGroup group : candidates) ownerSemanticKeys.put(group.ownerKind + "\u0000" + group.ownerId, group.semanticKey);
1271        Map<String, SlpcObject> uniqueOccurrences = new TreeMap<String, SlpcObject>();
1272        for (FactOccurrenceDraft draft : occurrenceDrafts) {
1273            SlpcObject withoutId = occurrence(draft, state, "slocc1-" + zeros(64));
1274            String id = SlpcIdentity.occurrenceId(withoutId, ownerSemanticKeys);
1275            SlpcObject occurrence = withoutId.toBuilder().put("occurrenceId", id).build();
1276            SlpcObject previous = uniqueOccurrences.get(id);
1277            uniqueOccurrences.put(id, previous == null ? occurrence : mergeOccurrence(previous, occurrence));
1278        }
1279        state.occurrences.addAll(uniqueOccurrences.values());
1280
1281        Map<String, Integer> evidenceCounts = new HashMap<String, Integer>();
1282        for (Object value : state.occurrences) {
1283            SlpcObject occurrence = (SlpcObject) value;
1284            SlpcObject owner = occurrence.object("ownerRef"); String key = owner.string("kind") + "\u0000" + owner.string("id");
1285            Integer old = evidenceCounts.get(key); evidenceCounts.put(key, old == null ? 1 : old + 1);
1286        }
1287        for (FactGroup group : canonical) state.facts.add(materializeFact(group, evidenceCounts));
1288        for (FactGroup group : candidates) state.factCandidates.add(materializeFact(group, evidenceCounts));
1289    }
1290
1291    private SlpcObject materializeFact(FactGroup group, Map<String, Integer> counts) {
1292        int evidenceCount = counts.get(group.ownerKind + "\u0000" + group.ownerId);
1293        String resolution = group.canonical ? "RESOLVED" : "UNRESOLVED";
1294        SlpcObject quality = qualitySummary(group.canonical ? "ELIGIBLE" : "INELIGIBLE", resolution, evidenceCount);
1295        SlpcObject.Builder result = SlpcObject.builder();
1296        if (group.canonical) result.put("factId", group.semanticKey);
1297        else result.put("candidateId", group.ownerId).put("candidateFingerprint", group.semanticKey);
1298        result.put("sourceEndpointRef", group.semantic.get("sourceEndpointRef"))
1299                .put("targetEndpointRef", group.semantic.string("targetEndpointRef"))
1300                .put("axis", group.semantic.string("axis")).put("role", group.semantic.string("role"))
1301                .put("operationKind", group.semantic.string("operationKind"))
1302                .put("operationInputKind", group.semantic.string("operationInputKind"));
1303        if (!group.canonical) result.put("candidateReasons", Collections.singletonList(candidateReason(group.semantic)));
1304        return result.put("qualitySummary", quality).put("extensions", Collections.emptyList()).build();
1305    }
1306
1307    private SlpcObject mergeOccurrence(SlpcObject left, SlpcObject right) {
1308        SlpcObject leftBase = left.toBuilder().remove("literalInputs").remove("supportPathRefs").remove("sourceLocations").build();
1309        SlpcObject rightBase = right.toBuilder().remove("literalInputs").remove("supportPathRefs").remove("sourceLocations").build();
1310        if (!leftBase.equals(rightBase)) throw new IllegalStateException("SLPC occurrence ID collision: " + left.string("occurrenceId"));
1311        Map<String, SlpcObject> literals = new TreeMap<String, SlpcObject>();
1312        addLiterals(literals, left.array("literalInputs")); addLiterals(literals, right.array("literalInputs"));
1313        List<Object> merged = new ArrayList<Object>(); int ordinal = 1;
1314        for (SlpcObject literal : literals.values()) merged.add(literal.toBuilder().put("inputOrdinal", ordinal++).build());
1315        SlpcObject.Builder result = left.toBuilder(); if (!merged.isEmpty()) result.put("literalInputs", merged);
1316        Map<String, SlpcObject> paths = new TreeMap<String, SlpcObject>();
1317        addSupportPaths(paths, left.array("supportPathRefs")); addSupportPaths(paths, right.array("supportPathRefs"));
1318        if (!paths.isEmpty()) result.put("supportPathRefs", new ArrayList<Object>(paths.values()));
1319        Map<String, SlpcObject> locations = new TreeMap<String, SlpcObject>();
1320        addSourceLocations(locations, left.array("sourceLocations")); addSourceLocations(locations, right.array("sourceLocations"));
1321        result.put("sourceLocations", new ArrayList<Object>(locations.values()));
1322        return result.build();
1323    }
1324
1325    private void addSourceLocations(Map<String, SlpcObject> target, List<Object> values) {
1326        if (values == null) return;
1327        for (Object value : values) {
1328            SlpcObject location = (SlpcObject) value; target.put(sourceLocationKey(location), location);
1329        }
1330    }
1331
1332    private String sourceLocationKey(SlpcObject location) {
1333        return location.string("artifactId") + ":" + location.get("startLine") + ":"
1334                + location.get("startColumn") + ":" + location.get("endLine") + ":" + location.get("endColumn");
1335    }
1336
1337    private void addSupportPaths(Map<String, SlpcObject> target, List<Object> values) {
1338        if (values == null) return;
1339        for (Object value : values) {
1340            SlpcObject support = (SlpcObject) value;
1341            target.put(support.string("graphRef") + "\u0000" + support.string("pathRef"), support);
1342        }
1343    }
1344
1345    private void addLiterals(Map<String, SlpcObject> target, List<Object> values) {
1346        if (values == null) return;
1347        for (Object value : values) { SlpcObject literal = (SlpcObject) value; SlpcObject fp = literal.object("fingerprint");
1348            target.put(fp.string("algorithm") + "\u0000" + fp.string("value"), literal); }
1349    }
1350
1351    private SlpcObject occurrence(FactOccurrenceDraft draft, State state, String id) {
1352        SlpcObject owner = SlpcObject.builder().put("kind", draft.group.ownerKind).put("id", draft.group.ownerId).build();
1353        SlpcObject origin = SlpcObject.builder().put("engineKind", "LEGACY_DLINEAGE").put("adapterName", ADAPTER_NAME).build();
1354        SlpcObject quality = occurrenceQuality(draft.resolution);
1355        SlpcObject.Builder result = SlpcObject.builder().put("occurrenceId", id).put("ownerRef", owner)
1356                .put("artifactId", state.artifact.string("artifactId")).put("statementId", state.statementId)
1357                .put("evidenceKind", "STATIC_SQL").put("origin", origin)
1358                .putNull("procedureEndpointRef").putNull("procedureCanonicalEndpointId")
1359                .put("transformation", draft.transformation).put("predicate", draft.predicate)
1360                .put("quality", quality).put("sourceLocations", draft.sourceLocations)
1361                .put("extensions", Collections.emptyList());
1362        if (draft.procedureEndpointRef != null) {
1363            result.put("procedureEndpointRef", draft.procedureEndpointRef)
1364                    .put("procedureCanonicalEndpointId",
1365                            state.endpointByRef.get(draft.procedureEndpointRef).string("canonicalEndpointId"));
1366        }
1367        if (!draft.literalInputs.isEmpty()) result.put("literalInputs", draft.literalInputs);
1368        if (!draft.supportPathRefs.isEmpty()) result.put("supportPathRefs", draft.supportPathRefs);
1369        return result.build();
1370    }
1371
1372    private void materializeObjectRelationships(State state) {
1373        List<RelationshipGroup> canonical = new ArrayList<RelationshipGroup>();
1374        List<RelationshipGroup> candidates = new ArrayList<RelationshipGroup>();
1375        for (RelationshipGroup group : state.relationshipGroups.values()) {
1376            if (group.canonical) canonical.add(group); else candidates.add(group);
1377        }
1378        Collections.sort(canonical, relationshipGroupComparator());
1379        Collections.sort(candidates, relationshipGroupComparator());
1380        for (RelationshipGroup group : canonical) {
1381            group.ownerKind = "OBJECT_RELATIONSHIP";
1382            group.ownerId = group.semanticKey;
1383        }
1384        for (int index = 0; index < candidates.size(); index++) {
1385            RelationshipGroup group = candidates.get(index);
1386            group.ownerKind = "OBJECT_RELATIONSHIP_CANDIDATE";
1387            group.ownerId = local("objrel-cand-", index + 1);
1388        }
1389        Map<String, String> semanticKeys = new HashMap<String, String>();
1390        for (RelationshipGroup group : canonical) semanticKeys.put(group.ownerKind + "\u0000" + group.ownerId, group.semanticKey);
1391        for (RelationshipGroup group : candidates) semanticKeys.put(group.ownerKind + "\u0000" + group.ownerId, group.semanticKey);
1392        Map<String, SlpcObject> newOccurrences = new TreeMap<String, SlpcObject>();
1393        for (RelationshipGroup group : canonical) addRelationshipOccurrence(group, semanticKeys, state, newOccurrences);
1394        for (RelationshipGroup group : candidates) addRelationshipOccurrence(group, semanticKeys, state, newOccurrences);
1395        state.occurrences.addAll(newOccurrences.values());
1396        Collections.sort(state.occurrences, new Comparator<Object>() { @Override public int compare(Object left, Object right) {
1397            return ((SlpcObject) left).string("occurrenceId").compareTo(((SlpcObject) right).string("occurrenceId"));
1398        }});
1399        for (RelationshipGroup group : canonical) state.objectRelationships.add(materializeRelationship(group, false));
1400        for (RelationshipGroup group : candidates) state.objectRelationshipCandidates.add(materializeRelationship(group, true));
1401    }
1402
1403    private void addRelationshipOccurrence(RelationshipGroup group, Map<String, String> semanticKeys,
1404            State state, Map<String, SlpcObject> target) {
1405        SlpcObject owner = SlpcObject.builder().put("kind", group.ownerKind).put("id", group.ownerId).build();
1406        SlpcObject origin = SlpcObject.builder().put("engineKind", "LEGACY_DLINEAGE")
1407                .put("adapterName", ADAPTER_NAME).build();
1408        String resolution = group.canonical ? "RESOLVED" : "UNRESOLVED";
1409        SlpcObject withoutId = SlpcObject.builder().put("occurrenceId", "slocc1-" + zeros(64))
1410                .put("ownerRef", owner).put("artifactId", state.artifact.string("artifactId"))
1411                .put("statementId", state.statementId).put("evidenceKind", "STATIC_SQL")
1412                .put("origin", origin).putNull("procedureEndpointRef").putNull("procedureCanonicalEndpointId")
1413                .putNull("transformation").putNull("predicate").put("quality", occurrenceQuality(resolution))
1414                .put("sourceLocations", new ArrayList<Object>(group.sourceLocations.values()))
1415                .put("extensions", Collections.emptyList()).build();
1416        String id = SlpcIdentity.occurrenceId(withoutId, semanticKeys);
1417        target.put(id, withoutId.toBuilder().put("occurrenceId", id).build());
1418    }
1419
1420    private SlpcObject materializeRelationship(RelationshipGroup group, boolean candidate) {
1421        SlpcObject.Builder result = SlpcObject.builder();
1422        if (candidate) {
1423            result.put("candidateId", group.ownerId).put("candidateFingerprint", group.semanticKey);
1424        } else {
1425            result.put("relationshipId", group.semanticKey);
1426        }
1427        result.put("relationshipKind", group.semantic.string("relationshipKind"))
1428                .put("participants", group.semantic.array("participants"));
1429        if (group.semantic.array("keyFields") != null) result.put("keyFields", group.semantic.array("keyFields"));
1430        if (group.semantic.array("keyPairs") != null) result.put("keyPairs", group.semantic.array("keyPairs"));
1431        if (candidate) result.put("candidateReasons", Collections.singletonList("IDENTITY_UNAVAILABLE"));
1432        return result.put("qualitySummary", qualitySummary(candidate ? "INELIGIBLE" : "ELIGIBLE",
1433                        candidate ? "UNRESOLVED" : "RESOLVED", 1))
1434                .put("extensions", Collections.emptyList()).build();
1435    }
1436
1437    private void buildIntermediateGraph(dataflow legacy, State state) {
1438        List<table> intermediate = new ArrayList<table>();
1439        for (table relation : dataflow.getAllTables(legacy)) if (isIntermediate(relation)) intermediate.add(relation);
1440        if (intermediate.isEmpty()) {
1441            for (relationship relation : legacy.getRelationships()) if ("fddi".equals(relation.getType())) {
1442                accountDedicatedLoss(relation, state, "SLPC_INTERMEDIATE_HOP_UNCOLLAPSIBLE",
1443                        "Legacy fddi has no retained intermediate topology against which to verify a boundary path.");
1444            }
1445            return;
1446        }
1447        Collections.sort(intermediate, tableComparator());
1448
1449        Map<String, table> tableById = new HashMap<String, table>();
1450        for (table relation : intermediate) if (relation.getId() != null) tableById.put(relation.getId(), relation);
1451        IdentityHashMap<table, table> parentByTable = new IdentityHashMap<table, table>();
1452        for (table relation : intermediate) {
1453            if (relation.getParent() == null) continue;
1454            table parent = tableById.get(relation.getParent());
1455            if (parent == null || parent == relation) {
1456                containmentLoss(relation, state,
1457                        "Legacy parent does not identify a distinct intermediate object; the node is retained as a root.");
1458            } else {
1459                parentByTable.put(relation, parent);
1460            }
1461        }
1462        breakContainmentCycles(intermediate, parentByTable, state);
1463
1464        IdentityHashMap<table, List<table>> childrenByTable = new IdentityHashMap<table, List<table>>();
1465        for (table relation : intermediate) childrenByTable.put(relation, new ArrayList<table>());
1466        List<table> roots = new ArrayList<table>();
1467        for (table relation : intermediate) {
1468            table parent = parentByTable.get(relation);
1469            if (parent == null) roots.add(relation); else childrenByTable.get(parent).add(relation);
1470        }
1471        Collections.sort(roots, sourceTableComparator());
1472        for (List<table> children : childrenByTable.values()) Collections.sort(children, sourceTableComparator());
1473
1474        List<NodeDraft> nodeDrafts = new ArrayList<NodeDraft>();
1475        Map<table, NodeDraft> tableNodes = new IdentityHashMap<table, NodeDraft>();
1476        Map<column, NodeDraft> fieldNodes = new IdentityHashMap<column, NodeDraft>();
1477        int ordinal = 1;
1478        ArrayDeque<PendingNode> stack = new ArrayDeque<PendingNode>();
1479        for (int index = roots.size() - 1; index >= 0; index--) stack.push(new PendingNode(roots.get(index), null, null));
1480        while (!stack.isEmpty()) {
1481            PendingNode pending = stack.pop();
1482            if (pending.field != null) {
1483                NodeDraft fieldNode = new NodeDraft(pending.relation, pending.field, ordinal++,
1484                        nodeKindForField(pending.relation), null, pending.parent);
1485                nodeDrafts.add(fieldNode); fieldNodes.put(pending.field, fieldNode);
1486                putNodeLegacyId(state.intermediateNodeByLegacyId, pending.field.getId(), fieldNode);
1487                continue;
1488            }
1489            NodeDraft relationNode = new NodeDraft(pending.relation, null, ordinal++, nodeKind(pending.relation),
1490                    provenLocalName(pending.relation), pending.parent);
1491            nodeDrafts.add(relationNode); tableNodes.put(pending.relation, relationNode);
1492            putNodeLegacyId(state.intermediateNodeByLegacyId, pending.relation.getId(), relationNode);
1493
1494            List<PendingNode> children = new ArrayList<PendingNode>();
1495            for (column field : pending.relation.getColumns()) {
1496                if (isRelationRows(field)) {
1497                    putNodeLegacyId(state.intermediateNodeByLegacyId, field.getId(), relationNode);
1498                } else {
1499                    children.add(new PendingNode(pending.relation, field, relationNode));
1500                }
1501            }
1502            for (table child : childrenByTable.get(pending.relation)) children.add(new PendingNode(child, null, relationNode));
1503            Collections.sort(children, pendingNodeComparator());
1504            for (int index = children.size() - 1; index >= 0; index--) stack.push(children.get(index));
1505        }
1506        state.intermediateTableNodes.putAll(tableNodes); state.intermediateColumnNodes.putAll(fieldNodes);
1507
1508        SlpcObject provisionalGraph = graphShell(state, "sliggfp1-" + zeros(64), Collections.<Object>emptyList(),
1509                Collections.<Object>emptyList());
1510        List<Object> provisionalNodes = new ArrayList<Object>();
1511        for (NodeDraft draft : nodeDrafts) {
1512            draft.provisionalRef = "tmp-node-" + draft.ordinal;
1513            SlpcObject node = draft.node(draft.provisionalRef, draft.parent == null ? null : draft.parent.provisionalRef, "slignfp2-" + zeros(64));
1514            provisionalNodes.add(node); draft.provisionalNode = node;
1515        }
1516        provisionalGraph = graphShell(state, "sliggfp1-" + zeros(64), provisionalNodes, Collections.<Object>emptyList());
1517        for (NodeDraft draft : nodeDrafts) {
1518            draft.fingerprint = SlpcIdentity.intermediateNodeFingerprint(draft.provisionalNode, provisionalGraph, state.endpointByRef);
1519        }
1520        Collections.sort(nodeDrafts, nodeFingerprintComparator());
1521        for (int index = 0; index < nodeDrafts.size(); index++) {
1522            NodeDraft draft = nodeDrafts.get(index); draft.finalRef = local("ign-", index + 1);
1523            if (draft.field == null) state.intermediateTableRefs.put(draft.relation, draft.finalRef);
1524            else state.intermediateColumnRefs.put(draft.field, draft.finalRef);
1525        }
1526        List<Object> nodes = new ArrayList<Object>();
1527        for (NodeDraft draft : nodeDrafts) nodes.add(draft.node(draft.finalRef, draft.parent == null ? null : draft.parent.finalRef, draft.fingerprint));
1528
1529        List<EdgeDraft> edges = new ArrayList<EdgeDraft>();
1530        for (relationship relation : legacy.getRelationships()) {
1531            if (!"fdd".equals(relation.getType()) && !"fdr".equals(relation.getType())
1532                    && !"frd".equals(relation.getType())) continue;
1533            if (state.dedicatedRelationshipAccounting.contains(legacyId(relation))) continue;
1534            if (unsupportedFactEffect(relation.getEffectType())) continue;
1535            for (sourceColumn source : relation.getSources()) {
1536                if (hasCandidateParents(source)) continue;
1537                FactSemantics semantics = semantics(relation, source);
1538                if (!semantics.publish) continue;
1539                SlpcObject target = targetAnchor(relation.getTarget(), semantics, state);
1540                if (target == null) continue;
1541                SlpcObject sourceAnchor = sourceAnchor(source, semantics, state);
1542                if (sourceAnchor == null) continue;
1543                // Pure physical-to-physical edges belong to facts, not to the intermediate graph.
1544                if ("ENDPOINT".equals(sourceAnchor.string("kind")) && "ENDPOINT".equals(target.string("kind"))) continue;
1545                edges.add(new EdgeDraft(relation, source, sourceAnchor, target, semantics,
1546                        transformationEvidence(relation, source), evidence(predicateText(relation, semantics))));
1547            }
1548        }
1549        Map<String, SlpcObject> nodeIndex = SlpcIdentity.index(nodes, "nodeRef");
1550        for (EdgeDraft edge : edges) {
1551            SlpcObject provisional = edge.edge("ige-000001", "sligefp1-" + zeros(64));
1552            edge.fingerprint = SlpcIdentity.intermediateEdgeFingerprint(provisional, state.endpointByRef, nodeIndex);
1553        }
1554        Map<String, EdgeDraft> uniqueEdges = new TreeMap<String, EdgeDraft>();
1555        for (EdgeDraft edge : edges) {
1556            EdgeDraft previous = uniqueEdges.put(edge.fingerprint, edge);
1557            if (previous != null && !previous.sameSemantic(edge)) {
1558                throw new IllegalStateException("SLPC intermediate edge fingerprint collision: " + edge.fingerprint);
1559            }
1560        }
1561        edges = new ArrayList<EdgeDraft>(uniqueEdges.values());
1562        Collections.sort(edges, edgeFingerprintComparator());
1563        List<Object> edgeRecords = new ArrayList<Object>();
1564        for (int index = 0; index < edges.size(); index++) {
1565            EdgeDraft edge = edges.get(index); edge.finalRef = local("ige-", index + 1);
1566            edgeRecords.add(edge.edge(edge.finalRef, edge.fingerprint));
1567        }
1568        Map<String, SlpcObject> edgeIndex = SlpcIdentity.index(edgeRecords, "edgeRef");
1569        List<PathDraft> paths = enumeratePaths(edges, state);
1570        for (PathDraft path : paths) {
1571            SlpcObject provisional = path.path("igp-000001", "sligpfp1-" + zeros(64));
1572            path.fingerprint = SlpcIdentity.intermediatePathFingerprint(provisional, edgeIndex);
1573        }
1574        Collections.sort(paths, pathFingerprintComparator());
1575        List<Object> pathRecords = new ArrayList<Object>();
1576        for (int index = 0; index < paths.size(); index++) {
1577            PathDraft path = paths.get(index); path.finalRef = local("igp-", index + 1);
1578            pathRecords.add(path.path(path.finalRef, path.fingerprint));
1579        }
1580        SlpcObject graph = graphShell(state, "sliggfp1-" + zeros(64), nodes, edgeRecords)
1581                .toBuilder().put("paths", pathRecords).build();
1582        graph = graph.toBuilder().put("graphFingerprint", SlpcIdentity.intermediateGraphFingerprint(graph)).build();
1583        List<Object> graphExtensions = interactiveExplainExtensions(legacy, edges, graph, state);
1584        if (!graphExtensions.isEmpty()) graph = graph.toBuilder().put("extensions", graphExtensions).build();
1585        state.graphs.add(graph);
1586        collapsePhysicalPaths(paths, state);
1587        accountFddiAgainstPaths(legacy, paths, state);
1588        state.diagnostics.add(diagnostic("SLPC_INTERMEDIATE_GRAPH_INCOMPLETE", "WARN", "INTERMEDIATE_GRAPH", "graph-000001",
1589                "Legacy result-set containment, proven edges, and bounded acyclic boundary paths are preserved; the legacy POJO still does not prove globally exhaustive analysis."));
1590        if (graphExtensions.isEmpty()) {
1591            state.diagnostics.add(diagnostic("SLPC_ADAPTER_CAPABILITY_GAP", "INFO", "INTERMEDIATE_GRAPH", "graph-000001",
1592                    "No authoritative typed-operation/ordered-operand evidence was supplied, so no interactive explain profile is emitted."));
1593            state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
1594                    "graph-000001", "GSP_LEGACY_INTERACTIVE_EXPLAIN_UNAVAILABLE",
1595                    "Clickable topology is retained, but typed steps require producer evidence supplied through LegacyAdapterContext."));
1596        }
1597    }
1598
1599    private List<PathDraft> enumeratePaths(List<EdgeDraft> edges, State state) {
1600        Map<String, List<EdgeDraft>> outgoing = new HashMap<String, List<EdgeDraft>>();
1601        List<EdgeDraft> starts = new ArrayList<EdgeDraft>();
1602        for (EdgeDraft edge : edges) {
1603            String key = anchorKey(edge.source);
1604            if (!outgoing.containsKey(key)) outgoing.put(key, new ArrayList<EdgeDraft>());
1605            outgoing.get(key).add(edge);
1606            if ("ENDPOINT".equals(edge.source.string("kind"))) starts.add(edge);
1607        }
1608        for (List<EdgeDraft> values : outgoing.values()) Collections.sort(values, edgeFingerprintComparator());
1609        Collections.sort(starts, edgeFingerprintComparator());
1610        Map<String, PathDraft> unique = new TreeMap<String, PathDraft>();
1611        ArrayDeque<PathWalk> stack = new ArrayDeque<PathWalk>();
1612        for (int index = starts.size() - 1; index >= 0; index--) stack.push(PathWalk.start(starts.get(index)));
1613        int expansions = 0;
1614        while (!stack.isEmpty()) {
1615            if (++expansions > 100000) { state.pathEnumerationTruncated = true; break; }
1616            PathWalk walk = stack.pop(); EdgeDraft last = walk.edges.get(walk.edges.size() - 1);
1617            if ("ENDPOINT".equals(last.target.string("kind"))) {
1618                String key = walk.edgeKey(); if (!unique.containsKey(key)) unique.put(key, new PathDraft(walk.edges));
1619                if (unique.size() >= 10000) { state.pathEnumerationTruncated = true; break; }
1620                continue;
1621            }
1622            List<EdgeDraft> next = outgoing.get(anchorKey(last.target));
1623            if (next == null) continue;
1624            for (int index = next.size() - 1; index >= 0; index--) {
1625                EdgeDraft edge = next.get(index);
1626                if (walk.used.contains(edge.finalRef)) { state.pathCycleObserved = true; continue; }
1627                stack.push(walk.append(edge));
1628            }
1629        }
1630        if (state.pathCycleObserved || state.pathEnumerationTruncated) {
1631            state.diagnostics.add(diagnostic("SLPC_INTERMEDIATE_HOP_UNCOLLAPSIBLE", "WARN", "DOCUMENT", null,
1632                    state.pathEnumerationTruncated
1633                            ? "Intermediate path enumeration reached its deterministic path/expansion safety cap."
1634                            : "A cycle was detected during path enumeration; cyclic continuations were not collapsed."));
1635        }
1636        return new ArrayList<PathDraft>(unique.values());
1637    }
1638
1639    private String anchorKey(SlpcObject anchor) {
1640        String ref = "ENDPOINT".equals(anchor.string("kind")) ? anchor.string("endpointRef") : anchor.string("nodeRef");
1641        return anchor.string("kind") + "\u0000" + ref + "\u0000" + anchor.string("portCode");
1642    }
1643
1644    private void collapsePhysicalPaths(List<PathDraft> paths, State state) {
1645        int collapsed = 0;
1646        for (PathDraft path : paths) {
1647            FactSemantics semantics = collapsedSemantics(path);
1648            if (semantics == null) continue;
1649            String sourceRef = path.sourceEndpointRef(), targetRef = path.targetEndpointRef();
1650            if (sourceRef == null || targetRef == null || sourceRef.equals(targetRef)) continue;
1651            SlpcObject semantic = semanticRecord(sourceRef, targetRef, semantics, state);
1652            FactGroup group = factGroup(semantic, state);
1653            List<Object> support = Collections.<Object>singletonList(SlpcObject.builder()
1654                    .put("graphRef", "graph-000001").put("pathRef", path.finalRef).build());
1655            FactOccurrenceDraft occurrence = new FactOccurrenceDraft(group, null, null,
1656                    group.canonical ? "RESOLVED" : "UNRESOLVED", Collections.<Object>emptyList(), support,
1657                    pathSourceLocations(path, state), null);
1658            group.occurrences.add(occurrence); state.factOccurrenceDrafts.add(occurrence); collapsed++;
1659        }
1660        if (collapsed > 0) {
1661            state.hasPhysicalHopCollapse = true;
1662            state.diagnostics.add(diagnostic("SLPC_INTERMEDIATE_HOP_COLLAPSED", "INFO", "DOCUMENT", null,
1663                    "Published " + collapsed + " physical-boundary lineage occurrence(s) with auditable supportPathRefs."));
1664        }
1665    }
1666
1667    private List<Object> pathSourceLocations(PathDraft path, State state) {
1668        Map<String, SlpcObject> result = new TreeMap<String, SlpcObject>();
1669        for (EdgeDraft edge : path.edges) addSourceLocations(result, sourceLocations(edge.relation, state));
1670        return new ArrayList<Object>(result.values());
1671    }
1672
1673    private FactGroup factGroup(SlpcObject semantic, State state) {
1674        boolean canonical = isCanonicalFact(semantic, state.endpointByRef);
1675        String key = canonical ? SlpcIdentity.factId(semantic, state.endpointByRef)
1676                : SlpcIdentity.factCandidateFingerprint(semantic, state.endpointByRef);
1677        FactGroup group = state.factGroups.get(key);
1678        if (group == null) { group = new FactGroup(semantic, canonical, key); state.factGroups.put(key, group); }
1679        else if (!group.sameSemantic(semantic)) throw new IllegalStateException("SLPC fact fingerprint collision: " + key);
1680        return group;
1681    }
1682
1683    private FactSemantics collapsedSemantics(PathDraft path) {
1684        boolean changed = false, aggregate = false;
1685        for (EdgeDraft edge : path.edges) {
1686            if (!"VALUE".equals(edge.semantics.axis) || !"NONE".equals(edge.semantics.operationKind)) return null;
1687            if (!equalsAny(edge.semantics.role, "DIRECT", "TRANSFORM", "AGGREGATE")) return null;
1688            changed |= !"DIRECT".equals(edge.semantics.role); aggregate |= "AGGREGATE".equals(edge.semantics.role);
1689        }
1690        String role = !changed ? "DIRECT" : aggregate && "AGGREGATE".equals(path.edges.get(path.edges.size() - 1).semantics.role)
1691                ? "AGGREGATE" : "TRANSFORM";
1692        return FactSemantics.publish("VALUE", role, "NONE", "NONE", false);
1693    }
1694
1695    private void accountFddiAgainstPaths(dataflow legacy, List<PathDraft> paths, State state) {
1696        for (relationship relation : legacy.getRelationships()) {
1697            if (!"fddi".equals(relation.getType())) continue;
1698            boolean matched = false;
1699            for (sourceColumn source : relation.getSources()) {
1700                for (String axis : new String[] {"VALUE", "ROW"}) {
1701                    FactSemantics semantics = FactSemantics.publish(axis,
1702                            "VALUE".equals(axis) ? "TRANSFORM" : "UNKNOWN", "NONE", "NONE", false);
1703                    String sourceRef = resolveSource(source, semantics, state);
1704                    String targetRef = resolveTarget(relation.getTarget(), semantics, state);
1705                    if (sourceRef == null || targetRef == null) continue;
1706                    for (PathDraft path : paths) {
1707                        if (sourceRef.equals(path.sourceEndpointRef()) && targetRef.equals(path.targetEndpointRef())
1708                                && axis.equals(path.axis())) { matched = true; break; }
1709                    }
1710                    if (matched) break;
1711                }
1712                if (matched) break;
1713            }
1714            if (matched) markDedicatedMapped(relation, state, "supportPathRefs");
1715            else accountDedicatedLoss(relation, state, "SLPC_INTERMEDIATE_HOP_UNCOLLAPSIBLE",
1716                    "Legacy fddi was not published by direction-by-name; no matching proven boundary path was found.");
1717        }
1718    }
1719
1720    private List<Object> interactiveExplainExtensions(dataflow legacy, List<EdgeDraft> edges,
1721            SlpcObject graph, State state) {
1722        List<StepDraft> drafts = new ArrayList<StepDraft>();
1723        Set<String> globallyUsedEdges = new HashSet<String>();
1724        for (relationship relation : legacy.getRelationships()) {
1725            LegacyAdapterContext.TypedTransform typed = state.context.typedTransformResolver().resolve(relation);
1726            if (typed == null) continue;
1727            if (!equalsAny(typed.operationFamily(), "ARITHMETIC", "COMPARISON", "BOOLEAN", "FUNCTION", "CAST",
1728                    "CASE", "CONCAT", "AGGREGATE", "WINDOW", "CONSTRUCTOR", "CONTROL_FLOW", "OTHER")
1729                    || !typed.operationCode().matches("^[A-Z][A-Z0-9_]*$")) {
1730                accountSemanticGap(relation, "SLPC_EXTENSION_UNSUPPORTED",
1731                        "Typed transform resolver returned an operation family/code outside the v0.2 registry.", state);
1732                continue;
1733            }
1734            List<EdgeDraft> relationEdges = new ArrayList<EdgeDraft>();
1735            for (EdgeDraft edge : edges) if (edge.relation == relation) relationEdges.add(edge);
1736            List<Object> bindings = new ArrayList<Object>(); SlpcObject resultAnchor = null; boolean valid = true;
1737            Set<String> localEdges = new HashSet<String>(); int ordinal = 1;
1738            for (LegacyAdapterContext.TypedOperand operand : typed.operands()) {
1739                if (!operand.roleCode().matches("^[A-Z][A-Z0-9_]*$")) { valid = false; break; }
1740                EdgeDraft match = null;
1741                for (EdgeDraft edge : relationEdges) {
1742                    if (sourceMatches(edge.sourceColumn, operand.sourceId())) {
1743                        if (match != null) { valid = false; break; }
1744                        match = edge;
1745                    }
1746                }
1747                if (!valid || match == null || !localEdges.add(match.finalRef)
1748                        || globallyUsedEdges.contains(match.finalRef)
1749                        || (resultAnchor != null && !resultAnchor.equals(match.target))) { valid = false; break; }
1750                resultAnchor = match.target;
1751                bindings.add(SlpcObject.builder().put("ordinal", ordinal++).put("roleCode", operand.roleCode())
1752                        .put("edgeRef", match.finalRef).build());
1753            }
1754            if (!valid || bindings.isEmpty()) {
1755                accountSemanticGap(relation, "SLPC_EXTENSION_UNSUPPORTED",
1756                        "Typed operands did not bind one-to-one to this relationship's graph edges and common result anchor.", state);
1757                continue;
1758            }
1759            globallyUsedEdges.addAll(localEdges);
1760            drafts.add(new StepDraft(typed.operationFamily(), typed.operationCode(), bindings, resultAnchor,
1761                    relationEdges.isEmpty() ? null : relationEdges.get(0).transformation));
1762        }
1763        if (drafts.isEmpty()) return Collections.emptyList();
1764        Collections.sort(drafts, new Comparator<StepDraft>() { @Override public int compare(StepDraft left, StepDraft right) {
1765            return left.sortKey().compareTo(right.sortKey());
1766        }});
1767        Map<String, SlpcObject> nodeIndex = SlpcIdentity.index(graph.array("nodes"), "nodeRef");
1768        Map<String, SlpcObject> edgeIndex = SlpcIdentity.index(graph.array("edges"), "edgeRef");
1769        List<Object> steps = new ArrayList<Object>();
1770        for (int index = 0; index < drafts.size(); index++) {
1771            StepDraft draft = drafts.get(index); int ordinal = index + 1;
1772            SlpcObject provisional = draft.step(local("igs-", ordinal), "sligsfp1-" + zeros(64), ordinal);
1773            String fingerprint = SlpcIdentity.interactiveStepFingerprint(provisional, graph,
1774                    state.endpointByRef, nodeIndex, edgeIndex);
1775            steps.add(draft.step(local("igs-", ordinal), fingerprint, ordinal));
1776        }
1777        state.hasInteractiveExplain = true;
1778        return Collections.<Object>singletonList(SlpcObject.builder()
1779                .put("extensionType", "gsp.interactive-explain.v1").put("schemaVersion", "1.0.0")
1780                .put("criticality", "OPTIONAL").put("payload", SlpcObject.builder().put("steps", steps).build()).build());
1781    }
1782
1783    private boolean sourceMatches(sourceColumn source, String sourceId) {
1784        return sourceId.equals(source.getId()) || sourceId.equals(source.getSource_id());
1785    }
1786
1787    private void buildJoinSemantics(dataflow legacy, State state) {
1788        List<SlpcObject> records = new ArrayList<SlpcObject>(); int ordinal = 1;
1789        for (relationship relation : legacy.getRelationships()) {
1790            if (!"join".equals(relation.getType())) continue;
1791            if (relation.getTarget() == null || relation.getSources().size() != 1) {
1792                accountLoss(relation, state, "SLPC_JOIN_SEMANTICS_PARTIAL",
1793                        "Dedicated legacy join record does not contain exactly one typed source/target pair.");
1794                continue;
1795            }
1796            sourceColumn left = relation.getSources().get(0);
1797            String leftField = resolveSourceField(left, state), rightField = resolveTargetField(relation.getTarget(), state);
1798            String leftRelation = relationEndpoint(left.getParent_id(), state);
1799            String rightRelation = relationEndpoint(relation.getTarget().getParent_id(), state);
1800            if (leftField == null || rightField == null || leftRelation == null || rightRelation == null) {
1801                accountLoss(relation, state, "SLPC_ENDPOINT_UNRESOLVED",
1802                        "Dedicated join participants could not all be bound without endpoint-level coarsening.");
1803                continue;
1804            }
1805            String joinType = joinType(relation.getJoinType());
1806            if ("UNKNOWN".equals(joinType)) {
1807                state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
1808                        legacyId(relation), "SLPC_JOIN_SEMANTICS_PARTIAL",
1809                        "The dedicated join record proves its participants but its join-type wire code is outside the SLPC registry."));
1810            }
1811            List<Object> keyPairs = "CROSS".equals(joinType) ? Collections.<Object>emptyList()
1812                    : Collections.<Object>singletonList(SlpcObject.builder().put("ordinal", 1)
1813                            .put("leftFieldEndpointRef", leftField).put("rightFieldEndpointRef", rightField).put("operator", "OTHER").build());
1814            accountSemanticGap(relation, "SLPC_JOIN_SEMANTICS_PARTIAL",
1815                    "Dedicated join records do not carry a stable occurrence link to fdr records or a typed comparison operator; refinesFact arrays remain empty.", state);
1816            String conditionForm = conditionForm(relation.getClause(), relation.getJoinType(), joinType);
1817            SlpcObject provisional = SlpcObject.builder().put("joinSemanticsId", "sljoin1-" + zeros(64))
1818                    .put("statementId", state.statementId).put("joinOrdinal", ordinal++).put("joinType", joinType)
1819                    .put("conditionForm", conditionForm).put("leftRelationEndpointRef", leftRelation)
1820                    .put("rightRelationEndpointRef", rightRelation).put("leftSideSemantics", leftSide(joinType))
1821                    .put("rightSideSemantics", rightSide(joinType)).put("keyPairs", keyPairs)
1822                    .put("predicate", "CROSS".equals(joinType) ? null : evidence(nonEmpty(relation.getCondition())))
1823                    .put("refinesFactIds", Collections.emptyList())
1824                    .put("refinesFactCandidateFingerprints", Collections.emptyList())
1825                    .put("quality", quality(participantsCanonicalRefs(Arrays2(leftRelation, rightRelation, leftField, rightField), state) ? "PARTIAL" : "UNRESOLVED",
1826                            "HEURISTIC", "INFERRED", 900000))
1827                    .put("sourceLocations", sourceLocations(relation, state))
1828                    .put("extensions", Collections.emptyList()).build();
1829            SlpcObject record = provisional.toBuilder().put("joinSemanticsId", SlpcIdentity.joinSemanticsId(provisional, state.endpointByRef)).build();
1830            records.add(record);
1831            state.coverage.add(new LegacyCoverageReport.Entry("RELATIONSHIP", legacyId(relation),
1832                    LegacyCoverageReport.Outcome.CONDITIONAL,
1833                    record.string("joinSemanticsId"), "dedicated join record; operator/refinement correlation remains partial"));
1834        }
1835        Collections.sort(records, new Comparator<SlpcObject>() { @Override public int compare(SlpcObject left, SlpcObject right) {
1836            return left.string("joinSemanticsId").compareTo(right.string("joinSemanticsId"));
1837        }});
1838        state.joinSemantics.addAll(records);
1839    }
1840
1841    private String joinType(String value) {
1842        if (value == null) return "UNKNOWN";
1843        String normalized = value.toLowerCase(java.util.Locale.ROOT);
1844        if (equalsAny(normalized, "left", "leftouter", "natural_left", "natural_leftouter")) return "LEFT";
1845        if (equalsAny(normalized, "right", "rightouter", "natural_right", "natural_rightouter")) return "RIGHT";
1846        if (equalsAny(normalized, "full", "fullouter", "natural_full", "natural_fullouter")) return "FULL";
1847        if ("cross".equals(normalized)) return "CROSS";
1848        if (equalsAny(normalized, "semi", "leftsemi", "rightsemi")) return "SEMI";
1849        if (equalsAny(normalized, "anti", "leftanti", "rightanti")) return "ANTI";
1850        if ("asof".equals(normalized)) return "ASOF";
1851        if (equalsAny(normalized, "inner", "join", "natural", "natural_inner", "straight")) return "INNER";
1852        return "UNKNOWN";
1853    }
1854
1855    private String conditionForm(String clause, String legacyJoinType, String joinType) {
1856        if ("CROSS".equals(joinType)) return "NONE";
1857        String rawType = safe(legacyJoinType).toLowerCase(java.util.Locale.ROOT);
1858        if (rawType.equals("natural") || rawType.startsWith("natural_")) return "NATURAL";
1859        if ("on".equals(clause)) return "ON";
1860        return nonEmpty(clause) == null ? "NONE" : "OTHER";
1861    }
1862
1863    private String leftSide(String joinType) {
1864        if ("LEFT".equals(joinType) || "SEMI".equals(joinType) || "ANTI".equals(joinType)) return "PRESERVED";
1865        if ("RIGHT".equals(joinType) || "FULL".equals(joinType)) return "NULL_EXTENDED";
1866        if ("INNER".equals(joinType)) return "FILTERED";
1867        if ("CROSS".equals(joinType)) return "NOT_APPLICABLE";
1868        return "UNKNOWN";
1869    }
1870
1871    private String rightSide(String joinType) {
1872        if ("RIGHT".equals(joinType)) return "PRESERVED"; if ("LEFT".equals(joinType) || "FULL".equals(joinType)) return "NULL_EXTENDED";
1873        if ("INNER".equals(joinType) || "SEMI".equals(joinType) || "ANTI".equals(joinType)) return "FILTERED";
1874        if ("CROSS".equals(joinType)) return "NOT_APPLICABLE"; return "UNKNOWN";
1875    }
1876
1877    private boolean participantsCanonicalRefs(List<String> refs, State state) {
1878        for (String ref : refs) if (state.endpointByRef.get(ref).string("canonicalEndpointId") == null) return false;
1879        return true;
1880    }
1881
1882    private List<String> Arrays2(String... values) {
1883        List<String> result = new ArrayList<String>(); Collections.addAll(result, values); return result;
1884    }
1885
1886    private SlpcObject graphShell(State state, String fingerprint, List<Object> nodes, List<Object> edges) {
1887        return SlpcObject.builder().put("graphRef", "graph-000001").put("graphFingerprint", fingerprint)
1888                .put("statementId", state.statementId).put("graphOrdinal", 1).put("graphKind", "QUERY_BLOCK")
1889                .put("analysisCompleteness", "PARTIAL").put("nodes", nodes).put("edges", edges)
1890                .put("paths", Collections.emptyList()).put("extensions", Collections.emptyList()).build();
1891    }
1892
1893    private SlpcObject semanticRecord(String sourceRef, String targetRef, FactSemantics semantics, State state) {
1894        return SlpcObject.builder().put("sourceEndpointRef", sourceRef).put("targetEndpointRef", targetRef)
1895                .put("axis", semantics.axis).put("role", semantics.role).put("operationKind", semantics.operationKind)
1896                .put("operationInputKind", semantics.operationInputKind).build();
1897    }
1898
1899    private FactSemantics semantics(relationship relation, sourceColumn source) {
1900        if ("fdd".equals(relation.getType())) {
1901            if ("constant".equals(source.getColumn_type())) return FactSemantics.publish("VALUE", "DERIVED", "NONE", "NONE", true);
1902            boolean transformed = (source.getTransforms() != null && !source.getTransforms().isEmpty()) || nonEmpty(relation.getFunction()) != null;
1903            return FactSemantics.publish("VALUE", transformed ? "TRANSFORM" : "DIRECT", "NONE", "NONE", false);
1904        }
1905        if ("frd".equals(relation.getType())) {
1906            targetColumn target = relation.getTarget();
1907            if (target != null && ("system".equals(target.getSource()) || "RelationRows".equals(target.getName()))) {
1908                return FactSemantics.publishWithGap("ROW", "UNKNOWN", "NONE", "NONE", false,
1909                        "SLPC_ROLE_UNKNOWN", "Legacy frd RelationRows target proves row influence but not a narrower row role.");
1910            }
1911            if (nonEmpty(relation.getFunction()) != null || "function".equals(relation.getEffectType())) {
1912                return FactSemantics.publish("VALUE", "AGGREGATE", "NONE", "NONE", false);
1913            }
1914            return FactSemantics.reject("SLPC_RELATIONSHIP_UNMAPPED",
1915                    "Legacy frd shape is neither an aggregate value result nor a RelationRows target; direction is quarantined.");
1916        }
1917        String clause = source.getClauseType();
1918        if (equalsAny(clause, "joinCondition", "join")) return FactSemantics.publish("ROW", "JOIN", "NONE", "NONE", false);
1919        if ("groupby".equals(clause)) return FactSemantics.publish("ROW", "UNKNOWN", "GROUP_BY", "GROUP_KEY", false);
1920        if (equalsAny(clause, "where", "having", "qualify", "top", "limit", "sample", "filter"))
1921            return FactSemantics.publish("ROW", "FILTER", "NONE", "NONE", false);
1922        if (equalsAny(clause, "orderby", "sortby")) {
1923            return FactSemantics.reject("SLPC_OPERATION_UNSUPPORTED",
1924                    "Legacy ORDER BY/SORT BY evidence requires DatasetOperationInfluence(SORT); it must not be published as a ROW fact.");
1925        }
1926        return FactSemantics.publishWithGap("ROW", "UNKNOWN", "NONE", "NONE", false,
1927                "SLPC_OPERATION_UNSUPPORTED", "Unrecognized or absent ESqlClause is retained only as ROW/UNKNOWN.");
1928    }
1929
1930    private SlpcObject transformationEvidence(relationship relation, sourceColumn source) {
1931        if (source.getTransforms() != null && !source.getTransforms().isEmpty()) {
1932            List<String> values = new ArrayList<String>();
1933            for (transform value : source.getTransforms()) if (value.getCode() != null) values.add(value.getCode());
1934            if (!values.isEmpty()) {
1935                List<byte[]> frames = new ArrayList<byte[]>();
1936                for (String value : values) frames.add(SlpcIdentity.string(value));
1937                return evidence("GSP-LEGACY-TRANSFORM-SEQUENCE-SHA256-V1",
1938                        SlpcIdentity.framedList(frames));
1939            }
1940        }
1941        return evidence(nonEmpty(relation.getFunction()));
1942    }
1943
1944    private String predicateText(relationship relation, FactSemantics semantics) {
1945        return "ROW".equals(semantics.axis) ? nonEmpty(relation.getCondition()) : null;
1946    }
1947
1948    private SlpcObject evidence(String text) {
1949        if (text == null) return null;
1950        // Persist only the hash: legacy text may include literals or credentials and has no redaction provenance.
1951        return evidence("RAW-UTF8-SHA256-V1", text.getBytes(StandardCharsets.UTF_8));
1952    }
1953
1954    private SlpcObject evidence(String algorithm, byte[] bytes) {
1955        SlpcObject fingerprint = SlpcObject.builder().put("algorithm", algorithm)
1956                .put("value", sha256(bytes)).build();
1957        return SlpcObject.builder().put("fingerprint", fingerprint).putNull("sourceLocation")
1958                .put("locationNullReason", "UNKNOWN").build();
1959    }
1960
1961    private SlpcObject artifact(LegacyAdapterContext context) {
1962        SlpcObject hash = SlpcObject.builder().put("algorithm", "SHA256").put("value", context.contentSha256()).build();
1963        SlpcObject withoutId = SlpcObject.builder().put("artifactId", "slart1-" + zeros(64))
1964                .put("sourceKind", context.sourceKind()).put("logicalLocator", context.logicalLocator())
1965                .put("sqlDialect", context.sqlDialect()).put("contentHash", hash).put("mediaType", "application/sql").build();
1966        return withoutId.toBuilder().put("artifactId", SlpcIdentity.artifactId(withoutId)).build();
1967    }
1968
1969    private SlpcObject statement(SlpcObject artifact, LegacyAdapterContext context, dataflow legacy) {
1970        SlpcObject.Builder builder = SlpcObject.builder().put("statementId", "slstmt1-" + zeros(64))
1971                .put("artifactId", artifact.string("artifactId")).put("statementIndex", 1)
1972                .put("kind", context.statementKind());
1973        SlpcObject location = statementSourceLocation(artifact.string("artifactId"), legacy);
1974        if (location != null) builder.put("sourceLocation", location);
1975        SlpcObject withoutId = builder.build();
1976        return withoutId.toBuilder().put("statementId", SlpcIdentity.statementId(withoutId)).build();
1977    }
1978
1979    private List<Object> statementSourceLocations(SlpcObject statement) {
1980        Object value = statement.get("sourceLocation");
1981        return value == null ? Collections.<Object>emptyList() : Collections.singletonList(value);
1982    }
1983
1984    private SlpcObject statementSourceLocation(String artifactId, dataflow legacy) {
1985        List<String> coordinates = new ArrayList<String>();
1986        for (table relation : dataflow.getAllTables(legacy)) {
1987            coordinates.add(relation.getCoordinate());
1988            for (column field : relation.getColumns()) coordinates.add(field.getCoordinate());
1989        }
1990        for (procedure routine : allProcedures(legacy)) {
1991            coordinates.add(routine.getCoordinate());
1992            for (argument parameter : routine.getArguments()) coordinates.add(parameter.getCoordinate());
1993        }
1994        for (gudusoft.gsqlparser.dlineage.dataflow.model.xml.process process : legacy.getProcesses()) {
1995            coordinates.add(process.getCoordinate());
1996        }
1997        for (relationship relation : legacy.getRelationships()) {
1998            if (relation.getTarget() != null) coordinates.add(relation.getTarget().getCoordinate());
1999            for (sourceColumn source : relation.getSources()) coordinates.add(source.getCoordinate());
2000            for (sourceColumn callee : relation.getCallees()) coordinates.add(callee.getCoordinate());
2001        }
2002        int startLine = Integer.MAX_VALUE, startColumn = Integer.MAX_VALUE, endLine = -1, endColumn = -1;
2003        for (String coordinate : coordinates) {
2004            int[] parsed = parsedCoordinate(coordinate);
2005            if (parsed == null) continue;
2006            if (parsed[0] < startLine || parsed[0] == startLine && parsed[1] < startColumn) {
2007                startLine = parsed[0]; startColumn = parsed[1];
2008            }
2009            if (parsed[2] > endLine || parsed[2] == endLine && parsed[3] > endColumn) {
2010                endLine = parsed[2]; endColumn = parsed[3];
2011            }
2012        }
2013        return startLine == Integer.MAX_VALUE ? null : sourceLocation(artifactId, startLine, startColumn, endLine, endColumn);
2014    }
2015
2016    private List<Object> sourceLocations(relationship relation, State state) {
2017        Map<String, SlpcObject> unique = new TreeMap<String, SlpcObject>();
2018        if (relation.getTarget() != null) addSourceLocation(unique, relation.getTarget().getCoordinate(), state);
2019        for (sourceColumn source : relation.getSources()) addSourceLocation(unique, source.getCoordinate(), state);
2020        for (sourceColumn callee : relation.getCallees()) addSourceLocation(unique, callee.getCoordinate(), state);
2021        if (!unique.isEmpty()) state.hasSourceLocation = true;
2022        return new ArrayList<Object>(unique.values());
2023    }
2024
2025    private List<Object> sourceLocations(String coordinate, State state) {
2026        Map<String, SlpcObject> unique = new TreeMap<String, SlpcObject>();
2027        addSourceLocation(unique, coordinate, state);
2028        if (!unique.isEmpty()) state.hasSourceLocation = true;
2029        return new ArrayList<Object>(unique.values());
2030    }
2031
2032    private void indexLegacySourceLocations(dataflow legacy, State state) {
2033        for (table relation : dataflow.getAllTables(legacy)) {
2034            state.sourceLocationsByLegacyEvidence.put("table:" + safeId(relation.getId(), relation.getName()),
2035                    sourceLocations(relation.getCoordinate(), state));
2036            for (column field : relation.getColumns()) {
2037                state.sourceLocationsByLegacyEvidence.put("column:" + safeId(field.getId(), field.getName()),
2038                        sourceLocations(field.getCoordinate(), state));
2039            }
2040        }
2041        for (relationship relation : legacy.getRelationships()) {
2042            state.sourceLocationsByLegacyEvidence.put(legacyId(relation), sourceLocations(relation, state));
2043        }
2044    }
2045
2046    private void addSourceLocation(Map<String, SlpcObject> target, String coordinate, State state) {
2047        int[] parsed = parsedCoordinate(coordinate);
2048        if (parsed == null) return;
2049        SlpcObject value = sourceLocation(state.artifact.string("artifactId"), parsed[0], parsed[1], parsed[2], parsed[3]);
2050        target.put(parsed[0] + ":" + parsed[1] + ":" + parsed[2] + ":" + parsed[3], value);
2051    }
2052
2053    private int[] parsedCoordinate(String coordinate) {
2054        if (coordinate == null) return null;
2055        Matcher matcher = LEGACY_COORDINATE.matcher(coordinate);
2056        if (!matcher.find()) return null;
2057        int startLine, startColumn, startArtifact, endLine, endColumn, endArtifact;
2058        try {
2059            startLine = Integer.parseInt(matcher.group(1)); startColumn = Integer.parseInt(matcher.group(2));
2060            startArtifact = Integer.parseInt(matcher.group(3)); endLine = Integer.parseInt(matcher.group(4));
2061            endColumn = Integer.parseInt(matcher.group(5)); endArtifact = Integer.parseInt(matcher.group(6));
2062        } catch (NumberFormatException invalid) { return null; }
2063        if (startLine < 1 || startColumn < 1 || endLine < 1 || endColumn < 1
2064                || startArtifact != 0 || endArtifact != 0
2065                || endLine < startLine || endLine == startLine && endColumn < startColumn) return null;
2066        return new int[] {startLine, startColumn, endLine, endColumn};
2067    }
2068
2069    private SlpcObject sourceLocation(String artifactId, int startLine, int startColumn, int endLine, int endColumn) {
2070        return SlpcObject.builder().put("artifactId", artifactId).put("startLine", startLine)
2071                .put("startColumn", startColumn).put("endLine", endLine).put("endColumn", endColumn).build();
2072    }
2073
2074    private List<Object> capabilities(State state) {
2075        List<Object> result = new ArrayList<Object>();
2076        Set<String> partial = new LinkedHashSet<String>();
2077        Collections.addAll(partial, "FACT_VALUE", "FACT_ROW_FILTER", "FACT_ROW_JOIN", "FACT_GROUP_BY",
2078                "TRANSFORM_EVIDENCE", "EXACT_IDENTIFIER_IDENTITY", "INTERMEDIATE_GRAPH", "JOIN_SEMANTICS",
2079                "STATEMENT_OPERATION", "LITERAL_PROVENANCE", "OBJECT_RELATIONSHIP", "FOREIGN_KEY_RELATIONSHIP",
2080                "PHYSICAL_HOP_COLLAPSE", "STORAGE_ASSET_IDENTITY", "ROUTINE_CALL", "STREAM_DEFINITION",
2081                "OBJECT_RENAME_SWAP_CLONE");
2082        if (state.hasInteractiveExplain) partial.add("INTERACTIVE_EXPLAIN");
2083        if (state.hasSourceLocation) partial.add("SOURCE_LOCATION");
2084        if (state.hasRoutineBinding) {
2085            partial.add("SCOPED_OBJECTS"); partial.add("ROUTINE_OVERLOAD_IDENTITY");
2086        }
2087        if (state.hasRoutineParameterFlow) partial.add("ROUTINE_PARAMETER_FLOW");
2088        for (String code : SlpcSemanticValidator.CAPABILITIES) {
2089            String status = partial.contains(code) ? "PARTIAL" : "UNSUPPORTED";
2090            if ("SECRET_REDACTION".equals(code)) status = "SUPPORTED";
2091            result.add(SlpcObject.builder().put("code", code).put("status", status).build());
2092        }
2093        state.diagnostics.add(diagnostic("SLPC_ADAPTER_CAPABILITY_GAP", "INFO", "DOCUMENT", null,
2094                "PARTIAL capabilities are bounded by completed legacy POJO evidence and caller-supplied identity/typed-transform proof; unsupported shapes remain candidates or diagnostics."));
2095        return result;
2096    }
2097
2098    private SlpcObject producer(State state) {
2099        return SlpcObject.builder().put("engineKind", "LEGACY_DLINEAGE").put("engineVersion", state.context.engineVersion())
2100                .put("adapterName", ADAPTER_NAME).put("adapterVersion", ADAPTER_VERSION).put("capabilities", capabilities(state)).build();
2101    }
2102
2103    private void accountUnsupportedRelationship(relationship relation, State state) {
2104        accountLoss(relation, state, "SLPC_RELATIONSHIP_UNMAPPED",
2105                "Legacy relationship type '" + safe(relation.getType()) + "' has no proven v0.2 mapping in M1.");
2106    }
2107
2108    private void accountSemanticGap(relationship relation, String code, String message, State state) {
2109        String key = legacyId(relation) + "\u0000" + code;
2110        if (!state.semanticGapKeys.add(key)) return;
2111        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
2112                legacyId(relation), code, message));
2113        state.diagnostics.add(diagnostic(code, "WARN", "DOCUMENT", null, message));
2114    }
2115
2116    private void accountLoss(relationship relation, State state, String code, String message) {
2117        state.coverage.add(new LegacyCoverageReport.Entry("RELATIONSHIP", legacyId(relation),
2118                LegacyCoverageReport.Outcome.UNSUPPORTED_WITH_LOSS, null, code));
2119        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
2120                legacyId(relation), code, message));
2121        state.diagnostics.add(diagnostic(code, "WARN", "DOCUMENT", null, message));
2122    }
2123
2124    private void accountErrors(dataflow legacy, State state) {
2125        int index = 0;
2126        for (gudusoft.gsqlparser.dlineage.dataflow.model.xml.error ignored : legacy.getErrors()) {
2127            index++;
2128            state.coverage.add(new LegacyCoverageReport.Entry("ERROR", "error-" + index,
2129                    LegacyCoverageReport.Outcome.CONDITIONAL, null, "represented by document diagnostic"));
2130            state.diagnostics.add(diagnostic("GSP_LEGACY_ANALYSIS_ERROR", "WARN", "DOCUMENT", null,
2131                    "The completed legacy analysis result contains an error record; its free text is intentionally not copied."));
2132        }
2133    }
2134
2135    private int countLegacyRecords(dataflow legacy) {
2136        int count = legacy.getRelationships().size() + legacy.getErrors().size() + legacy.getProcesses().size();
2137        for (table relation : dataflow.getAllTables(legacy)) count += 1 + relation.getColumns().size();
2138        for (procedure routine : legacy.getProcedures()) count += 1 + routine.getArguments().size();
2139        for (oraclePackage pkg : legacy.getPackages()) {
2140            count += 1 + pkg.getArguments().size();
2141            for (procedure routine : pkg.getProcedures()) count += 1 + routine.getArguments().size();
2142        }
2143        return count;
2144    }
2145
2146    private SlpcObject diagnostic(String code, String severity, String scope, String ref, String message) {
2147        return SlpcObject.builder().put("code", code).put("severity", severity).put("scope", scope).put("ref", ref)
2148                .put("message", message).putNull("sourceLocation").put("extensions", Collections.emptyList()).build();
2149    }
2150
2151    private String resolveTarget(targetColumn target, FactSemantics semantics, State state) {
2152        if (target == null) return null;
2153        if ("ROW".equals(semantics.axis)) return relationEndpoint(target.getParent_id(), state);
2154        String result = state.endpointByLegacyId.get(target.getId());
2155        if (result == null) result = state.endpointByLegacyId.get(target.getTarget_id());
2156        return endpointAtValueLevel(result, state);
2157    }
2158
2159    private String resolveTargetField(targetColumn target, State state) {
2160        String result = state.endpointByLegacyId.get(target.getId());
2161        if (result == null) result = state.endpointByLegacyId.get(target.getTarget_id());
2162        return endpointAtLevel(result, "FIELD", state);
2163    }
2164
2165    private String resolveSource(sourceColumn source, FactSemantics semantics, State state) {
2166        String result = state.endpointByLegacyId.get(source.getId());
2167        if (result == null) result = state.endpointByLegacyId.get(source.getSource_id());
2168        result = endpointAtValueLevel(result, state);
2169        if (result == null && "system".equals(source.getSource())
2170                && ("ROW".equals(semantics.axis) || "AGGREGATE".equals(semantics.role))) {
2171            result = relationEndpoint(source.getParent_id(), state);
2172        }
2173        return result;
2174    }
2175
2176    private String resolveSourceField(sourceColumn source, State state) {
2177        String result = state.endpointByLegacyId.get(source.getId());
2178        if (result == null) result = state.endpointByLegacyId.get(source.getSource_id());
2179        return endpointAtLevel(result, "FIELD", state);
2180    }
2181
2182    private String relationEndpoint(String legacyId, State state) {
2183        return endpointAtLevel(state.endpointByLegacyId.get(legacyId), "RELATION", state);
2184    }
2185
2186    private String relationEndpoint(targetColumn target, State state) {
2187        if (target == null) return null;
2188        String result = relationEndpoint(target.getParent_id(), state);
2189        if (result == null) result = relationEndpoint(target.getTarget_id(), state);
2190        if (result == null) result = relationEndpoint(target.getId(), state);
2191        return result;
2192    }
2193
2194    private String relationEndpoint(sourceColumn source, State state) {
2195        if (source == null) return null;
2196        String result = relationEndpoint(source.getParent_id(), state);
2197        if (result == null) result = relationEndpoint(source.getSource_id(), state);
2198        if (result == null) result = relationEndpoint(source.getId(), state);
2199        return result;
2200    }
2201
2202    private String endpointAtLevel(String ref, String level, State state) {
2203        SlpcObject endpoint = ref == null ? null : state.endpointByRef.get(ref);
2204        return endpoint != null && level.equals(endpoint.string("endpointLevel")) ? ref : null;
2205    }
2206
2207    private String endpointAtValueLevel(String ref, State state) {
2208        SlpcObject endpoint = ref == null ? null : state.endpointByRef.get(ref);
2209        return endpoint != null && equalsAny(endpoint.string("endpointLevel"), "FIELD", "VARIABLE") ? ref : null;
2210    }
2211
2212    private SlpcObject targetAnchor(targetColumn target, FactSemantics semantics, State state) {
2213        if (target == null) return null;
2214        boolean row = "ROW".equals(semantics.axis);
2215        NodeDraft node = row ? nodeByLegacyId(state, target.getParent_id())
2216                : nodeByLegacyId(state, target.getId(), target.getTarget_id());
2217        if (node != null) return nodeAnchor(node.finalRef, row ? "ROW" : "VALUE");
2218        String endpoint = resolveTarget(target, semantics, state);
2219        return endpoint == null ? null : endpointAnchor(endpoint, row ? "ROW" : "VALUE");
2220    }
2221
2222    private SlpcObject sourceAnchor(sourceColumn source, FactSemantics semantics, State state) {
2223        if (semantics.derived) return null;
2224        NodeDraft node = nodeByLegacyId(state, source.getId(), source.getSource_id());
2225        if (node == null && "system".equals(source.getSource())
2226                && ("ROW".equals(semantics.axis) || "AGGREGATE".equals(semantics.role))) {
2227            node = nodeByLegacyId(state, source.getParent_id());
2228        }
2229        if (node != null) return nodeAnchor(node.finalRef, "ROW".equals(semantics.axis) ? "ROW" : "VALUE");
2230        String endpoint = resolveSource(source, semantics, state);
2231        return endpoint == null ? null : endpointAnchor(endpoint, "ROW".equals(semantics.axis) ? "ROW" : "VALUE");
2232    }
2233
2234    private NodeDraft nodeByLegacyId(State state, String... ids) {
2235        for (String id : ids) if (id != null && state.intermediateNodeByLegacyId.containsKey(id)) return state.intermediateNodeByLegacyId.get(id);
2236        return null;
2237    }
2238
2239    private SlpcObject endpointAnchor(String ref, String port) { return SlpcObject.builder().put("kind", "ENDPOINT").put("endpointRef", ref).put("portCode", port).build(); }
2240    private SlpcObject nodeAnchor(String ref, String port) { return SlpcObject.builder().put("kind", "NODE").put("nodeRef", ref).put("portCode", port).build(); }
2241
2242    private List<Object> displaySegments(table relation, column field) {
2243        List<Object> result = new ArrayList<Object>();
2244        if (nonEmpty(relation.getServer()) != null) result.add(display("SERVER", relation.getServer()));
2245        if (nonEmpty(relation.getDatabase()) != null) result.add(display("DATABASE", relation.getDatabase()));
2246        if (nonEmpty(relation.getSchema()) != null) result.add(display("SCHEMA", relation.getSchema()));
2247        result.add(display("OBJECT", safe(relation.getName())));
2248        if (field != null) result.add(display("COLUMN", safe(field.getName())));
2249        return result;
2250    }
2251
2252    private SlpcObject display(String kind, String value) {
2253        return SlpcObject.builder().put("kind", kind).put("display", value)
2254                .put("inputForm", "ENGINE_SYNTHETIC").put("provenance", "ENGINE_SYNTHETIC").build();
2255    }
2256
2257    private String objectKind(table relation, State state) {
2258        if (relation.isResultSet()) {
2259            String type = safe(relation.getType());
2260            ResultSetType resultSetType = ResultSetType.of(type);
2261            if (resultSetType == ResultSetType.cte || "with_cte".equals(type)) return "CTE";
2262            if (resultSetType == ResultSetType.result_of) return "DERIVED_TABLE";
2263            return "RESULT_SET";
2264        }
2265        if (relation.isView()) return "VIEW";
2266        if (relation.isStream()) return "STREAM";
2267        if (relation.isStage()) return "STAGE";
2268        if (relation.isFile()) return "FILE";
2269        if (relation.isCursor()) return "CURSOR";
2270        if (relation.isVariable()) return "SCALAR_VARIABLE";
2271        if ("procedure".equalsIgnoreCase(relation.getType())) return "PROCEDURE";
2272        if ("CREATE_EXTERNAL_TABLE".equals(operationKind(state.context.statementKind()))
2273                && (relation.isTarget() || createdInSql(relation))) return "EXTERNAL_TABLE";
2274        if ("CREATE_MATERIALIZED_VIEW".equals(operationKind(state.context.statementKind()))
2275                && (relation.isTarget() || createdInSql(relation))) return "MATERIALIZED_VIEW";
2276        if (relation.isTable()) return "TABLE";
2277        return "UNKNOWN";
2278    }
2279
2280    private String routineObjectKind(table relation) {
2281        return "function".equalsIgnoreCase(relation.getType()) ? "FUNCTION" : "PROCEDURE";
2282    }
2283
2284    private String endpointLevel(table relation, String legacyKind) {
2285        if ("PROCEDURE".equals(legacyKind)) return "ROUTINE";
2286        if (relation.isVariable() || relation.isCursor()) return "VARIABLE";
2287        return "RELATION";
2288    }
2289
2290    private String nodeKind(table relation) {
2291        String raw = safe(relation.getType());
2292        if ("with_cte".equals(raw)) return "CTE";
2293        ResultSetType type = ResultSetType.of(raw);
2294        if (type == null) return "OTHER";
2295        switch (type) {
2296            case select_list: return "SELECT_LIST";
2297            case cte: return "CTE";
2298            case insert_select: return "INSERT_SELECT";
2299            case update_select: return "UPDATE_SELECT";
2300            case update_set: return "UPDATE_SET";
2301            case merge_update: return "MERGE_UPDATE";
2302            case merge_insert: return "MERGE_INSERT";
2303            case output: return "OUTPUT";
2304            case pivot_table: return "PIVOT";
2305            case unpivot_table: return "UNPIVOT";
2306            case alias: return "ALIAS";
2307            case function: return "FUNCTION_RESULT";
2308            case array: return "ARRAY";
2309            case struct: return "STRUCT";
2310            case result_of: return "RESULT_OF";
2311            case variable: return "VARIABLE";
2312            default: return "OTHER";
2313        }
2314    }
2315
2316    private String nodeKindForField(table relation) {
2317        // A projected field is not automatically a SQL alias.  The legacy
2318        // column POJO has no dedicated alias discriminator after the default
2319        // finish path, so retain the node without inventing ALIAS semantics.
2320        return "OTHER";
2321    }
2322
2323    private boolean isIntermediate(table relation) {
2324        return relation.isResultSet();
2325    }
2326
2327    private boolean isRelationRows(column field) {
2328        return "system".equals(field.getSource()) || "RelationRows".equals(field.getName());
2329    }
2330
2331    private boolean hasCandidateTables(table relation) {
2332        return relation.getCandidateTables() != null && !relation.getCandidateTables().isEmpty();
2333    }
2334
2335    private boolean hasCandidateParents(sourceColumn source) {
2336        return source.getCandidateParents() != null && !source.getCandidateParents().isEmpty();
2337    }
2338
2339    /**
2340     * These legacy effect codes describe object/storage operations or an
2341     * assumed dependency.  Treating them as ordinary VALUE/ROW facts would be
2342     * a semantic invention; a later M1 slice must map them to the corresponding
2343     * statement operation, object relationship, or quality model.
2344     */
2345    private boolean unsupportedFactEffect(String value) {
2346        return equalsAny(value,
2347                "rename_table", "swap_table", "append_from", "like_table",
2348                "clone_database", "clone_schema", "clone_table", "unload", "copy",
2349                "foreign_key", "create_synonym", "exchange_partition", "synonym",
2350                "drop_table", "drop_table_column", "add_table_column", "truncate_table",
2351                "external_script_passthrough");
2352    }
2353
2354    /** Data dependencies remain useful, but a second typed operation is owed. */
2355    private boolean effectNeedsAdditionalMapping(String value) {
2356        return equalsAny(value, "create_view", "create_table", "trigger", "expand_star");
2357    }
2358
2359    private boolean hasUnavailableIdentity(List<EndpointDraft> drafts) {
2360        for (EndpointDraft draft : drafts) if (draft.canonical == null) return true;
2361        return false;
2362    }
2363
2364    /** Only a dedicated alias field is accepted as SQL-proven local naming evidence. */
2365    private String provenLocalName(table relation) {
2366        return nonEmpty(relation.getAlias());
2367    }
2368
2369    private void containmentLoss(table relation, State state, String message) {
2370        String owner = safeId(relation.getId(), safe(relation.getName()));
2371        state.differences.add(new IntentionalDiffLedger.Entry(IntentionalDiffLedger.Category.KNOWN_UNSUPPORTED_WITH_LOSS,
2372                owner, "GSP_LEGACY_CONTAINMENT_UNRESOLVED", message));
2373    }
2374
2375    private void breakContainmentCycles(List<table> intermediate, IdentityHashMap<table, table> parentByTable, State state) {
2376        Set<table> settled = Collections.newSetFromMap(new IdentityHashMap<table, Boolean>());
2377        for (table start : intermediate) {
2378            if (settled.contains(start)) continue;
2379            List<table> path = new ArrayList<table>();
2380            IdentityHashMap<table, Integer> position = new IdentityHashMap<table, Integer>();
2381            table current = start;
2382            while (current != null && !settled.contains(current)) {
2383                Integer repeatedAt = position.get(current);
2384                if (repeatedAt != null) {
2385                    List<table> cycle = new ArrayList<table>(path.subList(repeatedAt, path.size()));
2386                    Collections.sort(cycle, tableComparator());
2387                    table detached = cycle.get(0);
2388                    parentByTable.remove(detached);
2389                    containmentLoss(detached, state,
2390                            "Legacy intermediate containment contains a cycle; one deterministic link was removed and the node retained as a root.");
2391                    break;
2392                }
2393                position.put(current, path.size()); path.add(current); current = parentByTable.get(current);
2394            }
2395            settled.addAll(path);
2396        }
2397    }
2398
2399    private Comparator<table> sourceTableComparator() {
2400        return new Comparator<table>() { @Override public int compare(table left, table right) {
2401            int coordinate = compareCoordinates(left.getCoordinate(), right.getCoordinate());
2402            return coordinate != 0 ? coordinate : tableComparator().compare(left, right);
2403        }};
2404    }
2405
2406    private Comparator<PendingNode> pendingNodeComparator() {
2407        return new Comparator<PendingNode>() { @Override public int compare(PendingNode left, PendingNode right) {
2408            String leftCoordinate = left.field == null ? left.relation.getCoordinate() : left.field.getCoordinate();
2409            String rightCoordinate = right.field == null ? right.relation.getCoordinate() : right.field.getCoordinate();
2410            int coordinate = compareCoordinates(leftCoordinate, rightCoordinate);
2411            if (coordinate != 0) return coordinate;
2412            String leftKey = (left.field == null ? "0" : "1") + "\u0000" + safe(left.relation.getId()) + "\u0000"
2413                    + safe(left.field == null ? null : left.field.getId()) + "\u0000" + safe(left.relation.getName()) + "\u0000"
2414                    + safe(left.field == null ? null : left.field.getName());
2415            String rightKey = (right.field == null ? "0" : "1") + "\u0000" + safe(right.relation.getId()) + "\u0000"
2416                    + safe(right.field == null ? null : right.field.getId()) + "\u0000" + safe(right.relation.getName()) + "\u0000"
2417                    + safe(right.field == null ? null : right.field.getName());
2418            return utf8Compare(leftKey, rightKey);
2419        }};
2420    }
2421
2422    private int compareCoordinates(String left, String right) {
2423        long[] a = firstCoordinate(left), b = firstCoordinate(right);
2424        for (int index = 0; index < a.length; index++) {
2425            if (a[index] < b[index]) return -1;
2426            if (a[index] > b[index]) return 1;
2427        }
2428        return 0;
2429    }
2430
2431    private long[] firstCoordinate(String value) {
2432        long unavailable = Long.MAX_VALUE;
2433        long[] result = {unavailable, unavailable, unavailable};
2434        if (value == null) return result;
2435        int open = value.indexOf('['), close = value.indexOf(']', open + 1);
2436        if (open < 0 || close < 0) return result;
2437        String[] parts = value.substring(open + 1, close).split(",", -1);
2438        for (int index = 0; index < result.length && index < parts.length; index++) {
2439            try {
2440                long parsed = Long.parseLong(parts[index].trim());
2441                result[index] = parsed < 0 ? unavailable : parsed;
2442            } catch (NumberFormatException ignored) {
2443                result[index] = unavailable;
2444            }
2445        }
2446        return result;
2447    }
2448
2449    private void putNodeLegacyId(Map<String, NodeDraft> target, String id, NodeDraft node) {
2450        if (id == null) return;
2451        NodeDraft previous = target.put(id, node);
2452        if (previous != null && previous != node) throw new IllegalStateException("duplicate legacy intermediate identifier: " + id);
2453    }
2454
2455    private boolean isCanonicalFact(SlpcObject semantic, Map<String, SlpcObject> endpoints) {
2456        String target = semantic.string("targetEndpointRef"); if (endpoints.get(target).string("canonicalEndpointId") == null) return false;
2457        String source = semantic.string("sourceEndpointRef"); return source == null || endpoints.get(source).string("canonicalEndpointId") != null;
2458    }
2459
2460    private boolean isCanonicalIdentity(SlpcObject identity) {
2461        String kind = identity.string("kind"); return "COMPOSITE_EXACT".equals(kind) || "AUTHORITATIVE_EXTERNAL".equals(kind) || "URI_EXACT".equals(kind);
2462    }
2463
2464    private String candidateReason(SlpcObject semantic) {
2465        return "IDENTITY_UNAVAILABLE";
2466    }
2467
2468    private SlpcObject occurrenceQuality(String resolution) {
2469        return quality(resolution, "EXACT", "PROVEN", 1000000);
2470    }
2471
2472    private SlpcObject quality(String resolution, String evaluation, String proof, int confidence) {
2473        return SlpcObject.builder().put("resolutionGrade", resolution).put("evaluationModel", evaluation)
2474                .put("semanticProof", proof).put("dynamic", false).put("confidenceMicros", confidence).build();
2475    }
2476
2477    private SlpcObject redactedLiteral(State state, int redactionOrdinal) {
2478        String kind = "UNKNOWN";
2479        SlpcObject fingerprint = SlpcObject.builder().put("algorithm", "SLPC-REDACTED-LITERAL-V1")
2480                .put("value", SlpcIdentity.redactedLiteralHash(state.context.sqlDialect(), kind, redactionOrdinal)).build();
2481        return SlpcObject.builder().put("inputOrdinal", 1).put("literalKind", kind)
2482                .put("text", "<SLPC-REDACTED:" + kind + ":" + redactionOrdinal + ">")
2483                .put("redactionOrdinal", redactionOrdinal).put("fingerprint", fingerprint)
2484                .putNull("sourceLocation").put("locationNullReason", "UNKNOWN").build();
2485    }
2486
2487    private SlpcObject qualitySummary(String eligibility, String resolution, int count) {
2488        return SlpcObject.builder().put("projectionEligibility", eligibility).put("resolutionGrade", resolution)
2489                .put("evaluationModel", "EXACT").put("semanticProof", "PROVEN").put("dynamic", false)
2490                .put("confidenceMicros", 1000000).put("evidenceCount", count).build();
2491    }
2492
2493    private boolean allCanonical(List<String> keys, State state) { for (String key : keys) if (!state.factGroups.get(key).canonical) return false; return true; }
2494
2495    private static Comparator<FactGroup> factGroupComparator() { return new Comparator<FactGroup>() { @Override public int compare(FactGroup left, FactGroup right) { return left.semanticKey.compareTo(right.semanticKey); }}; }
2496    private static Comparator<RelationshipGroup> relationshipGroupComparator() { return new Comparator<RelationshipGroup>() { @Override public int compare(RelationshipGroup left, RelationshipGroup right) { return left.semanticKey.compareTo(right.semanticKey); }}; }
2497    private static Comparator<NodeDraft> nodeFingerprintComparator() { return new Comparator<NodeDraft>() { @Override public int compare(NodeDraft left, NodeDraft right) { return left.fingerprint.compareTo(right.fingerprint); }}; }
2498    private static Comparator<EdgeDraft> edgeFingerprintComparator() { return new Comparator<EdgeDraft>() { @Override public int compare(EdgeDraft left, EdgeDraft right) { return left.fingerprint.compareTo(right.fingerprint); }}; }
2499    private static Comparator<PathDraft> pathFingerprintComparator() { return new Comparator<PathDraft>() { @Override public int compare(PathDraft left, PathDraft right) { return left.fingerprint.compareTo(right.fingerprint); }}; }
2500    private static Comparator<table> tableComparator() { return new Comparator<table>() { @Override public int compare(table left, table right) {
2501        return utf8Compare(safe(left.getId()) + "\u0000" + safe(left.getName()),
2502                safe(right.getId()) + "\u0000" + safe(right.getName()));
2503    }}; }
2504
2505    private static void putLegacyRef(Map<String, String> refs, String id, String ref) { if (id != null && !refs.containsKey(id)) refs.put(id, ref); }
2506    private static String legacyId(relationship relation) { return safeId(relation.getId(), "relationship:" + safe(relation.getType())); }
2507    private static String safeId(String id, String fallback) { return id == null || id.isEmpty() ? fallback : id; }
2508    private static String safe(String value) { return value == null ? "" : value; }
2509    private static String nonEmpty(String value) { return value == null || value.isEmpty() ? null : value; }
2510    private static String local(String prefix, int ordinal) { return String.format("%s%06d", prefix, ordinal); }
2511    private static String zeros(int count) { StringBuilder result = new StringBuilder(); for (int index = 0; index < count; index++) result.append('0'); return result.toString(); }
2512    private static String join(List<String> values) { StringBuilder result = new StringBuilder(); for (String value : values) { if (result.length() > 0) result.append(','); result.append(value); } return result.toString(); }
2513    private static boolean equalsAny(String value, String... choices) { if (value == null) return false; for (String choice : choices) if (value.equals(choice)) return true; return false; }
2514    private static boolean equalValues(Object left, Object right) { return left == null ? right == null : left.equals(right); }
2515    private static int utf8Compare(String left, String right) { byte[] a = left.getBytes(StandardCharsets.UTF_8), b = right.getBytes(StandardCharsets.UTF_8); int common = Math.min(a.length, b.length); for (int index = 0; index < common; index++) { int difference = (a[index] & 255) - (b[index] & 255); if (difference != 0) return difference; } return a.length - b.length; }
2516    private static String sha256(byte[] value) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(value); StringBuilder result = new StringBuilder(); for (byte item : digest) result.append(String.format("%02x", item & 255)); return result.toString(); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } }
2517
2518    private final class State {
2519        private final LegacyAdapterContext context;
2520        private SlpcObject artifact, statement; private String statementId;
2521        private final List<Object> endpoints = new ArrayList<Object>(), facts = new ArrayList<Object>(), factCandidates = new ArrayList<Object>(), occurrences = new ArrayList<Object>(), diagnostics = new ArrayList<Object>(), graphs = new ArrayList<Object>(), statementOperations = new ArrayList<Object>(), joinSemantics = new ArrayList<Object>(), objectRelationships = new ArrayList<Object>(), objectRelationshipCandidates = new ArrayList<Object>();
2522        private final Map<String, SlpcObject> endpointByRef = new LinkedHashMap<String, SlpcObject>();
2523        private final Map<String, String> endpointByLegacyId = new HashMap<String, String>();
2524        private final IdentityHashMap<table, EndpointDraft> tableDrafts = new IdentityHashMap<table, EndpointDraft>();
2525        private final IdentityHashMap<column, EndpointDraft> columnDrafts = new IdentityHashMap<column, EndpointDraft>();
2526        private final IdentityHashMap<procedure, EndpointDraft> routineDrafts = new IdentityHashMap<procedure, EndpointDraft>();
2527        private final IdentityHashMap<procedure, LegacyAdapterContext.RoutineBinding> routineBindings =
2528                new IdentityHashMap<procedure, LegacyAdapterContext.RoutineBinding>();
2529        private final IdentityHashMap<procedure, Map<String, EndpointDraft>> parameterDraftsByRoutine =
2530                new IdentityHashMap<procedure, Map<String, EndpointDraft>>();
2531        private final IdentityHashMap<EndpointDraft, LegacyAdapterContext.RoutineParameterBinding> parameterBindingByDraft =
2532                new IdentityHashMap<EndpointDraft, LegacyAdapterContext.RoutineParameterBinding>();
2533        private final Map<String, String> parameterRefByKey = new HashMap<String, String>();
2534        private final Map<String, List<Object>> sourceLocationsByLegacyEvidence =
2535                new HashMap<String, List<Object>>();
2536        private final Set<String> mappedArgumentIds = new HashSet<String>();
2537        private final IdentityHashMap<table, String> tableRefs = new IdentityHashMap<table, String>();
2538        private final IdentityHashMap<column, String> columnRefs = new IdentityHashMap<column, String>();
2539        private final IdentityHashMap<table, NodeDraft> intermediateTableNodes = new IdentityHashMap<table, NodeDraft>();
2540        private final IdentityHashMap<column, NodeDraft> intermediateColumnNodes = new IdentityHashMap<column, NodeDraft>();
2541        private final Map<String, NodeDraft> intermediateNodeByLegacyId = new HashMap<String, NodeDraft>();
2542        private final IdentityHashMap<table, String> intermediateTableRefs = new IdentityHashMap<table, String>();
2543        private final IdentityHashMap<column, String> intermediateColumnRefs = new IdentityHashMap<column, String>();
2544        private final Map<String, FactGroup> factGroups = new LinkedHashMap<String, FactGroup>();
2545        private final Map<String, RelationshipGroup> relationshipGroups = new LinkedHashMap<String, RelationshipGroup>();
2546        private final List<FactOccurrenceDraft> factOccurrenceDrafts = new ArrayList<FactOccurrenceDraft>();
2547        private final Set<String> dedicatedRelationshipAccounting = new HashSet<String>();
2548        private int nextRedactionOrdinal = 1;
2549        private int legacyInputCount;
2550        private boolean hasLegacyEndpointAmbiguity;
2551        private boolean hasStorageIdentityUnavailable, hasPhysicalHopCollapse, hasInteractiveExplain;
2552        private boolean hasRoutineBinding, hasRoutineParameterFlow, hasSourceLocation;
2553        private boolean pathCycleObserved, pathEnumerationTruncated;
2554        private final List<LegacyCoverageReport.Entry> coverage = new ArrayList<LegacyCoverageReport.Entry>();
2555        private final List<IntentionalDiffLedger.Entry> differences = new ArrayList<IntentionalDiffLedger.Entry>();
2556        private final Set<String> semanticGapKeys = new HashSet<String>();
2557        private State(LegacyAdapterContext context) { this.context = context; }
2558
2559        private LegacyAdaptation finish() {
2560            SlpcObject root = SlpcDocument.rootBuilder().put("producer", producer(this))
2561                    .put("artifacts", Collections.singletonList(artifact)).put("statements", Collections.singletonList(statement))
2562                    .put("endpoints", endpoints).put("facts", facts).put("factCandidates", factCandidates)
2563                    .put("datasetOperationInfluences", Collections.emptyList()).put("datasetOperationCandidates", Collections.emptyList())
2564                    .put("occurrences", occurrences).put("diagnostics", diagnostics).put("extensions", Collections.emptyList())
2565                    .put("objectRelationships", objectRelationships).put("objectRelationshipCandidates", objectRelationshipCandidates)
2566                    .put("statementOperations", statementOperations).put("joinSemantics", joinSemantics)
2567                    .put("intermediateGraphs", graphs).build();
2568            return new LegacyAdaptation(new SlpcDocument(root), new LegacyCoverageReport(legacyInputCount, coverage), new IntentionalDiffLedger(differences));
2569        }
2570    }
2571
2572    private static final class EndpointDraft {
2573        private final table relation; private final column field; private final SlpcObject provisional; private final String canonical, sortKey, stableTieBreaker, legacyKind, legacyId; private String endpointRef; private EndpointDraft representative; private procedure routine;
2574        private EndpointDraft(table relation, column field, SlpcObject provisional, String canonical, String sortKey, String stableTieBreaker, String legacyKind, String legacyId) { this.relation = relation; this.field = field; this.provisional = provisional; this.canonical = canonical; this.sortKey = sortKey; this.stableTieBreaker = stableTieBreaker; this.legacyKind = legacyKind; this.legacyId = legacyId; }
2575        private SlpcObject withRef() { return provisional.toBuilder().put("endpointRef", endpointRef).build(); }
2576        private boolean sameSemanticIdentity(EndpointDraft other) {
2577            if (canonical != null || other.canonical != null) {
2578                return canonical != null && canonical.equals(other.canonical)
2579                        && provisional.string("endpointLevel").equals(other.provisional.string("endpointLevel"))
2580                        && provisional.string("objectKind").equals(other.provisional.string("objectKind"))
2581                        && provisional.get("identity").equals(other.provisional.get("identity"))
2582                        && equalValues(provisional.get("scope"), other.provisional.get("scope"));
2583            }
2584            return provisional.toBuilder().remove("endpointRef").build()
2585                    .equals(other.provisional.toBuilder().remove("endpointRef").build());
2586        }
2587    }
2588
2589    private static final class FactSemantics {
2590        private final String axis, role, operationKind, operationInputKind, diagnosticCode, diagnosticMessage;
2591        private final boolean derived, publish;
2592        private FactSemantics(String axis, String role, String operationKind, String operationInputKind, boolean derived,
2593                boolean publish, String diagnosticCode, String diagnosticMessage) {
2594            this.axis = axis; this.role = role; this.operationKind = operationKind; this.operationInputKind = operationInputKind;
2595            this.derived = derived; this.publish = publish; this.diagnosticCode = diagnosticCode; this.diagnosticMessage = diagnosticMessage;
2596        }
2597        private static FactSemantics publish(String axis, String role, String operationKind, String operationInputKind, boolean derived) {
2598            return new FactSemantics(axis, role, operationKind, operationInputKind, derived, true, null, null);
2599        }
2600        private static FactSemantics publishWithGap(String axis, String role, String operationKind, String operationInputKind,
2601                boolean derived, String diagnosticCode, String diagnosticMessage) {
2602            return new FactSemantics(axis, role, operationKind, operationInputKind, derived, true, diagnosticCode, diagnosticMessage);
2603        }
2604        private static FactSemantics reject(String diagnosticCode, String diagnosticMessage) {
2605            return new FactSemantics("ROW", "UNKNOWN", "NONE", "NONE", false, false, diagnosticCode, diagnosticMessage);
2606        }
2607    }
2608
2609    private static final class SystemFieldDraft {
2610        private final table relation; private final column field;
2611        private SystemFieldDraft(table relation, column field) { this.relation = relation; this.field = field; }
2612    }
2613
2614    private static final class PendingNode {
2615        private final table relation; private final column field; private final NodeDraft parent;
2616        private PendingNode(table relation, column field, NodeDraft parent) { this.relation = relation; this.field = field; this.parent = parent; }
2617    }
2618
2619    private static final class FactGroup {
2620        private final SlpcObject semantic; private final boolean canonical; private final String semanticKey; private final List<FactOccurrenceDraft> occurrences = new ArrayList<FactOccurrenceDraft>(); private String ownerKind, ownerId;
2621        private FactGroup(SlpcObject semantic, boolean canonical, String semanticKey) { this.semantic = semantic; this.canonical = canonical; this.semanticKey = semanticKey; }
2622        private boolean sameSemantic(SlpcObject other) { return semantic.equals(other); }
2623    }
2624
2625    private static final class FactOccurrenceDraft {
2626        private final FactGroup group; private final SlpcObject transformation, predicate; private final String resolution;
2627        private final List<Object> literalInputs, supportPathRefs, sourceLocations; private final String procedureEndpointRef;
2628        private FactOccurrenceDraft(FactGroup group, SlpcObject transformation, SlpcObject predicate, String resolution,
2629                List<Object> literalInputs, List<Object> supportPathRefs) {
2630            this(group, transformation, predicate, resolution, literalInputs, supportPathRefs,
2631                    Collections.<Object>emptyList(), null);
2632        }
2633        private FactOccurrenceDraft(FactGroup group, SlpcObject transformation, SlpcObject predicate, String resolution,
2634                List<Object> literalInputs, List<Object> supportPathRefs, List<Object> sourceLocations,
2635                String procedureEndpointRef) {
2636            this.group = group; this.transformation = transformation; this.predicate = predicate;
2637            this.resolution = resolution; this.literalInputs = literalInputs; this.supportPathRefs = supportPathRefs;
2638            this.sourceLocations = sourceLocations; this.procedureEndpointRef = procedureEndpointRef;
2639        }
2640    }
2641
2642    private static final class RelationshipGroup {
2643        private final SlpcObject semantic; private final boolean canonical; private final String semanticKey;
2644        private final Set<String> legacyEvidence = new LinkedHashSet<String>(); private String ownerKind, ownerId;
2645        private final Map<String, SlpcObject> sourceLocations = new TreeMap<String, SlpcObject>();
2646        private RelationshipGroup(SlpcObject semantic, boolean canonical, String semanticKey) {
2647            this.semantic = semantic; this.canonical = canonical; this.semanticKey = semanticKey;
2648        }
2649    }
2650
2651    private static final class NodeDraft {
2652        private final table relation; private final column field; private final int ordinal; private final String kind, localName; private final NodeDraft parent;
2653        private String provisionalRef, finalRef, fingerprint; private SlpcObject provisionalNode;
2654        private NodeDraft(table relation, column field, int ordinal, String kind, String localName, NodeDraft parent) { this.relation = relation; this.field = field; this.ordinal = ordinal; this.kind = kind; this.localName = localName; this.parent = parent; }
2655        private SlpcObject node(String ref, String parentRef, String fingerprint) { return SlpcObject.builder().put("nodeRef", ref).put("nodeFingerprint", fingerprint).put("nodeOrdinal", ordinal).put("nodeKind", kind).put("localName", localName).put("parentNodeRef", parentRef).putNull("boundaryEndpointRef").putNull("nodeEvidence").put("extensions", Collections.emptyList()).build(); }
2656    }
2657
2658    private static final class EdgeDraft {
2659        private final relationship relation; private final sourceColumn sourceColumn;
2660        private final SlpcObject source, target; private final FactSemantics semantics; private final SlpcObject transformation, predicate; private String fingerprint, finalRef;
2661        private EdgeDraft(relationship relation, sourceColumn sourceColumn, SlpcObject source, SlpcObject target,
2662                FactSemantics semantics, SlpcObject transformation, SlpcObject predicate) { this.relation = relation; this.sourceColumn = sourceColumn; this.source = source; this.target = target; this.semantics = semantics; this.transformation = transformation; this.predicate = predicate; }
2663        private SlpcObject edge(String ref, String fingerprint) { return SlpcObject.builder().put("source", source).put("target", target).put("edgeRef", ref).put("edgeFingerprint", fingerprint).put("axis", semantics.axis).put("role", semantics.role).put("operationKind", semantics.operationKind).put("operationInputKind", semantics.operationInputKind).put("transformation", transformation).put("predicate", predicate).put("extensions", Collections.emptyList()).build(); }
2664        private boolean sameSemantic(EdgeDraft other) { return source.equals(other.source) && target.equals(other.target)
2665                && semantics.axis.equals(other.semantics.axis) && semantics.role.equals(other.semantics.role)
2666                && semantics.operationKind.equals(other.semantics.operationKind)
2667                && semantics.operationInputKind.equals(other.semantics.operationInputKind)
2668                && (transformation == null ? other.transformation == null : transformation.equals(other.transformation))
2669                && (predicate == null ? other.predicate == null : predicate.equals(other.predicate)); }
2670    }
2671
2672    private static final class PathDraft {
2673        private final List<EdgeDraft> edges; private String fingerprint, finalRef;
2674        private PathDraft(List<EdgeDraft> edges) { this.edges = new ArrayList<EdgeDraft>(edges); }
2675        private SlpcObject path(String ref, String fp) {
2676            List<Object> refs = new ArrayList<Object>(); for (EdgeDraft edge : edges) refs.add(edge.finalRef);
2677            return SlpcObject.builder().put("pathRef", ref).put("pathFingerprint", fp)
2678                    .put("edgeRefs", refs).put("extensions", Collections.emptyList()).build();
2679        }
2680        private String sourceEndpointRef() { return "ENDPOINT".equals(edges.get(0).source.string("kind"))
2681                ? edges.get(0).source.string("endpointRef") : null; }
2682        private String targetEndpointRef() { EdgeDraft last = edges.get(edges.size() - 1); return "ENDPOINT".equals(last.target.string("kind"))
2683                ? last.target.string("endpointRef") : null; }
2684        private String axis() { return edges.get(0).semantics.axis; }
2685    }
2686
2687    private static final class PathWalk {
2688        private final List<EdgeDraft> edges; private final Set<String> used;
2689        private PathWalk(List<EdgeDraft> edges, Set<String> used) { this.edges = edges; this.used = used; }
2690        private static PathWalk start(EdgeDraft edge) {
2691            List<EdgeDraft> edges = new ArrayList<EdgeDraft>(); edges.add(edge);
2692            Set<String> used = new HashSet<String>(); used.add(edge.finalRef); return new PathWalk(edges, used);
2693        }
2694        private PathWalk append(EdgeDraft edge) {
2695            List<EdgeDraft> copy = new ArrayList<EdgeDraft>(edges); copy.add(edge);
2696            Set<String> copyUsed = new HashSet<String>(used); copyUsed.add(edge.finalRef); return new PathWalk(copy, copyUsed);
2697        }
2698        private String edgeKey() { StringBuilder result = new StringBuilder(); for (EdgeDraft edge : edges) result.append(edge.finalRef).append('\u0000'); return result.toString(); }
2699    }
2700
2701    private static final class StepDraft {
2702        private final String family, code; private final List<Object> bindings; private final SlpcObject result, evidence;
2703        private StepDraft(String family, String code, List<Object> bindings, SlpcObject result, SlpcObject evidence) {
2704            this.family = family; this.code = code; this.bindings = bindings; this.result = result; this.evidence = evidence;
2705        }
2706        private SlpcObject step(String ref, String fingerprint, int ordinal) {
2707            return SlpcObject.builder().put("stepRef", ref).put("stepFingerprint", fingerprint)
2708                    .put("stepOrdinal", ordinal).put("operationFamily", family).put("operationCode", code)
2709                    .put("inputBindings", bindings).put("resultAnchor", result).put("expressionEvidence", evidence)
2710                    .put("extensions", Collections.emptyList()).build();
2711        }
2712        private String sortKey() {
2713            StringBuilder resultKey = new StringBuilder(family).append('\u0000').append(code).append('\u0000');
2714            for (Object value : bindings) resultKey.append(((SlpcObject) value).string("edgeRef")).append('\u0000');
2715            return resultKey.toString();
2716        }
2717    }
2718}