[data-slot=button]]:h-auto [&>[data-slot=button]]:min-h-9",
+ "[&>[data-slot=button]]:w-full [&>[data-slot=button]]:min-w-0",
+ "[&>[data-slot=button]]:shrink [&>[data-slot=button]]:whitespace-normal",
+ "[&>[data-slot=button]]:py-2 [&>[data-slot=button]]:leading-tight",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
diff --git a/apps/merge/src/components/task-progress.tsx b/apps/merge/src/components/task-progress.tsx
deleted file mode 100644
index f84c6258..00000000
--- a/apps/merge/src/components/task-progress.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { useLog } from "../hooks/log";
-import { Progress } from "./ui/progress";
-
-function formatElapsed(ms: number) {
- const totalSeconds = Math.floor(ms / 1_000);
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}:${String(seconds).padStart(2, "0")}`;
-}
-
-/**
- * Progress feedback for long-running worker tasks. Worker progress messages do
- * not include a numeric percentage yet, so the bar is indeterminate; the latest
- * log message and a live elapsed timer show that work is advancing.
- */
-export default function TaskProgress() {
- const { log, activeTasks, taskStartedAt } = useLog();
- const [now, setNow] = useState(() => Date.now());
-
- useEffect(() => {
- if (taskStartedAt == null) return;
- const interval = setInterval(() => setNow(Date.now()), 1_000);
- return () => clearInterval(interval);
- }, [taskStartedAt]);
-
- if (activeTasks === 0 || taskStartedAt == null) return null;
- const latest = log[log.length - 1];
-
- return (
-
-
-
-
{latest?.message}
-
- {formatElapsed(Math.max(0, now - taskStartedAt))}
-
-
-
- );
-}
diff --git a/apps/merge/src/hooks/osm.ts b/apps/merge/src/hooks/osm.ts
index c1c7ba0b..ec6455b3 100644
--- a/apps/merge/src/hooks/osm.ts
+++ b/apps/merge/src/hooks/osm.ts
@@ -10,6 +10,7 @@ import type {
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { getBrowserLoadCapabilities } from "../lib/browser-capabilities";
+import { prepareMergedOsmState } from "../lib/merged-osm-state";
import { describeOsmLoadFailure, type OsmLoadFailureContext } from "../lib/osm-load-failure";
import { ensureOsmPbfDownloadName } from "../lib/osm-pbf-download-name";
import { showSaveFilePickerWithFallback } from "../lib/save-file-picker";
@@ -141,6 +142,7 @@ export function useOsmFile(osmKey: string) {
setFile(file);
sourceUrlRef.current = null;
setOsm(null);
+ setOsmInfo(null);
setFileInfo(null);
setIsStored(false);
setLoadFailure(null);
@@ -566,56 +568,35 @@ export function useOsmFile(osmKey: string) {
* If the content hasn't changed (same content hash as original), keeps original file info.
*/
const setMergedOsm = useEffectEvent(async (newOsmId: string, mergedFileName?: string) => {
- // Get the new Osm instance from the worker
- const newOsm = await osmWorker.get(newOsmId);
- const newOsmInfo = newOsm.info();
+ const prepared = await prepareMergedOsmState({
+ currentFileInfo: fileInfo,
+ currentOsm: osm,
+ mergedFileName,
+ newOsmId,
+ worker: osmWorker,
+ });
// Check if anything actually changed using isEqual
- if (newOsm.isEqual(osm) && fileInfo) {
+ if (prepared.kind === "unchanged") {
// No changes - keep the original file info and stored state
- setOsm(newOsm);
- setOsmInfo(newOsmInfo);
- setSelectedOsm(newOsm);
+ setOsm(prepared.osm);
+ setOsmInfo(prepared.osmInfo);
+ setSelectedOsm(prepared.osm);
setLoadFailure(null);
- return newOsm;
+ return prepared.osm;
}
- // Generate a new file name based on merge context (fallback to timestamp)
- const timestamp = new Date().toISOString().slice(0, 19).replace(/[:]/g, "-");
- const newFileName = mergedFileName ?? `osmix-merged-${timestamp}.pbf`;
-
- // File size is estimated from entity counts (will be accurate after serialization)
- const estimatedSize =
- newOsmInfo.stats.nodes * 20 + newOsmInfo.stats.ways * 100 + newOsmInfo.stats.relations * 200;
-
- // Use content hash as the new ID to keep worker ID and storage key in sync
- const newFileHash = newOsm.contentHash();
- const newFileInfo: StoredFileInfo = {
- fileHash: newFileHash,
- fileName: newFileName,
- fileSize: estimatedSize,
- };
-
- // Re-register the Osm in the worker under the new fileHash so that
- // downloadOsm, storeCurrentOsm, and other calls that use osmInfo.id
- // will find the correct worker entry after saving/loading from storage.
- if (newOsmId !== newFileHash) {
- await osmWorker.rename(newOsmId, newFileHash);
- }
-
- // Update osmInfo.id to match the new fileHash (worker registration key)
- const updatedOsmInfo = { ...newOsmInfo, id: newFileHash };
-
- // Update all state
+ // The helper has refreshed the content-addressed worker instance and metadata.
+ sourceUrlRef.current = null;
setFile(null); // No actual File object for merged results
- setFileInfo(newFileInfo);
- setOsm(newOsm);
- setOsmInfo(updatedOsmInfo);
+ setFileInfo(prepared.fileInfo);
+ setOsm(prepared.osm);
+ setOsmInfo(prepared.osmInfo);
setIsStored(false); // New file, not stored yet
- setSelectedOsm(newOsm);
+ setSelectedOsm(prepared.osm);
setLoadFailure(null);
- return newOsm;
+ return prepared.osm;
});
const clearLoadFailure = useEffectEvent(() => setLoadFailure(null));
diff --git a/apps/merge/src/lib/conflation-workflow.ts b/apps/merge/src/lib/conflation-workflow.ts
new file mode 100644
index 00000000..bb5d5add
--- /dev/null
+++ b/apps/merge/src/lib/conflation-workflow.ts
@@ -0,0 +1,106 @@
+import type { OsmConflationBulkAction, OsmConflationOptions } from "osmix";
+
+export interface ConflationFormState {
+ enabled: boolean;
+ transferProperties: boolean;
+ propertyKeys: string;
+ attachNetwork: boolean;
+ maxDistanceMeters: number;
+}
+
+// These node-level accessibility tags produced useful Yakima matches without the
+// thousands of unmatched way candidates introduced by broad surface/geometry keys.
+export const DEFAULT_CONFLATION_PROPERTY_KEYS = [
+ "barrier",
+ "crossing",
+ "kerb",
+ "tactile_paving",
+] as const;
+
+export const DEFAULT_CONFLATION_FORM_STATE: ConflationFormState = {
+ enabled: false,
+ transferProperties: true,
+ propertyKeys: DEFAULT_CONFLATION_PROPERTY_KEYS.join(", "),
+ attachNetwork: false,
+ maxDistanceMeters: 1,
+};
+
+/** Parse a comma- or whitespace-separated tag-key field into stable unique keys. */
+export function parseConflationPropertyKeys(value: string): string[] {
+ return [
+ ...new Set(
+ value
+ .split(/[\s,]+/)
+ .map((key) => key.trim())
+ .filter(Boolean),
+ ),
+ ].sort();
+}
+
+/** Return the first configuration problem that must be resolved before discovery. */
+export function validateConflationForm(state: ConflationFormState): string | null {
+ if (!state.enabled) return null;
+ if (!Number.isFinite(state.maxDistanceMeters) || state.maxDistanceMeters <= 0) {
+ return "Match distance must be greater than zero.";
+ }
+ if (!state.transferProperties && !state.attachNetwork) {
+ return "Enable property transfer, network attachment, or both.";
+ }
+ if (state.transferProperties && parseConflationPropertyKeys(state.propertyKeys).length === 0) {
+ return "Enter at least one property key to transfer.";
+ }
+ return null;
+}
+
+/** Convert the opt-in form into deterministic worker options. */
+export function toOsmConflationOptions(
+ state: ConflationFormState,
+): OsmConflationOptions | undefined {
+ if (!state.enabled) return undefined;
+ const validationMessage = validateConflationForm(state);
+ if (validationMessage) throw new Error(validationMessage);
+ return {
+ propertyKeys: state.transferProperties ? parseConflationPropertyKeys(state.propertyKeys) : [],
+ attachNetwork: state.attachNetwork,
+ maxDistanceMeters: state.maxDistanceMeters,
+ automatic: "high-confidence",
+ };
+}
+
+export interface ConflationBulkActionCopy {
+ buttonLabel: string;
+ confirmLabel: string;
+ description: string;
+ title: string;
+}
+
+/** Keep filter-wide action wording consistent between the toolbar and confirmation dialog. */
+export function conflationBulkActionCopy(
+ action: OsmConflationBulkAction,
+): ConflationBulkActionCopy {
+ if (action === "transfer-properties") {
+ return {
+ buttonLabel: "Transfer properties",
+ confirmLabel: "Transfer properties",
+ description:
+ "Transfer the selected patch properties to every eligible base match in the current filters.",
+ title: "Transfer properties to filtered matches?",
+ };
+ }
+ if (action === "attach-network") {
+ return {
+ buttonLabel: "Attach network",
+ confirmLabel: "Attach network",
+ description:
+ "Attach imported way references to every eligible base match in the current filters.",
+ title: "Attach the filtered imported network?",
+ };
+ }
+ return {
+ buttonLabel: "Reject filtered",
+ confirmLabel: "Reject filtered matches",
+ description:
+ "Reject every filtered match that is not already rejected, including blocked and unmatched rows.",
+ title: "Reject all filtered matches?",
+ };
+}
diff --git a/apps/merge/src/lib/merge-workflow.ts b/apps/merge/src/lib/merge-workflow.ts
new file mode 100644
index 00000000..0edec5c1
--- /dev/null
+++ b/apps/merge/src/lib/merge-workflow.ts
@@ -0,0 +1,186 @@
+import type {
+ OsmChangesetStats,
+ OsmConflationGenerationResult,
+ OsmConflationOptions,
+ OsmConflationSummary,
+ OsmMergeOptions,
+} from "osmix";
+
+export type ChangesetReviewPurpose = "apply" | "diagnostic" | "preview";
+
+/**
+ * A same-dataset comparison may surface suspicious entities for review, but its
+ * proposed edits must never be applied automatically.
+ */
+export const WITHIN_DATASET_DIAGNOSTIC_OPTIONS = {
+ deduplicateNodes: true,
+ deduplicateWays: true,
+} as const satisfies Partial
;
+
+/** Reconcile entities from the patch against the base without normalizing either input. */
+export const CROSS_DATASET_RECONCILIATION_OPTIONS = {
+ deduplicateNodes: true,
+ deduplicateWays: true,
+} as const satisfies Partial;
+
+export const DIRECT_MERGE_OPTIONS = {
+ directMerge: true,
+} as const satisfies Partial;
+
+/** Build a verified base merge from the untouched base and patch. */
+export function verifiedBaseMergeOptions(reconcile: boolean): Partial {
+ return {
+ ...DIRECT_MERGE_OPTIONS,
+ ...(reconcile ? CROSS_DATASET_RECONCILIATION_OPTIONS : {}),
+ };
+}
+
+export const INTERSECTION_OPTIONS = {
+ createIntersections: true,
+} as const satisfies Partial;
+
+/** Options shared by the non-interactive, high-level merge workflow. */
+export const COMPLETE_MERGE_OPTIONS = {
+ ...verifiedBaseMergeOptions(true),
+ ...INTERSECTION_OPTIONS,
+} as const satisfies Partial;
+
+/** Add explicit fuzzy conflation without changing the exact-only default object. */
+export function completeMergeOptions(conflation?: OsmConflationOptions): Partial {
+ return conflation ? { ...COMPLETE_MERGE_OPTIONS, conflation } : COMPLETE_MERGE_OPTIONS;
+}
+
+/** Build the cumulative direct, exact, and reviewed-fuzzy verified merge options. */
+export function verifiedConflationMergeOptions(
+ reconcile: boolean,
+ conflation: OsmConflationOptions,
+): Partial {
+ return {
+ ...verifiedBaseMergeOptions(reconcile),
+ conflation,
+ };
+}
+
+interface ConflationRunAllWorker {
+ discoverConflation(
+ baseOsmId: string,
+ patchOsmId: string,
+ options: OsmConflationOptions,
+ ): Promise;
+ generateConflationChangeset(
+ baseOsmId: string,
+ options: Partial,
+ ): Promise;
+ generateChangeset(
+ baseOsmId: string,
+ patchOsmId: string,
+ options: Partial,
+ ): Promise;
+ applyChangesAndReplace(osmId: string): Promise;
+}
+
+interface RunConflationAllStepsOptions {
+ baseOsmId: string;
+ conflation: OsmConflationOptions;
+ isCancelled: () => boolean;
+ onBaseApplied?: () => void;
+ onDiscovered?: (summary: OsmConflationSummary) => void;
+ onGenerated?: (result: OsmConflationGenerationResult) => void;
+ onStageChange?: (stage: ConflationRunAllStage) => void;
+ patchOsmId: string;
+ worker: ConflationRunAllWorker;
+}
+
+export type ConflationRunAllStage =
+ | "apply-verified-merge"
+ | "create-intersections"
+ | "discover-imported-data"
+ | "generate-verified-merge";
+
+export type RunConflationAllStepsResult =
+ | {
+ generation: OsmConflationGenerationResult | null;
+ status: "cancelled";
+ summary: OsmConflationSummary;
+ }
+ | {
+ generation: OsmConflationGenerationResult;
+ intersections: OsmChangesetStats;
+ status: "completed";
+ summary: OsmConflationSummary;
+ };
+
+/**
+ * Run explicit conflation from untouched inputs, then create intersections on the applied result.
+ *
+ * Cancellation is honored until the first apply. Once the base changes, the intersection stage is
+ * completed before returning so callers never expose an incomplete result as a successful merge.
+ */
+export async function runConflationAllSteps({
+ baseOsmId,
+ conflation,
+ isCancelled,
+ onBaseApplied,
+ onDiscovered,
+ onGenerated,
+ onStageChange,
+ patchOsmId,
+ worker,
+}: RunConflationAllStepsOptions): Promise {
+ onStageChange?.("discover-imported-data");
+ const summary = await worker.discoverConflation(baseOsmId, patchOsmId, conflation);
+ onDiscovered?.(summary);
+ if (isCancelled()) return { generation: null, status: "cancelled", summary };
+
+ onStageChange?.("generate-verified-merge");
+ const generation = await worker.generateConflationChangeset(
+ baseOsmId,
+ verifiedBaseMergeOptions(true),
+ );
+ onGenerated?.(generation);
+ if (isCancelled()) return { generation, status: "cancelled", summary };
+
+ // This is the first irreversible stage. After it succeeds, finish or explicitly
+ // expose the intersection retry state instead of pretending cancellation rolled back.
+ onStageChange?.("apply-verified-merge");
+ await worker.applyChangesAndReplace(generation.stats.osmId);
+ onBaseApplied?.();
+
+ onStageChange?.("create-intersections");
+ const intersections = await worker.generateChangeset(baseOsmId, patchOsmId, INTERSECTION_OPTIONS);
+ await worker.applyChangesAndReplace(intersections.osmId);
+
+ return { generation, intersections, status: "completed", summary };
+}
+
+/**
+ * Restore any available candidate state, then leave the progress-only screen after a failed run.
+ * Showing the review in a `finally` block keeps discovery failures themselves retryable.
+ */
+export async function recoverConflationRunAllFailure({
+ restoreReview,
+ showReview,
+}: {
+ restoreReview?: () => Promise;
+ showReview: () => void;
+}): Promise<{ error: unknown } | null> {
+ let restoreFailure: { error: unknown } | null = null;
+ try {
+ await restoreReview?.();
+ } catch (error) {
+ restoreFailure = { error };
+ } finally {
+ showReview();
+ }
+ return restoreFailure;
+}
+
+export function canApplyChangeset(purpose: ChangesetReviewPurpose): boolean {
+ return purpose === "apply";
+}
+
+/** Clear the patch overlay before showing the verified merged result. */
+export function finalizeVerifiedMerge(clearPatch: () => void, showFinalResult: () => void): void {
+ clearPatch();
+ showFinalResult();
+}
diff --git a/apps/merge/src/lib/merged-osm-state.ts b/apps/merge/src/lib/merged-osm-state.ts
new file mode 100644
index 00000000..b73b1a99
--- /dev/null
+++ b/apps/merge/src/lib/merged-osm-state.ts
@@ -0,0 +1,78 @@
+import type { Osm, OsmInfo } from "osmix";
+
+import type { StoredFileInfo } from "../workers/osm.worker";
+
+interface MergedOsmWorker {
+ get(osmId: string): Promise;
+ rename(fromId: string, toId: string): Promise;
+}
+
+interface PrepareMergedOsmStateOptions {
+ currentOsm: Osm | null;
+ currentFileInfo: StoredFileInfo | null;
+ mergedFileName?: string;
+ newOsmId: string;
+ now?: Date;
+ worker: MergedOsmWorker;
+}
+
+export type PreparedMergedOsmState =
+ | {
+ kind: "unchanged";
+ osm: Osm;
+ osmInfo: OsmInfo;
+ }
+ | {
+ fileInfo: StoredFileInfo;
+ kind: "changed";
+ osm: Osm;
+ osmInfo: OsmInfo;
+ };
+
+/**
+ * Resolve a merged dataset to its content-addressed worker ID and refreshed metadata.
+ *
+ * Renaming a worker dataset re-registers it as a new `Osm` instance. The post-rename
+ * lookup is required so callers never retain an object whose ID has been removed from
+ * the worker registry.
+ */
+export async function prepareMergedOsmState({
+ currentOsm,
+ currentFileInfo,
+ mergedFileName,
+ newOsmId,
+ now = new Date(),
+ worker,
+}: PrepareMergedOsmStateOptions): Promise {
+ let mergedOsm = await worker.get(newOsmId);
+ const initialInfo = mergedOsm.info();
+
+ if (mergedOsm.isEqual(currentOsm) && currentFileInfo) {
+ return { kind: "unchanged", osm: mergedOsm, osmInfo: initialInfo };
+ }
+
+ const contentHash = mergedOsm.contentHash();
+ if (newOsmId !== contentHash) {
+ await worker.rename(newOsmId, contentHash);
+ mergedOsm = await worker.get(contentHash);
+ }
+
+ const refreshedInfo = mergedOsm.info();
+ const timestamp = now.toISOString().slice(0, 19).replace(/[:]/g, "-");
+ const fileName = mergedFileName ?? `osmix-merged-${timestamp}.pbf`;
+ const fileInfo: StoredFileInfo = {
+ fileHash: contentHash,
+ fileName,
+ fileSize:
+ refreshedInfo.stats.nodes * 20 +
+ refreshedInfo.stats.ways * 100 +
+ refreshedInfo.stats.relations * 200,
+ };
+
+ return {
+ fileInfo,
+ kind: "changed",
+ osm: mergedOsm,
+ osmInfo: { ...refreshedInfo, id: contentHash },
+ };
+}
diff --git a/apps/merge/src/pages/merge.tsx b/apps/merge/src/pages/merge.tsx
index 02fd1833..4d966608 100644
--- a/apps/merge/src/pages/merge.tsx
+++ b/apps/merge/src/pages/merge.tsx
@@ -8,13 +8,13 @@ import ExtractBlock from "../blocks/extract";
import InspectBlock from "../blocks/inspect";
import MergeBlock from "../blocks/merge";
import Basemap, { type MapInitialViewState } from "../components/basemap";
+import { ConflationComparisonLayer } from "../components/conflation-comparison-layer";
import CustomControl from "../components/custom-control";
import EntityDetailsMapControl from "../components/entity-details-map-control";
import ExtractMapLayers from "../components/extract-map-layers";
import { Main, MapContent, Sidebar } from "../components/layout";
import OsmFileMapControl from "../components/osm-file-map-control";
-import OsmixRasterSource from "../components/osmix-raster-source";
-import OsmixVectorOverlay from "../components/osmix-vector-overlay";
+import { OsmixMapSources } from "../components/osmix-map-sources";
import SelectedEntityLayer from "../components/selected-entity-layer";
import SidebarLog from "../components/sidebar-log";
import { buttonVariants } from "../components/ui/button";
@@ -186,19 +186,16 @@ export default function Merge() {
- {base.osm && }
- {patch.osm && }
- {base.osm && }
- {patch.osm && }
- {activeTab === "Extract" && extract.osm ? (
- <>
-
-
- >
- ) : null}
+
{activeTab === "Extract" ? : null}
+ {activeTab === "Merge" ? : null}
({
+ ...DEFAULT_CONFLATION_FORM_STATE,
+});
+
+export const conflationComparisonAtom = atom({
+ type: "FeatureCollection",
+ features: [],
+});
+
+export const conflationSummaryAtom = atom(null);
+export const conflationCandidatePageAtom = atom(null);
+export const conflationCandidatePageIndexAtom = atom(0);
+export const conflationCandidateFilterAtom = atom({});
+export const conflationDecisionsAtom = atom([]);
+export const conflationRoutingDiagnosticsAtom = atom(null);
+
+export const resetConflationReviewAtom = atom(null, (_get, set) => {
+ set(conflationSummaryAtom, null);
+ set(conflationCandidatePageAtom, null);
+ set(conflationCandidatePageIndexAtom, 0);
+ set(conflationCandidateFilterAtom, {});
+ set(conflationDecisionsAtom, []);
+ set(conflationRoutingDiagnosticsAtom, null);
+ set(conflationComparisonAtom, { type: "FeatureCollection", features: [] });
+});
diff --git a/apps/merge/tests/automatic-merge-progress.test.ts b/apps/merge/tests/automatic-merge-progress.test.ts
new file mode 100644
index 00000000..ae70f616
--- /dev/null
+++ b/apps/merge/tests/automatic-merge-progress.test.ts
@@ -0,0 +1,47 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import {
+ AutomaticMergeProgress,
+ CONFLATION_AUTOMATIC_MERGE_STEPS,
+ EXACT_AUTOMATIC_MERGE_STEPS,
+} from "../src/components/automatic-merge-progress";
+
+describe("automatic merge progress", () => {
+ it("marks earlier exact steps complete and the active step as running", () => {
+ const html = renderToStaticMarkup(
+ createElement(AutomaticMergeProgress, {
+ currentStepId: "refresh-result",
+ steps: EXACT_AUTOMATIC_MERGE_STEPS,
+ }),
+ );
+
+ expect(html).toContain('aria-label="Automatic merge progress"');
+ expect(html).toContain('data-status="completed"');
+ expect(html).toContain('aria-current="step"');
+ expect(html).toContain("Merge, reconcile, and create intersections");
+ expect(html).toContain("Refresh merged dataset");
+ expect(html).toContain("1 of 2 steps completed");
+ });
+
+ it("distinguishes completed, running, and remaining conflation stages", () => {
+ const html = renderToStaticMarkup(
+ createElement(AutomaticMergeProgress, {
+ currentStepId: "apply-verified-merge",
+ elapsedMs: 582_000,
+ latestMessage: "Applying verified imported-data changes",
+ steps: CONFLATION_AUTOMATIC_MERGE_STEPS,
+ }),
+ );
+
+ expect(html.match(/data-status="completed"/g)).toHaveLength(2);
+ expect(html.match(/data-status="running"/g)).toHaveLength(1);
+ expect(html.match(/data-status="remaining"/g)).toHaveLength(2);
+ expect(html).toContain("Apply verified merge changes is running");
+ expect(html).toContain("2 of 5 steps completed");
+ expect(html).toContain("9:42");
+ expect(html).toContain("Applying verified imported-data changes");
+ expect(html).not.toContain('role="progressbar"');
+ });
+});
diff --git a/apps/merge/tests/conflation-workflow.test.ts b/apps/merge/tests/conflation-workflow.test.ts
new file mode 100644
index 00000000..ab492f8b
--- /dev/null
+++ b/apps/merge/tests/conflation-workflow.test.ts
@@ -0,0 +1,172 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import {
+ ConflationBulkActions,
+ ConflationResultsHeader,
+} from "../src/components/conflation-review";
+import {
+ conflationBulkActionCopy,
+ DEFAULT_CONFLATION_FORM_STATE,
+ DEFAULT_CONFLATION_PROPERTY_KEYS,
+ parseConflationPropertyKeys,
+ toOsmConflationOptions,
+ validateConflationForm,
+} from "../src/lib/conflation-workflow";
+
+describe("conflation workflow configuration", () => {
+ it("keeps fuzzy matching disabled by default", () => {
+ expect(DEFAULT_CONFLATION_FORM_STATE).toEqual({
+ enabled: false,
+ transferProperties: true,
+ propertyKeys: "barrier, crossing, kerb, tactile_paving",
+ attachNetwork: false,
+ maxDistanceMeters: 1,
+ });
+ expect(parseConflationPropertyKeys(DEFAULT_CONFLATION_FORM_STATE.propertyKeys)).toEqual([
+ ...DEFAULT_CONFLATION_PROPERTY_KEYS,
+ ]);
+ expect(validateConflationForm(DEFAULT_CONFLATION_FORM_STATE)).toBeNull();
+ });
+
+ it("normalizes explicit property keys", () => {
+ expect(parseConflationPropertyKeys("name, surface name\noperator")).toEqual([
+ "name",
+ "operator",
+ "surface",
+ ]);
+ });
+
+ it("requires at least one selected operation", () => {
+ expect(
+ validateConflationForm({
+ ...DEFAULT_CONFLATION_FORM_STATE,
+ enabled: true,
+ transferProperties: false,
+ }),
+ ).toBe("Enable property transfer, network attachment, or both.");
+ });
+
+ it("requires explicit property keys when property transfer is enabled", () => {
+ expect(
+ validateConflationForm({
+ ...DEFAULT_CONFLATION_FORM_STATE,
+ enabled: true,
+ propertyKeys: "",
+ }),
+ ).toBe("Enter at least one property key to transfer.");
+ });
+
+ it("accepts network-only matching without property keys", () => {
+ const state = {
+ ...DEFAULT_CONFLATION_FORM_STATE,
+ enabled: true,
+ transferProperties: false,
+ attachNetwork: true,
+ };
+ expect(validateConflationForm(state)).toBeNull();
+ expect(toOsmConflationOptions(state)).toEqual({
+ propertyKeys: [],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence",
+ });
+ });
+
+ it("builds explicit high-confidence property-transfer options", () => {
+ expect(
+ toOsmConflationOptions({
+ ...DEFAULT_CONFLATION_FORM_STATE,
+ enabled: true,
+ propertyKeys: "operator, name operator",
+ }),
+ ).toEqual({
+ propertyKeys: ["name", "operator"],
+ attachNetwork: false,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence",
+ });
+ });
+
+ it("rejects invalid match distances", () => {
+ expect(
+ validateConflationForm({
+ ...DEFAULT_CONFLATION_FORM_STATE,
+ enabled: true,
+ maxDistanceMeters: 0,
+ }),
+ ).toBe("Match distance must be greater than zero.");
+ });
+
+ it("uses action-specific labels and explicit filter-wide confirmation wording", () => {
+ expect(conflationBulkActionCopy("transfer-properties")).toMatchObject({
+ buttonLabel: "Transfer properties",
+ title: "Transfer properties to filtered matches?",
+ });
+ expect(conflationBulkActionCopy("attach-network")).toMatchObject({
+ buttonLabel: "Attach network",
+ title: "Attach the filtered imported network?",
+ });
+ expect(conflationBulkActionCopy("reject")).toEqual({
+ buttonLabel: "Reject filtered",
+ confirmLabel: "Reject filtered matches",
+ description:
+ "Reject every filtered match that is not already rejected, including blocked and unmatched rows.",
+ title: "Reject all filtered matches?",
+ });
+ });
+
+ it("renders filter-wide counts and disables actions with no decisions to change", () => {
+ const preview = {
+ action: "transfer-properties" as const,
+ filteredCandidates: 145,
+ eligibleCandidates: 145,
+ changedCandidates: 145,
+ skippedCandidates: 0,
+ automaticCandidates: 145,
+ reviewCandidates: 0,
+ overriddenDecisions: 0,
+ };
+ const html = renderToStaticMarkup(
+ createElement(ConflationBulkActions, {
+ bulkActions: {
+ "transfer-properties": preview,
+ "attach-network": {
+ ...preview,
+ action: "attach-network",
+ changedCandidates: 12,
+ },
+ reject: {
+ ...preview,
+ action: "reject",
+ changedCandidates: 0,
+ },
+ },
+ filter: { status: "automatic" },
+ onBulkDecision: async () => {},
+ }),
+ );
+
+ expect(html).toContain("Bulk decisions");
+ expect(html).toContain('aria-label="About bulk decisions"');
+ expect(html).not.toContain("every match in the current filters across all pages");
+ expect(html).toContain("Transfer properties (145)");
+ expect(html).toContain("Attach network (12)");
+ expect(html).toMatch(/]*disabled=""[^>]*>Reject filtered \(0\)<\/button>/);
+ });
+
+ it("marks previous filtered results stale while the worker refreshes them", () => {
+ const html = renderToStaticMarkup(
+ createElement(ConflationResultsHeader, {
+ isFilterPending: true,
+ totalCandidates: 987_654,
+ }),
+ );
+
+ expect(html).toContain("Filtered matches (987,654, stale)");
+ expect(html).toContain("Updating filters…");
+ expect(html).toContain('role="status"');
+ expect(html).toContain('aria-live="polite"');
+ });
+});
diff --git a/apps/merge/tests/merge-inline-help.test.ts b/apps/merge/tests/merge-inline-help.test.ts
new file mode 100644
index 00000000..8e778426
--- /dev/null
+++ b/apps/merge/tests/merge-inline-help.test.ts
@@ -0,0 +1,179 @@
+import { createStore, Provider } from "jotai";
+import type { OsmConflationCandidateView, OsmConflationRoutingDiagnostics } from "osmix";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("../src/state/worker", () => ({
+ osmWorker: {
+ getChangesetPage: vi.fn(),
+ setChangesetFilters: vi.fn(),
+ },
+}));
+
+import { ConflationConfig } from "../src/components/conflation-config";
+import {
+ CandidateActionStatuses,
+ CandidateActions,
+ CandidateEvidence,
+ conflationCandidateTitle,
+ conflationReasonLabel,
+ ConflationStatusLegend,
+} from "../src/components/conflation-review";
+import { ConflationRoutingDiagnostics } from "../src/components/conflation-routing-diagnostics";
+import ChangesSummary, { ChangesFilters } from "../src/components/osm-changes-summary";
+import { changesetStatsAtom } from "../src/state/changes";
+import { conflationFormAtom } from "../src/state/conflation";
+
+const CANDIDATE: OsmConflationCandidateView = {
+ id: "node:11:22",
+ entityType: "node",
+ sourceId: 11,
+ targetId: 22,
+ status: "review",
+ reasons: ["routing-property"],
+ propertyTransfer: { status: "review", reasons: ["routing-property"] },
+ networkAttachment: { status: "automatic", reasons: [] },
+ evidence: {
+ distanceMeters: 0.25,
+ sourceRoutingFamilies: ["pedestrian"],
+ targetRoutingFamilies: ["bicycle-shared"],
+ tagDiff: [
+ {
+ key: "crossing",
+ baseValue: "unmarked",
+ patchValue: "marked",
+ protected: false,
+ routing: false,
+ },
+ ],
+ bearingDifferenceDegrees: 4,
+ lengthDifferenceRatio: 0.02,
+ maxGeometryDistanceMeters: 0.4,
+ },
+};
+
+function renderWithStore(
+ element: React.ReactNode,
+ configure: (store: ReturnType) => void,
+) {
+ const store = createStore();
+ configure(store);
+ return renderToStaticMarkup(createElement(Provider, { store }, element));
+}
+
+describe("merge inline guidance", () => {
+ it("keeps proximity labels visible and moves detailed help into info tooltips", () => {
+ const html = renderWithStore(createElement(ConflationConfig), (store) => {
+ store.set(conflationFormAtom, {
+ enabled: true,
+ transferProperties: true,
+ propertyKeys: "barrier, crossing, kerb, tactile_paving",
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ });
+ });
+
+ expect(html).toContain("OSM tag keys to transfer");
+ expect(html).toContain("Candidate search radius (meters)");
+ expect(html).toContain('aria-label="About proximity matching"');
+ expect(html).toContain('aria-label="About property transfer"');
+ expect(html).toContain('aria-label="About transferable OSM tags"');
+ expect(html).toContain('aria-label="About network attachment"');
+ expect(html).toContain('aria-label="About candidate search radius"');
+ expect(html).toContain('aria-label="About automatic matching decisions"');
+ expect(html).not.toContain("Distance alone never guarantees acceptance");
+ expect(html).not.toContain("routing-affecting tags require review");
+ expect(html).not.toContain("equivalent one-to-one imported");
+ expect(html).not.toContain("tagless nodes");
+ expect(html).not.toContain("referenced by any way or relation");
+ });
+
+ it("humanizes candidate statuses, reasons, evidence, and actions", () => {
+ const legend = renderToStaticMarkup(createElement(ConflationStatusLegend));
+ const evidence = renderToStaticMarkup(
+ createElement(CandidateEvidence, { candidate: CANDIDATE }),
+ );
+ const actions = renderToStaticMarkup(
+ createElement(CandidateActions, { candidate: CANDIDATE, onDecision: async () => {} }),
+ );
+ const actionStatuses = renderToStaticMarkup(
+ createElement(CandidateActionStatuses, { candidate: CANDIDATE }),
+ );
+
+ expect(legend).toContain('aria-label="About candidate statuses"');
+ expect(legend).not.toContain("at least one action needs a decision");
+ expect(conflationReasonLabel("would-collapse-way")).toBe("Attachment would collapse a way");
+ expect(conflationCandidateTitle(CANDIDATE)).toBe("Imported node 11 → Base node 22");
+ expect(evidence).toContain('aria-label="About candidate evidence metrics"');
+ expect(evidence).not.toContain("Distance finds nearby candidates");
+ expect(evidence).toContain("Imported routing family");
+ expect(evidence).toContain("Base routing family");
+ expect(evidence).toContain("Property");
+ expect(evidence).toContain("Base value");
+ expect(evidence).toContain("Imported value");
+ expect(actions).toContain("Transfer + attach");
+ expect(actionStatuses).toContain("Property transfer");
+ expect(actionStatuses).toContain("Needs review");
+ expect(actionStatuses).toContain("Network attachment");
+ expect(actionStatuses).toContain("Automatic");
+
+ const wayStatuses = renderToStaticMarkup(
+ createElement(CandidateActionStatuses, {
+ candidate: { ...CANDIDATE, entityType: "way", networkAttachment: null },
+ }),
+ );
+ expect(wayStatuses).not.toContain("Network attachment");
+ });
+
+ it("defines the routing baseline, metrics, signed deltas, and mode invariants", () => {
+ const mode = {
+ before: { components: 2, edges: 2, nodes: 3, routableNodes: 3 },
+ after: { components: 1, edges: 4, nodes: 4, routableNodes: 4 },
+ delta: { components: -1, edges: 2, nodes: 1, routableNodes: 1 },
+ };
+ const diagnostics: OsmConflationRoutingDiagnostics = { car: mode, walk: mode };
+ const html = renderToStaticMarkup(createElement(ConflationRoutingDiagnostics, { diagnostics }));
+
+ expect(html).toContain("including exact reconciliation when selected");
+ expect(html).toContain("Routable nodes");
+ expect(html).toContain("Directed edges");
+ expect(html).toContain("Connected components");
+ expect(html).toContain("weakly connected groups");
+ expect(html).toContain("does not guarantee travel in both directions");
+ expect(html).toContain("Signed delta");
+ expect(html).toContain(">+2<");
+ expect(html).toContain("walk-only attachment should not change CAR topology");
+ expect(html).toContain("do not prove that routing is correct");
+ });
+
+ it("shows reconciliation and intersection statistics with labeled filter groups", () => {
+ const html = renderWithStore(
+ createElement("div", null, createElement(ChangesSummary), createElement(ChangesFilters)),
+ (store) => {
+ store.set(changesetStatsAtom, {
+ osmId: "merged",
+ totalChanges: 25,
+ nodeChanges: 10,
+ wayChanges: 9,
+ relationChanges: 6,
+ deduplicatedNodes: 3,
+ deduplicatedNodesReplaced: 7,
+ deduplicatedWays: 2,
+ intersectionPointsFound: 5,
+ intersectionNodesCreated: 4,
+ });
+ },
+ );
+
+ expect(html).toContain("Reconciled nodes");
+ expect(html).toContain("Node references rewritten");
+ expect(html).toContain("Reconciled ways");
+ expect(html).toContain("Intersection nodes created");
+ expect(html).toContain("way node references and relation node members changed");
+ expect(html).toContain("one surviving entity");
+ expect(html).toContain(" {
+ it("defines detailed guidance for every workflow and review variant", () => {
+ expect(MERGE_STEP_GUIDE_IDS).toEqual(expectedGuideIds);
+ expect(Object.keys(MERGE_STEP_GUIDES)).toEqual(expectedGuideIds);
+
+ for (const guideId of expectedGuideIds) {
+ const guide = MERGE_STEP_GUIDES[guideId];
+ expect(guide.summary.length, `${guideId} summary`).toBeGreaterThan(20);
+ expect(guide.inputs.length, `${guideId} inputs`).toBeGreaterThan(0);
+ expect(guide.mutations.length, `${guideId} mutations`).toBeGreaterThan(0);
+ expect(guide.invariants.length, `${guideId} safety guarantees`).toBeGreaterThan(0);
+ expect(guide.output.length, `${guideId} output`).toBeGreaterThan(20);
+ expect("diagram" in guide ? guide.diagram : undefined).toBe(expectedDiagrams[guideId]);
+ }
+ });
+
+ it("renders the short summary and a closed detailed disclosure", () => {
+ const html = renderToStaticMarkup(createElement(MergeStepGuide, { guideId: "select" }));
+
+ expect(html).toContain('data-slot="merge-step-guide"');
+ expect(html).toContain('data-guide-id="select"');
+ expect(html).toContain('data-slot="merge-step-guide-summary"');
+ expect(html).toContain(MERGE_STEP_GUIDES.select.summary);
+ expect(html).toContain("How this step works");
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).not.toContain('data-slot="merge-step-guide-details"');
+ });
+
+ it("keeps the exact-off review free of exact-reconciliation behavior", () => {
+ const guide = MERGE_STEP_GUIDES["review-cumulative-without-exact"];
+ const copy = [
+ guide.summary,
+ ...guide.inputs,
+ ...guide.mutations,
+ ...guide.invariants,
+ guide.output,
+ ].join(" ");
+
+ expect(copy).not.toMatch(/exact[- ]reconcil/i);
+ expect(copy).not.toContain("reconciled references");
+ expect(guide.diagram).toBe("direct-merge");
+ });
+
+ it("renders semantic level-three headings for every detailed section", () => {
+ const html = renderToStaticMarkup(
+ createElement(MergeStepGuide, { defaultOpen: true, guideId: "run-all" }),
+ );
+
+ expect(html.match(/role="heading" aria-level="3"/g)).toHaveLength(5);
+ for (const heading of ["Inputs", "What can change", "Safety guarantees", "Output", "Caution"]) {
+ expect(html).toContain(heading);
+ }
+ });
+
+ it.each(MERGE_GUIDE_DIAGRAM_IDS)("renders an accessible, responsive %s diagram", (diagram) => {
+ const html = renderToStaticMarkup(createElement(MergeGuideDiagram, { diagram }));
+ const fontSizes = [...html.matchAll(/font-size="([0-9]+)"/g)].map((match) => Number(match[1]));
+
+ expect(html).toContain('role="img"');
+ expect(html).toContain(`data-diagram="${diagram}"`);
+ expect(html).toContain('viewBox="0 0 240 300"');
+ expect(html).toContain('class="h-auto w-full max-w-full"');
+ expect(html).toMatch(/aria-labelledby="[^"]+ [^"]+"/);
+ expect(html).toMatch(/[^<]+<\/title>/);
+ expect(html).toMatch(/[^<]+<\/desc>/);
+ expect(Math.min(...fontSizes)).toBeGreaterThanOrEqual(10);
+ expect(html).not.toContain("foreignObject");
+ });
+
+ it("uses Base UI open-state styling and hides the decorative chevron", () => {
+ const html = renderToStaticMarkup(
+ createElement(
+ Details,
+ { defaultOpen: true },
+ createElement(DetailsSummary, null, "Technical details"),
+ createElement(DetailsContent, null, "Expanded content"),
+ ),
+ );
+
+ expect(html).toContain("data-panel-open:shadow-sm");
+ expect(html).toContain("group-data-panel-open:rotate-180");
+ expect(html).toMatch(/]*aria-hidden="true"/);
+ expect(html).toContain('aria-expanded="true"');
+ expect(html).toContain("Expanded content");
+ });
+});
diff --git a/apps/merge/tests/merge-worker-hash.test.ts b/apps/merge/tests/merge-worker-hash.test.ts
index 84e6b12c..7b15bc8f 100644
--- a/apps/merge/tests/merge-worker-hash.test.ts
+++ b/apps/merge/tests/merge-worker-hash.test.ts
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { MergeWorker } from "../src/workers/osm.worker";
+
vi.mock("comlink", async (importOriginal) => ({
...(await importOriginal()),
expose: vi.fn(),
@@ -12,11 +14,8 @@ class MockBroadcastChannel {
}
describe("MergeWorker streaming hashing", () => {
- let MergeWorker: typeof import("../src/workers/osm.worker").MergeWorker;
-
- beforeAll(async () => {
+ beforeAll(() => {
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel);
- ({ MergeWorker } = await import("../src/workers/osm.worker"));
});
afterAll(() => {
diff --git a/apps/merge/tests/merge-workflow.test.ts b/apps/merge/tests/merge-workflow.test.ts
new file mode 100644
index 00000000..a4fa88c3
--- /dev/null
+++ b/apps/merge/tests/merge-workflow.test.ts
@@ -0,0 +1,247 @@
+import type { OsmChangesetStats, OsmConflationGenerationResult, OsmConflationSummary } from "osmix";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ canApplyChangeset,
+ COMPLETE_MERGE_OPTIONS,
+ completeMergeOptions,
+ CROSS_DATASET_RECONCILIATION_OPTIONS,
+ finalizeVerifiedMerge,
+ INTERSECTION_OPTIONS,
+ recoverConflationRunAllFailure,
+ runConflationAllSteps,
+ verifiedConflationMergeOptions,
+ verifiedBaseMergeOptions,
+ WITHIN_DATASET_DIAGNOSTIC_OPTIONS,
+} from "../src/lib/merge-workflow";
+
+const changesetStats = (osmId: string, totalChanges: number): OsmChangesetStats => ({
+ deduplicatedNodes: 0,
+ deduplicatedNodesReplaced: 0,
+ deduplicatedWays: 0,
+ intersectionNodesCreated: 0,
+ intersectionPointsFound: 0,
+ nodeChanges: totalChanges,
+ osmId,
+ relationChanges: 0,
+ totalChanges,
+ wayChanges: 0,
+});
+
+const summary: OsmConflationSummary = {
+ accepted: 0,
+ automatic: 1,
+ blocked: 0,
+ rejected: 0,
+ review: 1,
+ total: 3,
+ unmatched: 1,
+};
+
+const generation: OsmConflationGenerationResult = {
+ stats: changesetStats("base", 3),
+ routing: {
+ car: {
+ before: { components: 1, edges: 2, nodes: 2, routableNodes: 2 },
+ after: { components: 1, edges: 2, nodes: 2, routableNodes: 2 },
+ delta: { components: 0, edges: 0, nodes: 0, routableNodes: 0 },
+ },
+ walk: {
+ before: { components: 2, edges: 2, nodes: 3, routableNodes: 3 },
+ after: { components: 1, edges: 4, nodes: 4, routableNodes: 4 },
+ delta: { components: -1, edges: 2, nodes: 1, routableNodes: 1 },
+ },
+ },
+};
+
+describe("merge workflow policy", () => {
+ it("keeps within-dataset duplicate scans diagnostic", () => {
+ expect(WITHIN_DATASET_DIAGNOSTIC_OPTIONS).toEqual({
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ });
+ expect(canApplyChangeset("diagnostic")).toBe(false);
+ expect(canApplyChangeset("preview")).toBe(false);
+ });
+
+ it("uses the same cross-dataset reconciliation options in a complete merge", () => {
+ expect(COMPLETE_MERGE_OPTIONS).toMatchObject(CROSS_DATASET_RECONCILIATION_OPTIONS);
+ expect(COMPLETE_MERGE_OPTIONS).toEqual({
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ directMerge: true,
+ createIntersections: true,
+ });
+ expect(canApplyChangeset("apply")).toBe(true);
+ });
+
+ it("keeps exact-only defaults while adding explicitly configured conflation", () => {
+ const conflation = {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence" as const,
+ };
+
+ expect(completeMergeOptions()).toBe(COMPLETE_MERGE_OPTIONS);
+ expect(completeMergeOptions(conflation)).toEqual({
+ ...COMPLETE_MERGE_OPTIONS,
+ conflation,
+ });
+ expect(verifiedConflationMergeOptions(true, conflation)).toEqual({
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ conflation,
+ });
+ });
+
+ it("regenerates the verified base merge from the original inputs", () => {
+ expect(verifiedBaseMergeOptions(false)).toEqual({
+ directMerge: true,
+ });
+ expect(verifiedBaseMergeOptions(true)).toEqual({
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ });
+ expect(INTERSECTION_OPTIONS).toEqual({ createIntersections: true });
+ });
+
+ it("clears the patch before verified final inspection", () => {
+ const transitions: string[] = [];
+
+ finalizeVerifiedMerge(
+ () => transitions.push("clear-patch"),
+ () => transitions.push("show-final"),
+ );
+
+ expect(transitions).toEqual(["clear-patch", "show-final"]);
+ });
+
+ it("runs enabled run-all through session generation before intersections", async () => {
+ const calls: string[] = [];
+ const stages: string[] = [];
+ const intersections = changesetStats("base", 2);
+ const conflation = {
+ propertyKeys: ["name"],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence" as const,
+ };
+ const worker = {
+ discoverConflation: vi.fn(async () => {
+ calls.push("discover");
+ return summary;
+ }),
+ generateConflationChangeset: vi.fn(async () => {
+ calls.push("generate-conflation");
+ return generation;
+ }),
+ applyChangesAndReplace: vi.fn(async () => {
+ calls.push("apply");
+ }),
+ generateChangeset: vi.fn(async () => {
+ calls.push("generate-intersections");
+ return intersections;
+ }),
+ };
+
+ const result = await runConflationAllSteps({
+ baseOsmId: "base",
+ conflation,
+ isCancelled: () => false,
+ onStageChange: (stage) => stages.push(stage),
+ patchOsmId: "patch",
+ worker,
+ });
+
+ expect(result).toEqual({
+ generation,
+ intersections,
+ status: "completed",
+ summary,
+ });
+ expect(calls).toEqual([
+ "discover",
+ "generate-conflation",
+ "apply",
+ "generate-intersections",
+ "apply",
+ ]);
+ expect(stages).toEqual([
+ "discover-imported-data",
+ "generate-verified-merge",
+ "apply-verified-merge",
+ "create-intersections",
+ ]);
+ expect(worker.discoverConflation).toHaveBeenCalledWith("base", "patch", conflation);
+ expect(worker.generateConflationChangeset).toHaveBeenCalledWith(
+ "base",
+ verifiedBaseMergeOptions(true),
+ );
+ expect(worker.generateChangeset).toHaveBeenCalledWith("base", "patch", INTERSECTION_OPTIONS);
+ expect(worker.applyChangesAndReplace).toHaveBeenNthCalledWith(1, "base");
+ expect(worker.applyChangesAndReplace).toHaveBeenNthCalledWith(2, "base");
+ });
+
+ it("cancels enabled run-all before mutating either input", async () => {
+ const worker = {
+ discoverConflation: vi.fn(async () => summary),
+ generateConflationChangeset: vi.fn(async () => generation),
+ applyChangesAndReplace: vi.fn(async () => {}),
+ generateChangeset: vi.fn(async () => changesetStats("base", 0)),
+ };
+
+ const result = await runConflationAllSteps({
+ baseOsmId: "base",
+ conflation: {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ automatic: "high-confidence",
+ },
+ isCancelled: () => true,
+ patchOsmId: "patch",
+ worker,
+ });
+
+ expect(result).toEqual({ generation: null, status: "cancelled", summary });
+ expect(worker.generateConflationChangeset).not.toHaveBeenCalled();
+ expect(worker.applyChangesAndReplace).not.toHaveBeenCalled();
+ expect(worker.generateChangeset).not.toHaveBeenCalled();
+ });
+
+ it("returns failed conflation run-all work to a retryable review screen", async () => {
+ const transitions: string[] = [];
+
+ const restoreError = await recoverConflationRunAllFailure({
+ restoreReview: async () => {
+ transitions.push("restore-candidate-session");
+ },
+ showReview: () => transitions.push("show-match-imported-data"),
+ });
+
+ expect(restoreError).toBeNull();
+ expect(transitions).toEqual(["restore-candidate-session", "show-match-imported-data"]);
+
+ transitions.length = 0;
+ const discoveryFailure = await recoverConflationRunAllFailure({
+ restoreReview: async () => {
+ transitions.push("restore-failed");
+ throw Error("candidate discovery did not create a session");
+ },
+ showReview: () => transitions.push("show-match-imported-data"),
+ });
+
+ expect(discoveryFailure).toEqual({
+ error: Error("candidate discovery did not create a session"),
+ });
+ expect(transitions).toEqual(["restore-failed", "show-match-imported-data"]);
+
+ transitions.length = 0;
+ await recoverConflationRunAllFailure({
+ showReview: () => transitions.push("show-match-imported-data"),
+ });
+ expect(transitions).toEqual(["show-match-imported-data"]);
+ });
+});
diff --git a/apps/merge/tests/merged-osm-state.test.ts b/apps/merge/tests/merged-osm-state.test.ts
new file mode 100644
index 00000000..eb252a9e
--- /dev/null
+++ b/apps/merge/tests/merged-osm-state.test.ts
@@ -0,0 +1,112 @@
+import type { Osm, OsmInfo } from "osmix";
+import { describe, expect, it, vi } from "vitest";
+
+import { prepareMergedOsmState } from "../src/lib/merged-osm-state";
+
+const info = (id: string): OsmInfo => ({
+ bbox: [7.4, 43.7, 7.5, 43.8],
+ header: {},
+ id,
+ spatialIndexes: {
+ nodes: { all: true, tagged: true },
+ ways: true,
+ },
+ stats: { nodes: 10, relations: 2, ways: 3 },
+});
+
+const osm = (id: string, contentHash: string, equal = false) =>
+ ({
+ contentHash: () => contentHash,
+ id,
+ info: () => info(id),
+ isEqual: () => equal,
+ }) as unknown as Osm;
+
+describe("merged OSM state", () => {
+ it("re-fetches a renamed dataset and returns content-addressed metadata", async () => {
+ const beforeRename = osm("base", "merged-hash");
+ const afterRename = osm("merged-hash", "merged-hash");
+ const registry = new Map([["base", beforeRename]]);
+ const get = vi.fn(async (id: string) => {
+ const registered = registry.get(id);
+ if (!registered) throw Error(`Missing OSM ${id}`);
+ return registered;
+ });
+ const rename = vi.fn(async (fromId: string, toId: string) => {
+ if (!registry.delete(fromId)) throw Error(`Missing OSM ${fromId}`);
+ registry.set(toId, afterRename);
+ });
+
+ const result = await prepareMergedOsmState({
+ currentFileInfo: {
+ fileHash: "base",
+ fileName: "base.pbf",
+ fileSize: 1,
+ },
+ currentOsm: osm("base", "base"),
+ mergedFileName: "merged.pbf",
+ newOsmId: "base",
+ worker: { get, rename },
+ });
+
+ expect(rename).toHaveBeenCalledWith("base", "merged-hash");
+ expect(get).toHaveBeenNthCalledWith(1, "base");
+ expect(get).toHaveBeenNthCalledWith(2, "merged-hash");
+ expect(await get(result.osm.id)).toBe(afterRename);
+ expect(registry.has("base")).toBe(false);
+ expect(result).toEqual({
+ fileInfo: {
+ fileHash: "merged-hash",
+ fileName: "merged.pbf",
+ fileSize: 900,
+ },
+ kind: "changed",
+ osm: afterRename,
+ osmInfo: info("merged-hash"),
+ });
+ });
+
+ it("keeps source metadata when the applied changeset does not change content", async () => {
+ const unchanged = osm("base", "base", true);
+ const get = vi.fn<(id: string) => Promise>().mockResolvedValue(unchanged);
+ const rename = vi.fn<(fromId: string, toId: string) => Promise>();
+
+ const result = await prepareMergedOsmState({
+ currentFileInfo: {
+ fileHash: "base",
+ fileName: "base.pbf",
+ fileSize: 1,
+ },
+ currentOsm: osm("base", "base"),
+ newOsmId: "base",
+ worker: { get, rename },
+ });
+
+ expect(result).toEqual({ kind: "unchanged", osm: unchanged, osmInfo: info("base") });
+ expect(rename).not.toHaveBeenCalled();
+ expect(get).toHaveBeenCalledOnce();
+ });
+
+ it("does not rename a dataset that already uses its content hash", async () => {
+ const merged = osm("merged-hash", "merged-hash");
+ const get = vi.fn<(id: string) => Promise>().mockResolvedValue(merged);
+ const rename = vi.fn<(fromId: string, toId: string) => Promise>();
+
+ const result = await prepareMergedOsmState({
+ currentFileInfo: null,
+ currentOsm: null,
+ newOsmId: "merged-hash",
+ now: new Date("2026-07-21T01:02:03Z"),
+ worker: { get, rename },
+ });
+
+ expect(result.kind).toBe("changed");
+ if (result.kind === "changed") {
+ expect(result.fileInfo.fileName).toBe("osmix-merged-2026-07-21T01-02-03.pbf");
+ expect(result.osm.id).toBe("merged-hash");
+ expect(result.osmInfo.id).toBe("merged-hash");
+ }
+ expect(rename).not.toHaveBeenCalled();
+ expect(get).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/merge/tests/osmix-raster-source.test.ts b/apps/merge/tests/osmix-raster-source.test.ts
new file mode 100644
index 00000000..322e6be9
--- /dev/null
+++ b/apps/merge/tests/osmix-raster-source.test.ts
@@ -0,0 +1,48 @@
+import { Osm } from "osmix";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("../src/state/worker", () => ({ osmWorker: {} }));
+
+import { OsmixMapSources } from "../src/components/osmix-map-sources";
+import OsmixRasterSource from "../src/components/osmix-raster-source";
+
+function childKeys(element: ReturnType) {
+ return (element.props.children as React.ReactElement[]).filter(Boolean).map((child) => child.key);
+}
+
+describe("Osmix map sources", () => {
+ it("remounts when a merge replaces the dataset ID", () => {
+ const beforeMerge = OsmixRasterSource({ osmId: "yakima-base", tileSize: 512 });
+ const afterMerge = OsmixRasterSource({ osmId: "yakima-merged", tileSize: 512 });
+
+ expect(beforeMerge.key).toBe(beforeMerge.props.id);
+ expect(afterMerge.key).toBe(afterMerge.props.id);
+ expect(afterMerge.key).not.toBe(beforeMerge.key);
+ });
+
+ it("replaces base and patch source wrappers after a merge", () => {
+ const beforeMerge = OsmixMapSources({
+ activeTab: "Merge",
+ baseOsm: new Osm({ id: "yakima-base" }),
+ extractOsm: null,
+ patchOsm: new Osm({ id: "yakima-osw" }),
+ });
+ const afterMerge = OsmixMapSources({
+ activeTab: "Merge",
+ baseOsm: new Osm({ id: "yakima-merged" }),
+ extractOsm: null,
+ patchOsm: null,
+ });
+
+ expect(childKeys(beforeMerge)).toEqual([
+ "base:raster:yakima-base",
+ "patch:raster:yakima-osw",
+ "base:overlay:yakima-base",
+ "patch:overlay:yakima-osw",
+ ]);
+ expect(childKeys(afterMerge)).toEqual([
+ "base:raster:yakima-merged",
+ "base:overlay:yakima-merged",
+ ]);
+ });
+});
diff --git a/packages/change/README.md b/packages/change/README.md
index ad76e53a..7abc68bd 100644
--- a/packages/change/README.md
+++ b/packages/change/README.md
@@ -6,7 +6,7 @@
- Construct repeatable `OsmChangeset`s that track creates, modifies, and deletes with origin metadata and per-entity refs.
- **Augmented diffs**: Automatically captures both old and new entity states for modifications and deletions, following the [Overpass API Augmented Diffs](https://wiki.openstreetmap.org/wiki/Overpass_API/Augmented_Diffs) format.
-- Deduplicate coincident nodes or overlapping ways, replace references, and optionally create intersection points where geometry meets.
+- Conservatively reconcile compatible nodes or overlapping ways, replace references, and optionally create intersection points where geometry meets.
- Generate summary stats and OSC-friendly XML fragments so downstream systems can audit each change step.
- Run `merge(base, patch, options)` to execute the full dedupe/merge workflow with a single call.
- Export lightweight utilities for measuring distances, pruning duplicate refs, and deciding when ways should connect.
@@ -30,8 +30,6 @@ const base = await fromPbf(monacoPbf);
const patch = await fromPbf(patchPbf);
const changeset = new OsmChangeset(base);
-changeset.deduplicateNodes(base.nodes);
-changeset.deduplicateWays(base.ways);
changeset.generateDirectChanges(patch);
console.log(changeStatsSummary(changeset.stats));
@@ -40,7 +38,11 @@ const merged = applyChangesetToOsm(changeset);
console.log(merged.id);
```
-`OsmChangeset` keeps track of creates/modifies/deletes per entity type. Call the helpers (`deduplicateNodes`, `deduplicateWays`, `generateDirectChanges`, `createIntersectionsForWays`, etc.) in whatever order your workflow requires, then use `applyChangesetToOsm()` to produce a new `Osm` instance with the edits applied.
+`OsmChangeset` keeps track of creates/modifies/deletes per entity type. Prefer `merge()` for the complete
+pipeline. When composing it manually, generate direct changes and then reconcile patch nodes before patch
+ways in one changeset rooted in the original base. Apply that changeset before creating intersections so the
+new patch ways are present in the rebuilt spatial index. For that reason, `generateChangeset()` rejects
+`directMerge: true` combined with `createIntersections: true`; use `merge()` for the staged pipeline.
### Run the bundled merge pipeline
@@ -56,7 +58,60 @@ const combined = await merge(base, patch, {
console.log(combined.id);
```
-`merge` wraps a sequence of changesets that deduplicate each dataset, optionally create intersections, and (when `directMerge` is true) generate modifications that reconcile the patch into the base. All options default to `false`, so you can enable only the stages you need.
+`merge` preserves the two source datasets and uses deduplication only to reconcile compatible patch entities
+with the base. It optionally creates intersections and, when `directMerge` is true, generates modifications
+that merge the patch into the base. All options default to `false`, so you can enable only the stages you need.
+An empty patch is therefore an identity operation; the high-level pipeline does not normalize either input as
+a hidden preliminary step.
+
+### Match imported data within one meter
+
+Exact reconciliation remains the default. For imported GeoJSON, Shapefile, OSW, or other independently
+created data, opt into proximity conflation with explicit property keys and an explicit network-attachment
+choice. The historical radius is one meter unless `maxDistanceMeters` is supplied.
+
+```ts check-docs change-context
+import {
+ applyChangesetToOsm,
+ discoverConflationCandidates,
+ generateConflationChangeset,
+} from "osmix";
+
+const conflation = {
+ propertyKeys: ["name", "operator", "surface"],
+ attachNetwork: true,
+};
+const discovery = discoverConflationCandidates(base, patch, conflation);
+
+// Review discovery.candidates and persist decisions by stable candidate ID.
+const decisions = discovery.candidates
+ .filter((candidate) => candidate.status === "review")
+ .map((candidate) => ({ candidateId: candidate.id, action: "reject" as const }));
+
+const changeset = generateConflationChangeset(
+ base,
+ patch,
+ {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ conflation,
+ },
+ decisions,
+ discovery,
+);
+const conflated = applyChangesetToOsm(changeset);
+```
+
+Discovery compares only the untouched patch with the immutable original base. High-confidence candidates
+apply automatically by default; set `automatic: "none"` when every match should require a decision. Property
+transfer changes only selected tags on the base entity. Network attachment changes only patch-created way
+references. Base IDs, coordinates, ordered way references, and ordered relation members stay authoritative.
+
+Structural properties cannot transfer. Routing-affecting properties, motor-road attachments, ambiguous
+targets, relation membership, and uncertain geometry require review. Grade conflicts, restrictions, dangling
+references, and way collapse remain blocked even when an accept decision is supplied. Equivalent one-to-one
+patch ways may be suppressed after property transfer; segmented way chains are reported but unsupported.
## API
@@ -72,8 +127,8 @@ constructor(base: Osm)
#### Core methods
-- `deduplicateNodes(nodes: Nodes)`: Check a set of nodes (usually from the base or patch) for duplicates against the base dataset. Deletes duplicates and maps their IDs to the surviving node.
-- `deduplicateWays(ways: Ways)`: Check a set of ways for geometric duplicates. Deletes duplicates and preserves the one with more tags/metadata.
+- `deduplicateNodes(nodes: Nodes)`: Check candidate nodes (normally from a patch) against the base dataset and map safe duplicates to the surviving base node. Proximity alone is not sufficient.
+- `deduplicateWays(ways: Ways)`: Check candidate ways against the base and reconcile only matching geometry with compatible routing and grade-separation tags.
- `generateDirectChanges(patch: Osm)`: Merge a patch dataset into the changeset. Handles creates and updates.
- `createIntersectionsForWays(ways: Ways)`: Checks provided ways for intersections with existing ways in the base dataset. Splits ways and inserts nodes where they cross.
- `applyNodeReplacementsToWays(replacementMap)`: Updates way references based on a map of replaced node IDs (generated by `deduplicateNodes`).
@@ -86,13 +141,32 @@ High-level pipeline to merge `patch` into `base`. Returns a new `Osm` instance.
Options:
- `directMerge` (boolean): Apply creates/updates from patch.
-- `deduplicateNodes` (boolean): Run node deduplication.
-- `deduplicateWays` (boolean): Run way deduplication.
+- `deduplicateNodes` (boolean): Reconcile compatible patch nodes with unique base matches.
+- `deduplicateWays` (boolean): Reconcile compatible patch ways with matching base geometry.
- `createIntersections` (boolean): Split intersecting ways.
+- `conflation` (optional): Explicit imported-data matching configuration. `propertyKeys` and
+ `attachNetwork` are required when supplied; `maxDistanceMeters` defaults to `1`, and `automatic` defaults
+ to `"high-confidence"`.
+
+### Conflation discovery and generation
+
+- `discoverConflationCandidates(base, patch, options)`: Return deterministic node and one-to-one-way
+ candidates with action-specific status, evidence, tag diffs, and reason codes.
+- `filterConflationCandidates(candidates, filter, decisions?)`: Filter discovery rows without rerunning the
+ spatial search.
+- `summarizeConflationCandidates(candidates, decisions?)`: Count automatic, review, blocked, unmatched, and
+ rejected rows.
+- `generateConflationChangeset(base, patch, mergeOptions, decisions?, discovery?)`: Generate one cumulative
+ direct, exact, and fuzzy changeset from untouched inputs.
+- `generateConflationApplicationChangeset(baseline, patch, discovery, originalBase, decisions?)`: Apply only
+ reviewed fuzzy actions to an already materialized ordinary-merge baseline. The immutable original base is
+ required so generation can rediscover and validate candidates instead of trusting mutable review records.
### `applyChangesetToOsm(changeset: OsmChangeset): Osm`
Applies all pending changes in the changeset to produce a **new** `Osm` instance. The original `base` is immutable.
+Application rejects new dangling references, degenerate highways, and detached turn-restriction topology
+before returning the result.
### Augmented Diffs
@@ -145,6 +219,11 @@ Options:
- Requires runtimes compatible with `@osmix/core` (Node 20+, Bun, or modern browsers) since the same typed-array data structures are used.
- Deduplication helpers assume datasets store dense node blocks and rely on spatial indexes built via `Osm.buildIndexes()`.
- Intersections are generated only for highway/footway-style features; polygonal ways are ignored.
+- A scan that compares a dataset with itself is useful for diagnostics, but its proposed proximity matches
+ should not be applied automatically. Use the high-level cross-dataset merge for reconciliation.
+- PBFs produced by older Osmix versions may already contain topology changes caused by automatic
+ within-input deduplication. Those files cannot be repaired reliably without their source inputs and should
+ be regenerated from the original base and patch files.
## Development
diff --git a/packages/change/src/apply-changeset.ts b/packages/change/src/apply-changeset.ts
index fedc5a9b..11024ffa 100644
--- a/packages/change/src/apply-changeset.ts
+++ b/packages/change/src/apply-changeset.ts
@@ -10,6 +10,22 @@
import { Osm } from "@osmix/core";
import type { OsmChangeset } from "./changeset.ts";
+import { assertNoNewRoutingIntegrityIssues, reuseRoutingIntegrityAnalysis } from "./integrity.ts";
+
+function hasOwnChanges(changes: Record) {
+ for (const key in changes) {
+ if (Object.hasOwn(changes, key)) return true;
+ }
+ return false;
+}
+
+function isEmptyChangeset(changeset: OsmChangeset) {
+ return (
+ !hasOwnChanges(changeset.nodeChanges) &&
+ !hasOwnChanges(changeset.wayChanges) &&
+ !hasOwnChanges(changeset.relationChanges)
+ );
+}
/**
* Apply a changeset to an Osm index, producing a new Osm index.
@@ -30,12 +46,29 @@ import type { OsmChangeset } from "./changeset.ts";
* @example
* ```ts
* const changeset = new OsmChangeset(baseOsm)
- * changeset.deduplicateNodes(baseOsm.nodes)
+ * changeset.generateDirectChanges(patchOsm)
+ * changeset.deduplicateNodes(patchOsm.nodes)
* const newOsm = applyChangesetToOsm(changeset)
* ```
*/
export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string) {
const baseOsm = changeset.osm;
+ if (isEmptyChangeset(changeset)) {
+ // Keep the documented fresh-result behavior while reusing finalized,
+ // immutable typed buffers and spatial indexes for a true no-op. This is
+ // important for empty-patch identity merges on large base datasets.
+ const osm = new Osm({
+ ...baseOsm.transferables(),
+ id: newOsmId ?? baseOsm.id,
+ });
+ if (!osm.hasSpatialIndexes()) osm.buildSpatialIndexes();
+ // The wrapper above references the exact same finalized entity buffers.
+ // Carry the source analysis forward so the next merge stage can reuse it.
+ reuseRoutingIntegrityAnalysis(baseOsm, osm);
+ assertNoNewRoutingIntegrityIssues(changeset.routingIntegrityBaselineKeys, osm);
+ return osm;
+ }
+
const osm = new Osm({
id: newOsmId ?? baseOsm.id,
header: baseOsm.header,
@@ -43,17 +76,10 @@ export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string)
const { nodeChanges, wayChanges, relationChanges } = changeset;
- // Work on shallow copies so applying a changeset never consumes its change
- // records. The change entities are only read while indexing them below.
- const pendingNodeChanges = { ...nodeChanges };
- const pendingWayChanges = { ...wayChanges };
- const pendingRelationChanges = { ...relationChanges };
-
// Add nodes from base, modifying and deleting as needed
for (const node of baseOsm.nodes) {
const change = nodeChanges[node.id];
if (change) {
- delete pendingNodeChanges[node.id];
if (change.changeType === "delete") continue; // Don't add deleted nodes
if (change.changeType === "create")
throw Error("Changeset contains create changes for existing entities");
@@ -63,18 +89,22 @@ export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string)
// All remaining node changes should be create
// Add nodes from patch
- for (const change of Object.values(pendingNodeChanges)) {
- if (change.changeType !== "create") {
+ for (const idText in nodeChanges) {
+ if (!Object.hasOwn(nodeChanges, idText)) continue;
+ const change = nodeChanges[Number(idText)]!;
+ if (change.changeType === "create") {
+ osm.nodes.addNode(change.entity);
+ continue;
+ }
+ if (!baseOsm.nodes.ids.has(Number(idText))) {
throw Error("Changeset still contains node changes in incorrect stage.");
}
- osm.nodes.addNode(change.entity);
}
// Add ways from base, modifying and deleting as needed
for (const way of baseOsm.ways) {
const change = wayChanges[way.id];
if (change) {
- delete pendingWayChanges[way.id];
if (change.changeType === "delete") continue; // Don't add deleted ways
if (change.changeType === "create") {
throw Error("Changeset contains create changes for existing entities");
@@ -86,17 +116,22 @@ export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string)
// All remaining way changes should be create
// Add ways from patch
- for (const change of Object.values(pendingWayChanges)) {
- if (change.changeType !== "create")
+ for (const idText in wayChanges) {
+ if (!Object.hasOwn(wayChanges, idText)) continue;
+ const change = wayChanges[Number(idText)]!;
+ if (change.changeType === "create") {
+ osm.ways.addWay(change.entity);
+ continue;
+ }
+ if (!baseOsm.ways.ids.has(Number(idText))) {
throw Error("Changeset still contains way changes in incorrect stage.");
- osm.ways.addWay(change.entity);
+ }
}
// Add relations from base, modifying and deleting as needed
for (const relation of baseOsm.relations) {
const change = relationChanges[relation.id];
if (change) {
- delete pendingRelationChanges[relation.id];
if (change.changeType === "delete") continue; // Don't add deleted relations
if (change.changeType === "create") {
throw Error("Changeset contains create changes for existing entities");
@@ -106,10 +141,16 @@ export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string)
}
// Add relations from patch
- for (const change of Object.values(pendingRelationChanges)) {
- if (change.changeType !== "create")
+ for (const idText in relationChanges) {
+ if (!Object.hasOwn(relationChanges, idText)) continue;
+ const change = relationChanges[Number(idText)]!;
+ if (change.changeType === "create") {
+ osm.relations.addRelation(change.entity);
+ continue;
+ }
+ if (!baseOsm.relations.ids.has(Number(idText))) {
throw Error("Changeset still contains relation changes in incorrect stage.");
- osm.relations.addRelation(change.entity);
+ }
}
// Everything should be added now, finish the osm
@@ -118,5 +159,7 @@ export function applyChangesetToOsm(changeset: OsmChangeset, newOsmId?: string)
// Build spatial indexes
osm.buildSpatialIndexes();
+ assertNoNewRoutingIntegrityIssues(changeset.routingIntegrityBaselineKeys, osm);
+
return osm;
}
diff --git a/packages/change/src/changeset.ts b/packages/change/src/changeset.ts
index 1d75b93d..15c6ba1a 100644
--- a/packages/change/src/changeset.ts
+++ b/packages/change/src/changeset.ts
@@ -9,27 +9,214 @@
*/
import type { IdOrIndex, Nodes, Osm, Ways } from "@osmix/core";
+import { toMicroDegrees } from "@osmix/geo/coordinates";
import type { OsmEntity, OsmEntityType, OsmEntityTypeMap, OsmNode, OsmWay } from "@osmix/types";
-import { entityPropertiesEqual, getEntityType, isWayEqual } from "@osmix/types/utils";
+import { entityPropertiesEqual, getEntityType } from "@osmix/types/utils";
import { dequal } from "dequal"; // dequal/lite does not work with `TypedArray`s
+import { inheritedRoutingIntegrityIssueKeys, routingIntegrityIssueKeys } from "./integrity.ts";
import type { OsmChange, OsmChanges, OsmChangesetStats, OsmEntityRef } from "./types.ts";
import {
+ areWayTagsIntersectionCandidate,
cleanCoords,
entityHasTagValue,
- getEntityVersion,
- isWayIntersectionCandidate,
nearestNodeOnWay,
removeDuplicateAdjacentRelationMembers,
removeDuplicateAdjacentWayRefs,
+ routingGradeSignature,
waysIntersect,
waysShouldConnect,
} from "./utils.ts";
type ReplacementMap = Map;
type IdIndex = Nodes["ids"];
+type ExactWayIndex = Map;
+type WaysByNode = ReadonlyMap;
+
+interface NodeCandidate {
+ baseNodes: OsmNode[];
+ patchNode: OsmNode;
+}
+
+interface WayCoordinateCacheEntry {
+ cleaned?: [number, number][];
+ coordinates: [number, number][] | null;
+ nodeCoordinateRevision: number;
+ wayRevision: number;
+}
+
+interface IntersectionMetadata {
+ eligible: Uint8Array;
+ gradeIds: Int32Array;
+}
const EMPTY_ID = -1;
+const DESCRIPTIVE_WAY_TAGS = new Set([
+ "alt_name",
+ "int_name",
+ "loc_name",
+ "name",
+ "note",
+ "official_name",
+ "old_name",
+ "operator",
+ "ref",
+ "short_name",
+ "source",
+ "wikidata",
+ "wikipedia",
+]);
+const DESCRIPTIVE_WAY_TAG_PREFIXES = [
+ "alt_name:",
+ "name:",
+ "note:",
+ "official_name:",
+ "old_name:",
+ "operator:",
+ "source:",
+] as const;
+const GRADE_AND_ACCESS_TAGS = [
+ "access",
+ "barrier",
+ "bicycle",
+ "foot",
+ "horse",
+ "motor_vehicle",
+ "motorcar",
+ "vehicle",
+] as const;
+const GRADE_TAG_DEFAULTS = {
+ bridge: "no",
+ covered: "no",
+ layer: "0",
+ level: "",
+ tunnel: "no",
+} as const;
+const NODE_ROUTING_CRITICAL_TAGS = [
+ "access",
+ "barrier",
+ "bicycle",
+ "foot",
+ "ford",
+ "highway",
+ "horse",
+ "motor_vehicle",
+ "motorcar",
+ "vehicle",
+] as const;
+
+function sameOsmCoordinate(a: OsmNode, b: OsmNode) {
+ return (
+ toMicroDegrees(a.lon) === toMicroDegrees(b.lon) &&
+ toMicroDegrees(a.lat) === toMicroDegrees(b.lat)
+ );
+}
+
+function hasAnyTagConflict(a: OsmEntity["tags"], b: OsmEntity["tags"]) {
+ if (!a || !b) return false;
+ return Object.entries(a).some(([key, value]) => b[key] != null && b[key] !== value);
+}
+
+function isDescriptiveWayTag(key: string) {
+ return (
+ DESCRIPTIVE_WAY_TAGS.has(key) ||
+ DESCRIPTIVE_WAY_TAG_PREFIXES.some((prefix) => key.startsWith(prefix))
+ );
+}
+
+function routingSemanticTagsEqual(a: OsmEntity["tags"], b: OsmEntity["tags"]) {
+ const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
+ return [...keys].every((key) => isDescriptiveWayTag(key) || a?.[key] === b?.[key]);
+}
+
+function hashText(hash: number, value: string) {
+ let nextHash = hash;
+ for (let index = 0; index < value.length; index++) {
+ nextHash ^= value.charCodeAt(index);
+ nextHash = Math.imul(nextHash, 16_777_619);
+ }
+ return nextHash >>> 0;
+}
+
+/**
+ * Produce a compact lookup key for exact way reconciliation. Hash collisions are
+ * expected and harmless because candidates still pass the complete refs and tag
+ * predicates before they can be accepted.
+ */
+function exactWayHash(way: OsmWay) {
+ let hash = 2_166_136_261;
+ hash = hashText(hash, `${way.refs.length}:`);
+ for (const ref of way.refs) hash = hashText(hash, `${ref},`);
+ for (const [key, value] of Object.entries(way.tags ?? {}).toSorted(([a], [b]) =>
+ a < b ? -1 : a > b ? 1 : 0,
+ )) {
+ if (isDescriptiveWayTag(key)) continue;
+ hash = hashText(hash, `${key.length}:${key}${String(value).length}:${String(value)}`);
+ }
+ return hash;
+}
+
+function hasConflictingGradeOrAccessTags(a: OsmEntity["tags"], b: OsmEntity["tags"]) {
+ if (GRADE_AND_ACCESS_TAGS.some((key) => String(a?.[key] ?? "") !== String(b?.[key] ?? ""))) {
+ return true;
+ }
+ return Object.entries(GRADE_TAG_DEFAULTS).some(
+ ([key, defaultValue]) => String(a?.[key] ?? defaultValue) !== String(b?.[key] ?? defaultValue),
+ );
+}
+
+function withNonConflictingTags(base: T, patch: T): T {
+ if (!patch.tags) return base;
+ const tags = { ...base.tags };
+ let changed = false;
+ for (const [key, value] of Object.entries(patch.tags)) {
+ if (tags[key] != null) continue;
+ tags[key] = value;
+ changed = true;
+ }
+ return changed ? { ...base, tags } : base;
+}
+
+function withNonConflictingDescriptiveTags(base: T, patch: T): T {
+ if (!patch.tags) return base;
+ const tags = { ...base.tags };
+ let changed = false;
+ for (const [key, value] of Object.entries(patch.tags)) {
+ if (!isDescriptiveWayTag(key) || tags[key] != null) continue;
+ tags[key] = value;
+ changed = true;
+ }
+ return changed ? { ...base, tags } : base;
+}
+
+function nodeRoutingTagCount(node: OsmNode) {
+ return NODE_ROUTING_CRITICAL_TAGS.reduce(
+ (count, key) => count + (node.tags?.[key] == null ? 0 : 1),
+ 0,
+ );
+}
+
+function wayBbox(coordinates: [number, number][]): [number, number, number, number] {
+ let minLon = Number.POSITIVE_INFINITY;
+ let minLat = Number.POSITIVE_INFINITY;
+ let maxLon = Number.NEGATIVE_INFINITY;
+ let maxLat = Number.NEGATIVE_INFINITY;
+ for (const [lon, lat] of coordinates) {
+ minLon = Math.min(minLon, lon);
+ minLat = Math.min(minLat, lat);
+ maxLon = Math.max(maxLon, lon);
+ maxLat = Math.max(maxLat, lat);
+ }
+ return [minLon, minLat, maxLon, maxLat];
+}
+
+function bboxesIntersect(
+ a: readonly [number, number, number, number],
+ b: readonly [number, number, number, number],
+) {
+ if (a[0] > a[2] || a[1] > a[3] || b[0] > b[2] || b[1] > b[3]) return false;
+ return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
+}
/** Return the true maximum ID regardless of insertion order. */
function maximumId(ids: IdIndex): number | null {
@@ -88,6 +275,8 @@ export class OsmChangeset {
relationChanges: Record> = {};
osm: Osm;
+ /** @internal Integrity issues inherited from merge inputs rather than introduced by changes. */
+ routingIntegrityBaselineKeys: Set;
// Next node ID tracker for generating new IDs during intersection creation
currentNodeId: number;
@@ -98,17 +287,26 @@ export class OsmChangeset {
intersectionPointsFound = 0;
intersectionNodesCreated = 0;
+ /** Revisions keep geometry caches correct while intersections rewrite ways in place. */
+ private nodeCoordinateRevision = 0;
+ private readonly wayGeometryRevisions = new Map();
+ private readonly wayCoordinateCache = new Map();
+
static fromJson(base: Osm, json: OsmChanges) {
const changeset = new OsmChangeset(base);
changeset.nodeChanges = json.nodes;
changeset.wayChanges = json.ways;
changeset.relationChanges = json.relations;
+ // Serialized node changes may move, delete, or supply a previously missing
+ // ref. Conservatively disable packed base-coordinate reuse for this instance.
+ if (Object.keys(json.nodes).length > 0) changeset.nodeCoordinateRevision++;
return changeset;
}
constructor(base: Osm) {
this.osm = base;
this.currentNodeId = maximumId(base.nodes.ids) ?? EMPTY_ID;
+ this.routingIntegrityBaselineKeys = routingIntegrityIssueKeys(base);
}
get stats(): OsmChangesetStats {
@@ -156,6 +354,13 @@ export class OsmChangeset {
}
create(entity: OsmEntity, osmId: string, refs?: OsmEntityRef[]) {
+ this.recordCreate(entity, osmId, refs);
+ const type = getEntityType(entity);
+ if (type === "node") this.nodeCoordinateRevision++;
+ if (type === "way") this.invalidateWayGeometry(entity.id);
+ }
+
+ private recordCreate(entity: OsmEntity, osmId: string, refs?: OsmEntityRef[]) {
this.changes(getEntityType(entity))[entity.id] = {
changeType: "create",
entity,
@@ -191,12 +396,25 @@ export class OsmChangeset {
// If we already have a change, preserve the original oldEntity.
const oldEntity = change?.oldEntity ?? (changeEntity ? undefined : existingEntity);
+ const modifiedEntity = modify(existingEntity);
changes[id] = {
changeType: change?.changeType ?? "modify",
- entity: modify(existingEntity),
+ entity: modifiedEntity,
osmId: this.osm.id, // If we're modifying an entity, it must exist in the base OSM
oldEntity,
};
+
+ if (type === "node") {
+ const previous = existingEntity as OsmNode;
+ const next = modifiedEntity as OsmNode;
+ if (previous.lon !== next.lon || previous.lat !== next.lat) {
+ this.nodeCoordinateRevision++;
+ }
+ } else if (type === "way") {
+ const previous = existingEntity as OsmWay;
+ const next = modifiedEntity as OsmWay;
+ if (!dequal(previous.refs, next.refs)) this.invalidateWayGeometry(id);
+ }
}
getEntity(type: T, id: number): OsmEntityTypeMap[T] | undefined {
@@ -219,94 +437,182 @@ export class OsmChangeset {
osmId: this.osm.id,
oldEntity: entity, // For augmented diffs: capture the entity being deleted
};
+ const type = getEntityType(entity);
+ if (type === "node") this.nodeCoordinateRevision++;
+ if (type === "way") this.invalidateWayGeometry(entity.id);
+ }
+
+ private invalidateWayGeometry(wayId: number) {
+ this.wayGeometryRevisions.set(wayId, (this.wayGeometryRevisions.get(wayId) ?? 0) + 1);
+ this.wayCoordinateCache.delete(wayId);
+ }
+
+ private *currentWays() {
+ for (const way of this.osm.ways) {
+ const current = this.getCurrentWay(way);
+ if (current) yield current;
+ }
+ for (const change of Object.values(this.wayChanges)) {
+ // Existing IDs were already yielded in base insertion order. Object-key
+ // order matches the former Map append order for patch-created entities.
+ if (this.osm.ways.ids.has(change.entity.id) || change.changeType === "delete") continue;
+ yield change.entity;
+ }
+ }
+
+ private *currentRelations() {
+ for (const relation of this.osm.relations) {
+ const change = this.relationChanges[relation.id];
+ if (change?.changeType === "delete") continue;
+ yield change?.entity ?? relation;
+ }
+ for (const change of Object.values(this.relationChanges)) {
+ if (this.osm.relations.ids.has(change.entity.id) || change.changeType === "delete") continue;
+ yield change.entity;
+ }
+ }
+
+ private nodeContextsCompatible(patchNode: OsmNode, baseNode: OsmNode, waysByNode: WaysByNode) {
+ if (hasAnyTagConflict(patchNode.tags, baseNode.tags)) return false;
+ if (hasConflictingGradeOrAccessTags(patchNode.tags, baseNode.tags)) return false;
+
+ const patchWays = waysByNode.get(patchNode.id) ?? [];
+ const baseWays = waysByNode.get(baseNode.id) ?? [];
+ if (patchWays.length === 0 || baseWays.length === 0) return true;
+
+ return patchWays.every((patchWay) =>
+ baseWays.every(
+ (baseWay) =>
+ !hasConflictingGradeOrAccessTags(patchWay.tags, baseWay.tags) &&
+ (patchWay.tags?.["highway"] == null) === (baseWay.tags?.["highway"] == null),
+ ),
+ );
}
/**
- * Check nodes for duplicates and consolidate them within this OSM dataset.
- * This process helps merge disparate datasets that share common geometry.
- *
- * The algorithm:
- * 1. Find all pairs of nodes at the same geographic location (within a tiny radius).
- * 2. For each pair, determine which node to keep:
- * - Prefer higher version number.
- * - If versions are equal, prefer the node with more tags.
- * - If tags are equal, prefer the higher ID (deterministic tie-breaker).
- * 3. Build a replacement map (deleted ID -> kept ID).
- * 4. Flatten chains (e.g., if A->B and B->C, then A->C).
- * 5. Schedule duplicate nodes for deletion.
- * 6. Update all ways and relations to reference the kept nodes.
+ * Build incident-way context in one pass, but only for nodes that already have
+ * an exact-coordinate candidate. This replaces a full way scan per candidate.
*/
- deduplicateNodes(nodes: Nodes) {
- const checkedIdPairs = new IdPairs();
- const replacementMap = new Map();
-
- // Find overlapping nodes and determine which to keep
- for (const node of nodes) {
- if (!this.osm.nodes.ids.has(node.id)) continue;
- if (this.nodeChanges[node.id]?.changeType === "delete") continue;
-
- // Use a tiny radius (1 meter = 0.001 km) to find nodes at effectively the same location
- const existingNodes = this.osm.nodes.findIndexesWithinRadius(node.lon, node.lat, 0.001);
- const existingNodeIds = existingNodes
- .map((index) => ({ id: this.osm.nodes.ids.at(index), index }))
- .filter((n) => n.id !== node.id && !checkedIdPairs.has(n.id, node.id));
-
- for (const { index: existingNodeIndex } of existingNodeIds) {
- const existingNode = this.osm.nodes.getByIndex(existingNodeIndex);
- if (existingNode == null) continue;
-
- checkedIdPairs.add(existingNode.id, node.id);
-
- // Determine which node to keep using version/tags logic (same as deduplicateWay)
- const nodeVersion = getEntityVersion(node);
- const existingNodeVersion = getEntityVersion(existingNode);
-
- let nodeToKeep: number = node.id;
- let nodeToDelete: number = existingNode.id;
-
- // Check version - prefer higher version
- if (existingNodeVersion > nodeVersion) {
- // Existing node has higher version, keep existing node
- nodeToKeep = existingNode.id;
- nodeToDelete = node.id;
- } else if (nodeVersion === existingNodeVersion) {
- // Same version, keep node with more tags (>= comparison to match deduplicateWay)
- const nodeTagCount = Object.keys(node.tags ?? {}).length;
- const existingNodeTagCount = Object.keys(existingNode.tags ?? {}).length;
- if (existingNodeTagCount >= nodeTagCount) {
- // Existing node has same or more tags, keep existing node
- // If equal tags, use higher ID for normalization
- if (existingNodeTagCount === nodeTagCount) {
- nodeToKeep = Math.max(node.id, existingNode.id);
- nodeToDelete = Math.min(node.id, existingNode.id);
- } else {
- nodeToKeep = existingNode.id;
- nodeToDelete = node.id;
- }
- }
- }
+ private currentWaysByNode(nodeIds: ReadonlySet): WaysByNode {
+ const waysByNode = new Map();
+ for (const nodeId of nodeIds) waysByNode.set(nodeId, []);
+ if (waysByNode.size === 0) return waysByNode;
+
+ for (const way of this.currentWays()) {
+ let matchedRefs: Set | undefined;
+ for (const ref of way.refs) {
+ const incidentWays = waysByNode.get(ref);
+ if (!incidentWays || matchedRefs?.has(ref)) continue;
+ incidentWays.push(way);
+ (matchedRefs ??= new Set()).add(ref);
+ }
+ }
+ return waysByNode;
+ }
- // Add to replacement map (deleted node -> kept node)
- replacementMap.set(nodeToDelete, nodeToKeep);
+ private removeUnsafeNodeReplacements(replacementMap: ReplacementMap) {
+ if (replacementMap.size === 0) return;
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const way of this.currentWays()) {
+ if (way.tags?.["highway"] == null || new Set(way.refs).size < 2) continue;
+ const replacedRefs = way.refs.map((ref) => replacementMap.get(ref) ?? ref);
+ if (new Set(replacedRefs).size >= 2) continue;
+ for (const ref of way.refs) {
+ if (!replacementMap.delete(ref)) continue;
+ changed = true;
+ }
}
}
+ }
- // Flatten deletion chains before updating higher-layer references.
- const flattenedMap = flattenReplacementMap(replacementMap);
- this.applyNodeReplacementsToWays(flattenedMap);
- this.applyNodeReplacementsToRelations(flattenedMap);
+ private reconcileNodeTags(patchNode: OsmNode, baseNodeId: number) {
+ const baseNode = this.getCurrentNode(baseNodeId);
+ if (!baseNode) return;
+ const mergedNode = withNonConflictingTags(baseNode, patchNode);
+ if (mergedNode !== baseNode) this.modify("node", baseNodeId, () => mergedNode);
+ }
- // Schedule nodes for deletion only after all references are updated.
- for (const fromId of flattenedMap.keys()) {
- const nodeToDelete = this.osm.nodes.getById(fromId);
- if (nodeToDelete) {
- this.deduplicatedNodes++;
- this.delete(nodeToDelete, [
- { type: "node", id: flattenedMap.get(fromId)!, osmId: this.osm.id },
- ]);
- }
+ private deleteReconciledNode(node: OsmNode, survivorId: number) {
+ const pendingChange = this.nodeChanges[node.id];
+ if (pendingChange?.changeType === "create") {
+ delete this.nodeChanges[node.id];
+ } else {
+ const storedNode = this.osm.nodes.getById(node.id);
+ if (!storedNode) return;
+ this.delete(storedNode, [{ type: "node", id: survivorId, osmId: this.osm.id }]);
}
- return flattenedMap;
+ this.deduplicatedNodes++;
+ }
+
+ /**
+ * Reconcile incoming nodes with unambiguous base nodes at the exact OSM coordinate.
+ * Cross-dataset reconciliation always preserves the base ID. Same-dataset diagnostic
+ * scans use the highest compatible ID as a deterministic candidate survivor.
+ */
+ deduplicateNodes(nodes: Nodes) {
+ const sameDataset = nodes === this.osm.nodes;
+ const replacementMap: ReplacementMap = new Map();
+ const exactCandidates: NodeCandidate[] = [];
+ const contextNodeIds = new Set();
+
+ for (const patchNode of nodes) {
+ if (this.nodeChanges[patchNode.id]?.changeType === "delete") continue;
+ if (!sameDataset && this.nodeChanges[patchNode.id]?.changeType !== "create") continue;
+ const currentPatchNode = this.getCurrentNode(patchNode.id);
+ if (!currentPatchNode) continue;
+
+ // Exact reconciliation only accepts equality at OSM's seven-decimal storage
+ // precision. Query that exact coordinate rather than calculating and sorting
+ // haversine distances for candidates that could never be accepted.
+ const candidateNodes = this.osm.nodes
+ .findIndexesWithinBbox([patchNode.lon, patchNode.lat, patchNode.lon, patchNode.lat])
+ .map((index) => this.osm.nodes.getByIndex(index))
+ .map((baseNode) => this.getCurrentNode(baseNode.id) ?? baseNode)
+ .filter(
+ (baseNode) =>
+ baseNode.id !== patchNode.id &&
+ (!sameDataset || baseNode.id > patchNode.id) &&
+ this.nodeChanges[baseNode.id]?.changeType !== "delete" &&
+ sameOsmCoordinate(currentPatchNode, baseNode) &&
+ !hasAnyTagConflict(currentPatchNode.tags, baseNode.tags) &&
+ !hasConflictingGradeOrAccessTags(currentPatchNode.tags, baseNode.tags),
+ );
+
+ if (candidateNodes.length === 0) continue;
+ exactCandidates.push({ baseNodes: candidateNodes, patchNode: currentPatchNode });
+ contextNodeIds.add(currentPatchNode.id);
+ for (const baseNode of candidateNodes) contextNodeIds.add(baseNode.id);
+ }
+
+ if (exactCandidates.length === 0) return replacementMap;
+
+ const waysByNode = this.currentWaysByNode(contextNodeIds);
+ for (const { baseNodes, patchNode } of exactCandidates) {
+ const compatibleNodes = baseNodes.filter((baseNode) =>
+ this.nodeContextsCompatible(patchNode, baseNode, waysByNode),
+ );
+ if (compatibleNodes.length === 0 || (!sameDataset && compatibleNodes.length !== 1)) continue;
+ const baseNode = sameDataset
+ ? compatibleNodes.toSorted((a, b) => b.id - a.id)[0]
+ : compatibleNodes[0];
+ replacementMap.set(patchNode.id, baseNode!.id);
+ }
+
+ if (replacementMap.size === 0) return replacementMap;
+ this.removeUnsafeNodeReplacements(replacementMap);
+ if (replacementMap.size === 0) return replacementMap;
+ this.applyNodeReplacementsToWays(replacementMap);
+ this.applyNodeReplacementsToRelations(replacementMap);
+
+ for (const [patchNodeId, baseNodeId] of replacementMap) {
+ const patchNode = this.getCurrentNode(patchNodeId);
+ if (!patchNode) continue;
+ this.reconcileNodeTags(patchNode, baseNodeId);
+ this.deleteReconciledNode(patchNode, baseNodeId);
+ }
+ return replacementMap;
}
/**
@@ -314,11 +620,10 @@ export class OsmChangeset {
* Returns the total number of node references replaced.
*/
private applyNodeReplacementsToWays(replacementMap: Map): number {
+ if (replacementMap.size === 0) return 0;
let replacedCount = 0;
- for (let wayIndex = 0; wayIndex < this.osm.ways.size; wayIndex++) {
- const way = this.osm.ways.getByIndex(wayIndex);
- if (this.wayChanges[way.id]?.changeType === "delete") continue;
+ for (const way of this.currentWays()) {
let hasReplacement = false;
const newRefs = way.refs.map((ref) => {
const replacement = replacementMap.get(ref);
@@ -349,14 +654,10 @@ export class OsmChangeset {
* Returns the total number of node member references replaced.
*/
private applyNodeReplacementsToRelations(replacementMap: Map): number {
+ if (replacementMap.size === 0) return 0;
let replacedCount = 0;
- for (let relationIndex = 0; relationIndex < this.osm.relations.size; relationIndex++) {
- const baseRelation = this.osm.relations.getByIndex(relationIndex);
- const relation =
- (this.relationChanges[baseRelation.id]?.entity as
- | OsmEntityTypeMap["relation"]
- | undefined) ?? baseRelation;
+ for (const relation of this.currentRelations()) {
let hasReplacement = false;
const newMembers = relation.members.map((member) => {
if (member.type !== "node") return member;
@@ -369,7 +670,7 @@ export class OsmChangeset {
return member;
});
- if (hasReplacement && this.relationChanges[relation.id]?.changeType !== "delete") {
+ if (hasReplacement) {
this.modify("relation", relation.id, (relation) =>
removeDuplicateAdjacentRelationMembers({
...relation,
@@ -383,18 +684,122 @@ export class OsmChangeset {
return replacedCount;
}
+ private replaceRestrictionViaNode(fromId: number, toId: number) {
+ for (const relation of this.currentRelations()) {
+ if (
+ relation.tags?.["type"] !== "restriction" ||
+ !relation.members.some(
+ (member) => member.type === "node" && member.role === "via" && member.ref === fromId,
+ )
+ ) {
+ continue;
+ }
+ this.modify("relation", relation.id, (relation) =>
+ removeDuplicateAdjacentRelationMembers({
+ ...relation,
+ members: relation.members.map((member) =>
+ member.type === "node" && member.role === "via" && member.ref === fromId
+ ? { ...member, ref: toId }
+ : member,
+ ),
+ }),
+ );
+ }
+ }
+
+ private chooseIntersectionNode(
+ wayNode: OsmNode,
+ intersectingWayNode: OsmNode,
+ wayIsPatch: boolean,
+ intersectingWayIsPatch: boolean,
+ ) {
+ if (hasAnyTagConflict(wayNode.tags, intersectingWayNode.tags)) return null;
+
+ const wayRoutingTags = nodeRoutingTagCount(wayNode);
+ const intersectingRoutingTags = nodeRoutingTagCount(intersectingWayNode);
+ let keepWayNode: boolean;
+ if (wayIsPatch !== intersectingWayIsPatch) {
+ keepWayNode = !wayIsPatch;
+ } else if (wayRoutingTags !== intersectingRoutingTags) {
+ keepWayNode = wayRoutingTags > intersectingRoutingTags;
+ } else {
+ const wayTagCount = Object.keys(wayNode.tags ?? {}).length;
+ const intersectingTagCount = Object.keys(intersectingWayNode.tags ?? {}).length;
+ keepWayNode = wayTagCount >= intersectingTagCount;
+ }
+
+ const survivor = keepWayNode ? wayNode : intersectingWayNode;
+ const replaced = keepWayNode ? intersectingWayNode : wayNode;
+ return { keepWayNode, replaced, survivor };
+ }
+
+ /**
+ * Endpoint reuse is a local rewrite of exactly one of the intersecting ways.
+ * Reject it before mutation when snapping two nearby crossings to the same
+ * endpoint would create invalid topology.
+ */
+ private intersectionReplacementIsUnsafe(
+ way: OsmWay,
+ replacedNodeId: number,
+ survivorNodeId: number,
+ ) {
+ const refs = way.refs.map((ref) => (ref === replacedNodeId ? survivorNodeId : ref));
+ if (refs.some((ref, index) => index > 0 && ref === refs[index - 1])) return true;
+ return new Set(refs).size < 2;
+ }
+
+ private mergeNodeTags(survivor: OsmNode, replaced: OsmNode) {
+ const merged = withNonConflictingTags(survivor, replaced);
+ if (merged !== survivor) this.modify("node", survivor.id, () => merged);
+ return merged;
+ }
+
+ private markNodeAsCrossing(nodeId: number) {
+ const node = this.getCurrentNode(nodeId);
+ if (!node || entityHasTagValue(node, "crossing", "yes")) return;
+ this.modify("node", node.id, (node) => ({
+ ...node,
+ tags: { ...node.tags, crossing: "yes" },
+ }));
+ }
+
/**
* De-duplicate the ways within this OSM changeset.
*/
*deduplicateWaysGenerator(ways: Ways, replacementMap: ReplacementMap = new Map()) {
const dedupedIdPairs = new IdPairs();
+ const sameDataset = ways === this.osm.ways;
+ const exactWayIndex = sameDataset ? undefined : this.buildCrossDatasetExactWayIndex();
for (const way of ways) {
- if (!this.osm.ways.ids.has(way.id)) continue;
if (this.wayChanges[way.id]?.changeType === "delete") continue;
- yield this.deduplicateWay(way, dedupedIdPairs, replacementMap);
+ yield this.deduplicateWayAgainstBase(
+ way,
+ sameDataset,
+ dedupedIdPairs,
+ replacementMap,
+ exactWayIndex,
+ );
}
}
+ /**
+ * Index immutable base targets by ordered refs and routing semantics. Candidate
+ * buckets are collision-checked with the complete reconciliation predicates.
+ */
+ private buildCrossDatasetExactWayIndex(): ExactWayIndex {
+ const index: ExactWayIndex = new Map();
+ for (let wayIndex = 0; wayIndex < this.osm.ways.size; wayIndex++) {
+ const currentWay = this.getCurrentWay(this.osm.ways.getByIndex(wayIndex));
+ if (!currentWay) continue;
+ const hash = exactWayHash(currentWay);
+ const indexed = index.get(hash);
+ if (indexed === undefined) index.set(hash, wayIndex);
+ else if (typeof indexed === "number") index.set(hash, [indexed, wayIndex]);
+ else indexed.push(wayIndex);
+ }
+ return index;
+ }
+
deduplicateWays(ways: Ways) {
const replacementMap: ReplacementMap = new Map();
for (const _ of this.deduplicateWaysGenerator(ways, replacementMap));
@@ -406,14 +811,10 @@ export class OsmChangeset {
* Returns the total number of way member references replaced.
*/
private applyWayReplacementsToRelations(replacementMap: ReplacementMap): number {
+ if (replacementMap.size === 0) return 0;
let replacedCount = 0;
- for (let relationIndex = 0; relationIndex < this.osm.relations.size; relationIndex++) {
- const baseRelation = this.osm.relations.getByIndex(relationIndex);
- const relation =
- (this.relationChanges[baseRelation.id]?.entity as
- | OsmEntityTypeMap["relation"]
- | undefined) ?? baseRelation;
+ for (const relation of this.currentRelations()) {
let hasReplacement = false;
const newMembers = relation.members.map((member) => {
if (member.type !== "way") return member;
@@ -426,7 +827,7 @@ export class OsmChangeset {
return member;
});
- if (hasReplacement && this.relationChanges[relation.id]?.changeType !== "delete") {
+ if (hasReplacement) {
this.modify("relation", relation.id, (relation) =>
removeDuplicateAdjacentRelationMembers({
...relation,
@@ -439,75 +840,84 @@ export class OsmChangeset {
return replacedCount;
}
- /**
- * Deduplicate a way by comparing it with existing ways in the OSM dataset.
- * When a duplicate way is found, the patch way is deleted and references point to the kept way.
- *
- * Duplication criteria:
- * - Geometrically identical (same coordinates).
- * - Properties (except ID) must be roughly compatible.
- * - Keeps the way with the higher version or more tags.
- *
- */
+ private deleteReconciledWay(way: OsmWay, survivorId: number) {
+ const pendingChange = this.wayChanges[way.id];
+ if (pendingChange?.changeType === "create") {
+ delete this.wayChanges[way.id];
+ } else {
+ const storedWay = this.osm.ways.getById(way.id);
+ if (!storedWay) return;
+ this.delete(storedWay, [{ type: "way", id: survivorId, osmId: this.osm.id }]);
+ }
+ this.deduplicatedWays++;
+ }
+
+ private deduplicateWayAgainstBase(
+ patchWay: OsmWay,
+ sameDataset: boolean,
+ dedupedIdPairs: IdPairs,
+ replacementMap: ReplacementMap,
+ exactWayIndex?: ExactWayIndex,
+ ) {
+ if (!this.osm.ways.ids.has(patchWay.id) && this.wayChanges[patchWay.id] == null) return 0;
+ if (!sameDataset && this.wayChanges[patchWay.id]?.changeType !== "create") return 0;
+ const currentPatchWay =
+ this.getCurrentWay(patchWay) ?? this.wayChanges[patchWay.id]?.entity ?? patchWay;
+ const indexed = exactWayIndex?.get(exactWayHash(currentPatchWay));
+ if (exactWayIndex && indexed === undefined) return 0;
+ const indexedCandidates =
+ typeof indexed === "number" ? [indexed] : indexed === undefined ? [] : indexed;
+
+ const wayCoords = this.getWayCoordinates(currentPatchWay);
+ if (!wayCoords || wayCoords.length < 2) return 0;
+
+ const patchBbox = wayBbox(wayCoords);
+ const closeWayIndexes = exactWayIndex
+ ? indexedCandidates.filter((index) =>
+ bboxesIntersect(patchBbox, this.osm.ways.getEntityBbox({ index })),
+ )
+ : this.osm.ways.intersects(patchBbox);
+ const candidates = closeWayIndexes
+ .map((index) => this.osm.ways.getByIndex(index))
+ .filter((baseWay) => {
+ if (baseWay.id === patchWay.id) return false;
+ if (sameDataset) {
+ if (baseWay.id < patchWay.id) return false;
+ }
+ if (dedupedIdPairs.has(patchWay.id, baseWay.id)) return false;
+ dedupedIdPairs.add(patchWay.id, baseWay.id);
+ const currentBaseWay = this.getCurrentWay(baseWay);
+ if (!currentBaseWay) return false;
+ if (!dequal(currentPatchWay.refs, currentBaseWay.refs)) return false;
+ return routingSemanticTagsEqual(currentPatchWay.tags, currentBaseWay.tags);
+ });
+
+ if (candidates.length === 0 || (!sameDataset && candidates.length !== 1)) return 0;
+ const baseWay = sameDataset ? candidates.toSorted((a, b) => b.id - a.id)[0] : candidates[0];
+ const currentBaseWay = this.getCurrentWay(baseWay!);
+ if (!currentBaseWay) return 0;
+
+ const mergedWay = withNonConflictingDescriptiveTags(currentBaseWay, currentPatchWay);
+ if (mergedWay !== currentBaseWay) this.modify("way", currentBaseWay.id, () => mergedWay);
+
+ replacementMap.set(patchWay.id, currentBaseWay.id);
+ this.applyWayReplacementsToRelations(replacementMap);
+ this.deleteReconciledWay(patchWay, currentBaseWay.id);
+ return 1;
+ }
+
+ /** Reconcile one incoming way with a unique, equivalent base way. */
deduplicateWay(
patchWay: OsmWay,
dedupedIdPairs: IdPairs,
replacementMap: ReplacementMap = new Map(),
) {
- const wayIndex = this.osm.ways.ids.getIndexFromId(patchWay.id);
- const wayCoords = this.osm.ways.getCoordinates(wayIndex);
-
- // Look for duplicate ways in OSM index
- const closeWayIndexes = this.osm.ways.intersects(this.osm.ways.getEntityBbox(patchWay));
- const wayVersion = getEntityVersion(patchWay);
- const wayTagCount = Object.keys(patchWay.tags ?? {}).length;
- const candidateDuplicateWays: OsmWay[] = closeWayIndexes
- .map((index) => {
- const otherWay = this.osm.ways.getByIndex(index);
- if (otherWay.id === patchWay.id) return null;
-
- // Has this pair been deduped or checked already?
- if (dedupedIdPairs.has(patchWay.id, otherWay.id)) return null;
- dedupedIdPairs.add(patchWay.id, otherWay.id);
-
- // Check if all way properties other than the ID are equal
- if (isWayEqual(patchWay, otherWay)) return otherWay;
-
- // Check geometry
- const coords = this.osm.ways.getCoordinates(index);
- if (!dequal(wayCoords, coords)) return null;
-
- // Check version
- const otherWayVersion = getEntityVersion(otherWay);
- if (otherWayVersion < wayVersion) return null;
- if (otherWayVersion > wayVersion) return otherWay;
-
- // Ways are geometrically equal, with same version. Keep the way with more tags
- const tagCount = Object.keys(otherWay.tags ?? {}).length;
- return tagCount >= wayTagCount ? otherWay : null;
- })
- .filter((way) => way != null);
-
- if (candidateDuplicateWays.length === 0) return 0;
-
- const survivorIds = candidateDuplicateWays
- .map((way) => resolveReplacement(way.id, replacementMap))
- .filter((id, index, ids) => ids.indexOf(id) === index);
- if (survivorIds.length === 0) return 0;
- const survivorId = survivorIds.length === 1 ? survivorIds[0]! : Math.min(...survivorIds);
- const finalSurvivorId = resolveReplacement(survivorId, replacementMap);
- if (finalSurvivorId === patchWay.id) {
- throw Error(`Replacement cycle detected at way ${patchWay.id}`);
- }
- replacementMap.set(patchWay.id, finalSurvivorId);
- const flattenedMap = flattenReplacementMap(replacementMap);
- this.applyWayReplacementsToRelations(flattenedMap);
-
- // Delete this way
- this.delete(patchWay, [{ type: "way", id: finalSurvivorId, osmId: this.osm.id }]);
- this.deduplicatedWays++;
-
- return candidateDuplicateWays.length;
+ return this.deduplicateWayAgainstBase(
+ patchWay,
+ this.osm.ways.ids.has(patchWay.id),
+ dedupedIdPairs,
+ replacementMap,
+ );
}
/**
@@ -519,9 +929,22 @@ export class OsmChangeset {
*/
*createIntersectionsForWaysGenerator(ways: Ways) {
const wayIdPairs = new IdPairs();
+ const patchWayIds = new Set();
+ for (const way of ways) patchWayIds.add(way.id);
+ const metadata = this.buildIntersectionMetadata();
for (const way of ways) {
- if (!this.osm.ways.ids.has(way.id)) continue;
- yield this.createIntersectionsForWay({ id: way.id }, wayIdPairs);
+ // Yield once per input way so callers can report complete progress even
+ // when exact reconciliation already removed an equivalent patch way.
+ if (!this.osm.ways.ids.has(way.id)) {
+ yield;
+ continue;
+ }
+ yield this.createIntersectionsForWayInternal(
+ { id: way.id },
+ wayIdPairs,
+ patchWayIds,
+ metadata,
+ );
}
}
@@ -541,20 +964,91 @@ export class OsmChangeset {
return change?.entity ?? this.osm.nodes.getById(id);
}
+ /**
+ * Precompute the immutable tag checks used for every spatial candidate. Way
+ * refs change during insertion, but intersection eligibility and grade do not.
+ */
+ private buildIntersectionMetadata(): IntersectionMetadata {
+ const eligible = new Uint8Array(this.osm.ways.size);
+ const gradeIds = new Int32Array(this.osm.ways.size);
+ const grades = new Map();
+ let nextGradeId = 1;
+
+ for (let index = 0; index < this.osm.ways.size; index++) {
+ const wayId = this.osm.ways.ids.at(index);
+ const change = this.wayChanges[wayId];
+ if (change?.changeType === "delete") continue;
+ const tags = change ? change.entity.tags : this.osm.ways.tags.getTags(index);
+ if (!areWayTagsIntersectionCandidate(tags)) continue;
+ eligible[index] = 1;
+ const grade = routingGradeSignature(tags);
+ let gradeId = grades.get(grade);
+ if (gradeId === undefined) {
+ gradeId = nextGradeId++;
+ grades.set(grade, gradeId);
+ }
+ gradeIds[index] = gradeId;
+ }
+
+ return { eligible, gradeIds };
+ }
+
/**
* Resolve way coordinates from the base dataset plus pending node changes.
* Returns null when any ref is genuinely unavailable instead of substituting geometry.
*/
private getWayCoordinates(way: OsmWay): [number, number][] | null {
+ const wayRevision = this.wayGeometryRevisions.get(way.id) ?? 0;
+ const cached = this.wayCoordinateCache.get(way.id);
+ if (
+ cached &&
+ cached.wayRevision === wayRevision &&
+ cached.nodeCoordinateRevision === this.nodeCoordinateRevision
+ ) {
+ return cached.coordinates;
+ }
+
+ // Unchanged base geometry can resolve packed node indexes directly. This
+ // avoids one binary ID lookup per ref in the intersection hot path. Match
+ // the fallback's missing-ref behavior by requiring every ref to resolve.
+ if (this.nodeCoordinateRevision === 0 && this.wayChanges[way.id] === undefined) {
+ const [wayIndex] = this.osm.ways.ids.idOrIndex({ id: way.id });
+ if (wayIndex !== -1) {
+ const coordinates = this.osm.ways.getResolvedCoordinates(wayIndex);
+ if (coordinates.length !== way.refs.length) return null;
+ this.wayCoordinateCache.set(way.id, {
+ coordinates,
+ nodeCoordinateRevision: this.nodeCoordinateRevision,
+ wayRevision,
+ });
+ return coordinates;
+ }
+ }
+
const coordinates: [number, number][] = [];
for (const ref of way.refs) {
const node = this.getCurrentNode(ref);
+ // Do not cache unresolved geometry: a later node creation can make this
+ // same set of refs resolvable without changing the way revision.
if (!node) return null;
coordinates.push([node.lon, node.lat]);
}
+ this.wayCoordinateCache.set(way.id, {
+ coordinates,
+ nodeCoordinateRevision: this.nodeCoordinateRevision,
+ wayRevision,
+ });
return coordinates;
}
+ private getCleanWayCoordinates(way: OsmWay): [number, number][] | null {
+ const coordinates = this.getWayCoordinates(way);
+ if (!coordinates) return null;
+ const cached = this.wayCoordinateCache.get(way.id);
+ if (!cached) return cleanCoords(coordinates);
+ return (cached.cleaned ??= cleanCoords(coordinates));
+ }
+
/**
* Create intersections for a single way.
* - Finds other ways that intersect the given way's bounding box.
@@ -563,127 +1057,195 @@ export class OsmChangeset {
* - Inserts existing nodes or creates new intersection nodes at the crossing points.
*/
createIntersectionsForWay(wayIdOrIndex: IdOrIndex, wayIdPairs: IdPairs) {
+ return this.createIntersectionsForWayInternal(
+ wayIdOrIndex,
+ wayIdPairs,
+ null,
+ this.buildIntersectionMetadata(),
+ );
+ }
+
+ private createIntersectionsForWayInternal(
+ wayIdOrIndex: IdOrIndex,
+ wayIdPairs: IdPairs,
+ patchWayIds: ReadonlySet | null,
+ metadata: IntersectionMetadata,
+ ) {
let intersectionsFound = 0;
let intersectionsCreated = 0;
// Get the actual way from the OSM data (which may have been modified by deduplication)
const [wayIndex] = this.osm.ways.ids.idOrIndex(wayIdOrIndex);
- const way = this.getCurrentWay(this.osm.ways.getByIndex(wayIndex));
- if (!way) return;
- if (!isWayIntersectionCandidate(way)) return;
+ if (wayIndex >= 0 && metadata.eligible[wayIndex] !== 1) return;
+ const baseWay = this.osm.ways.getByIndex(wayIndex);
+ const initialWay = this.getCurrentWay(baseWay);
+ if (!initialWay) return;
- const wayCoordinates = this.getWayCoordinates(way);
- if (!wayCoordinates || wayCoordinates.length < 2) return;
+ const initialWayCoordinates = this.getWayCoordinates(initialWay);
+ if (!initialWayCoordinates || initialWayCoordinates.length < 2) return;
// Check for intersecting ways. Since the way exists in the base OSM, there will always be at least one way.
const bbox = this.osm.ways.getEntityBbox({ index: wayIndex });
- const intersectingWayIndexes = this.osm.ways.intersects(bbox);
- if (intersectingWayIndexes.length <= 1) return; // No candidates
+ const initialGradeId = metadata.gradeIds[wayIndex];
+ const intersectingWayIndexes = this.osm.ways.intersects(bbox, (intersectingWayIndex) => {
+ const intersectingWayId = this.osm.ways.ids.at(intersectingWayIndex);
+ if (intersectingWayId == null || intersectingWayId === initialWay.id) return false;
+ if (wayIdPairs.has(initialWay.id, intersectingWayId)) return false;
+
+ // The old loop recorded every spatial pair before checking routing and
+ // grade compatibility. Keep that side effect while avoiding entity and
+ // coordinate work for pairs that can never connect.
+ if (
+ metadata.eligible[intersectingWayIndex] !== 1 ||
+ metadata.gradeIds[intersectingWayIndex] !== initialGradeId
+ ) {
+ wayIdPairs.add(initialWay.id, intersectingWayId);
+ return false;
+ }
+ return true;
+ });
+ if (intersectingWayIndexes.length === 0) return;
- const coordinates = cleanCoords(wayCoordinates);
for (const intersectingWayIndex of intersectingWayIndexes) {
const intersectingWayId = this.osm.ways.ids.at(intersectingWayIndex);
// Skip self and null ways
- if (intersectingWayId == null || intersectingWayId === way.id) continue;
- if (wayIdPairs.has(way.id, intersectingWayId)) continue;
- wayIdPairs.add(way.id, intersectingWayId);
+ if (intersectingWayId == null || intersectingWayId === initialWay.id) continue;
+ if (wayIdPairs.has(initialWay.id, intersectingWayId)) continue;
+ wayIdPairs.add(initialWay.id, intersectingWayId);
// Skip ways that aren't applicable for connecting
+ const way = this.getCurrentWay(baseWay);
const intersectingWay = this.getCurrentWay(this.osm.ways.getByIndex(intersectingWayIndex));
- if (!intersectingWay) continue;
+ if (!way || !intersectingWay) continue;
if (!waysShouldConnect(way.tags, intersectingWay.tags)) continue;
+ const wayCoordinates = this.getWayCoordinates(way);
const intersectingWayCoordinates = this.getWayCoordinates(intersectingWay);
- if (!intersectingWayCoordinates || intersectingWayCoordinates.length < 2) continue;
- const intersectingWayCoords = cleanCoords(intersectingWayCoordinates);
+ if (
+ !wayCoordinates ||
+ wayCoordinates.length < 2 ||
+ !intersectingWayCoordinates ||
+ intersectingWayCoordinates.length < 2
+ ) {
+ continue;
+ }
+ const coordinates = this.getCleanWayCoordinates(way);
+ const intersectingWayCoords = this.getCleanWayCoordinates(intersectingWay);
+ if (!coordinates || !intersectingWayCoords) continue;
// Skip ways that are geometrically equal
if (dequal(coordinates, intersectingWayCoords)) continue;
const intersectingPoints = waysIntersect(coordinates, intersectingWayCoords);
for (const pt of intersectingPoints) {
+ const currentWay = this.getCurrentWay(baseWay);
+ // Reuse the already decoded base entity; getCurrentWay still selects any
+ // pending rewrite made by an earlier point in this same pair.
+ const currentIntersectingWay = this.getCurrentWay(intersectingWay);
+ if (!currentWay || !currentIntersectingWay) continue;
+ const currentWayCoordinates = this.getWayCoordinates(currentWay);
+ const currentIntersectingWayCoordinates = this.getWayCoordinates(currentIntersectingWay);
+ if (!currentWayCoordinates || !currentIntersectingWayCoordinates) continue;
+
const intersectingWayNodeId = nearestNodeOnWay(
- intersectingWay,
- intersectingWayCoords,
+ currentIntersectingWay,
+ currentIntersectingWayCoordinates,
pt,
).nodeId;
- const wayNodeId = nearestNodeOnWay(way, coordinates, pt).nodeId;
+ const wayNodeId = nearestNodeOnWay(currentWay, currentWayCoordinates, pt).nodeId;
// If both ways already share the same node at this intersection,
// just add the crossing tag (if needed) but don't count as an intersection.
- if (wayNodeId && intersectingWayNodeId && wayNodeId === intersectingWayNodeId) {
- const sharedNode = this.getCurrentNode(wayNodeId);
- if (sharedNode && !entityHasTagValue(sharedNode, "crossing", "yes")) {
- this.modify("node", sharedNode.id, (node) => {
- return {
- ...node,
- tags: { ...node.tags, crossing: "yes" },
- };
- });
- }
+ if (
+ wayNodeId != null &&
+ intersectingWayNodeId != null &&
+ wayNodeId === intersectingWayNodeId
+ ) {
+ this.markNodeAsCrossing(wayNodeId);
continue;
}
- intersectionsFound++;
-
- // Prefer the incoming way node, then the intersecting way node, then a new node.
- if (wayNodeId) {
+ let endpointResolution: ReturnType | undefined;
+ let createDedicatedIntersection = false;
+ if (wayNodeId != null && intersectingWayNodeId != null) {
const wayNode = this.getCurrentNode(wayNodeId);
- if (wayNode == null) throw Error(`Way node ${String(wayNodeId)} not found`);
- if (intersectingWayNodeId) {
- // Replace in intersecting way
- this.modify("way", intersectingWay.id, (way) => {
- return {
- ...way,
- refs: way.refs.map((ref) => (ref === intersectingWayNodeId ? wayNodeId : ref)),
- };
- });
- } else {
- this.spliceNodeIntoWay(intersectingWay, wayNode);
+ const intersectingWayNode = this.getCurrentNode(intersectingWayNodeId);
+ if (!wayNode || !intersectingWayNode) continue;
+ endpointResolution = this.chooseIntersectionNode(
+ wayNode,
+ intersectingWayNode,
+ patchWayIds?.has(currentWay.id) ?? false,
+ patchWayIds?.has(currentIntersectingWay.id) ?? false,
+ );
+ if (!endpointResolution) continue;
+ const rewrittenWay = endpointResolution.keepWayNode ? currentIntersectingWay : currentWay;
+ if (
+ this.intersectionReplacementIsUnsafe(
+ rewrittenWay,
+ endpointResolution.replaced.id,
+ endpointResolution.survivor.id,
+ )
+ ) {
+ endpointResolution = undefined;
+ createDedicatedIntersection = true;
}
+ }
- if (!entityHasTagValue(wayNode, "crossing", "yes")) {
- this.modify("node", wayNode.id, (node) => {
- return {
- ...node,
- tags: { ...node.tags, crossing: "yes" },
- };
- });
+ intersectionsFound++;
+
+ if (endpointResolution) {
+ const survivor = this.mergeNodeTags(
+ endpointResolution.survivor,
+ endpointResolution.replaced,
+ );
+ if (endpointResolution.keepWayNode) {
+ this.modify("way", currentIntersectingWay.id, (way) => ({
+ ...way,
+ refs: way.refs.map((ref) =>
+ ref === endpointResolution!.replaced.id ? survivor.id : ref,
+ ),
+ }));
+ } else {
+ this.modify("way", currentWay.id, (way) => ({
+ ...way,
+ refs: way.refs.map((ref) =>
+ ref === endpointResolution!.replaced.id ? survivor.id : ref,
+ ),
+ }));
}
- } else if (intersectingWayNodeId) {
+ this.replaceRestrictionViaNode(endpointResolution.replaced.id, survivor.id);
+ this.markNodeAsCrossing(survivor.id);
+ } else if (createDedicatedIntersection) {
+ intersectionsCreated++;
+ const newIntersectionNode = this.createIntersectionNode(
+ currentWay,
+ currentIntersectingWay,
+ pt,
+ );
+ this.spliceNodeIntoWay(currentWay, newIntersectionNode);
+ this.spliceNodeIntoWay(currentIntersectingWay, newIntersectionNode);
+ } else if (wayNodeId != null) {
+ const wayNode = this.getCurrentNode(wayNodeId);
+ if (wayNode == null) throw Error(`Way node ${String(wayNodeId)} not found`);
+ this.spliceNodeIntoWay(currentIntersectingWay, wayNode);
+ this.markNodeAsCrossing(wayNode.id);
+ } else if (intersectingWayNodeId != null) {
const intersectingWayNode = this.getCurrentNode(intersectingWayNodeId);
if (intersectingWayNode == null)
throw Error(`Intersecting way node ${String(intersectingWayNodeId)} not found`);
- this.spliceNodeIntoWay(way, intersectingWayNode);
- if (!entityHasTagValue(intersectingWayNode, "crossing", "yes")) {
- this.modify("node", intersectingWayNode.id, (node) => {
- return {
- ...node,
- tags: { ...node.tags, crossing: "yes" },
- };
- });
- }
+ this.spliceNodeIntoWay(currentWay, intersectingWayNode);
+ this.markNodeAsCrossing(intersectingWayNode.id);
} else {
intersectionsCreated++;
-
- const newIntersectionNode: OsmNode = {
- id: this.nextNodeId(),
- lon: pt[0],
- lat: pt[1],
- tags: {
- crossing: "yes",
- },
- };
- this.create(newIntersectionNode, this.osm.id, [
- { type: "way", id: way.id, osmId: this.osm.id },
- { type: "way", id: intersectingWay.id, osmId: this.osm.id },
- ]);
-
- // Splice into the existing ways
- this.spliceNodeIntoWay(way, newIntersectionNode);
- this.spliceNodeIntoWay(intersectingWay, newIntersectionNode);
+ const newIntersectionNode = this.createIntersectionNode(
+ currentWay,
+ currentIntersectingWay,
+ pt,
+ );
+ this.spliceNodeIntoWay(currentWay, newIntersectionNode);
+ this.spliceNodeIntoWay(currentIntersectingWay, newIntersectionNode);
}
}
}
@@ -697,6 +1259,28 @@ export class OsmChangeset {
};
}
+ private createIntersectionNode(
+ way: OsmWay,
+ intersectingWay: OsmWay,
+ point: [number, number],
+ ): OsmNode {
+ const node: OsmNode = {
+ id: this.nextNodeId(),
+ lon: point[0],
+ lat: point[1],
+ tags: {
+ crossing: "yes",
+ },
+ };
+ // The new maximum ID cannot be referenced by existing base ways, so adding
+ // it does not invalidate any cached geometry until each way is spliced.
+ this.recordCreate(node, this.osm.id, [
+ { type: "way", id: way.id, osmId: this.osm.id },
+ { type: "way", id: intersectingWay.id, osmId: this.osm.id },
+ ]);
+ return node;
+ }
+
/**
* We do not pass coordinates here because the way may have already been modified.
*/
@@ -704,32 +1288,50 @@ export class OsmChangeset {
const currentWay = this.getCurrentWay(way);
if (!currentWay) return;
const coordinates = this.getWayCoordinates(currentWay);
- if (!coordinates || coordinates.length === 0) return;
- const { refIndex } = nearestNodeOnWay(
- currentWay,
- coordinates,
- [node.lon, node.lat],
- Number.POSITIVE_INFINITY,
- );
- if (refIndex < 0) return;
+ if (!coordinates || coordinates.length < 2 || currentWay.refs.includes(node.id)) return;
+
+ let closestSegment = -1;
+ let closestDistance = Number.POSITIVE_INFINITY;
+ for (let index = 0; index < coordinates.length - 1; index++) {
+ const start = coordinates[index]!;
+ const end = coordinates[index + 1]!;
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ const lengthSquared = dx * dx + dy * dy;
+ if (lengthSquared === 0) continue;
+ const projection = ((node.lon - start[0]) * dx + (node.lat - start[1]) * dy) / lengthSquared;
+ const parameter = Math.max(0, Math.min(1, projection));
+ const projectedLon = start[0] + parameter * dx;
+ const projectedLat = start[1] + parameter * dy;
+ const distance = (node.lon - projectedLon) ** 2 + (node.lat - projectedLat) ** 2;
+ if (distance >= closestDistance) continue;
+ closestDistance = distance;
+ closestSegment = index;
+ }
+ if (closestSegment < 0) return;
this.modify("way", way.id, (way) => ({
...way,
- refs: way.refs.toSpliced(refIndex, 0, node.id),
+ refs: way.refs.toSpliced(closestSegment + 1, 0, node.id),
}));
}
/**
- * Create changes to merge nodes, ways, and relations from a patch OSM file into the base OSM.
- * - Check for duplicate nodes in the patch, replace the existing nodes where appropriate.
- * - Check for duplicate incoming ways, only add single instances of geometrically equal ways.
+ * Create direct same-ID modifications and new-entity changes from a patch OSM file.
*
* Implementation notes:
- * - Ways are processed before nodes to improve node deduplication accuracy (see comment on line 633).
- * - Node replacements in relations are handled by `applyNodeReplacementsToRelations()` when
- * deduplicating nodes, but relation member updates during direct merge are not automatically
- * handled. Use `deduplicateNodes()` after `generateDirectChanges()` if relation updates are needed.
+ * - Ways are processed before nodes so subsequent node reconciliation can inspect pending ways.
+ * - Call `deduplicateNodes()` and `deduplicateWays()` afterward for conservative cross-dataset
+ * reconciliation and relation-member rewrites.
*/
generateDirectChanges(patch: Osm) {
+ for (const key of inheritedRoutingIntegrityIssueKeys(
+ this.osm,
+ patch,
+ this.routingIntegrityBaselineKeys,
+ )) {
+ this.routingIntegrityBaselineKeys.add(key);
+ }
+
// Reset the current node ID to the highest node ID in the base or patch.
const maximums = [maximumId(this.osm.nodes.ids), maximumId(patch.nodes.ids)].filter(
(id): id is number => id !== null,
@@ -781,18 +1383,25 @@ export class OsmChangeset {
}
class IdPairs {
- #idPairs = new Set();
-
- #makeIdsKey(wayIds: number[]) {
- return wayIds.toSorted((a, b) => a - b).join(",");
- }
-
- add(...wayIds: number[]) {
- this.#idPairs.add(this.#makeIdsKey(wayIds));
+ #idPairs = new Map>();
+
+ add(firstId: number, secondId: number) {
+ const lowerId = Math.min(firstId, secondId);
+ const higherId = Math.max(firstId, secondId);
+ const partners = this.#idPairs.get(lowerId);
+ if (partners === undefined) this.#idPairs.set(lowerId, higherId);
+ else if (typeof partners === "number") {
+ if (partners !== higherId) this.#idPairs.set(lowerId, new Set([partners, higherId]));
+ } else partners.add(higherId);
}
- has(...wayIds: number[]) {
- return this.#idPairs.has(this.#makeIdsKey(wayIds));
+ has(firstId: number, secondId: number) {
+ const lowerId = Math.min(firstId, secondId);
+ const higherId = Math.max(firstId, secondId);
+ const partners = this.#idPairs.get(lowerId);
+ return typeof partners === "number"
+ ? partners === higherId
+ : (partners?.has(higherId) ?? false);
}
clear() {
diff --git a/packages/change/src/conflation.ts b/packages/change/src/conflation.ts
new file mode 100644
index 00000000..2a6c4d6a
--- /dev/null
+++ b/packages/change/src/conflation.ts
@@ -0,0 +1,1645 @@
+/** Safe, explicit proximity conflation for imported OSM-like datasets. */
+
+import type { Osm } from "@osmix/core";
+import { haversineDistance } from "@osmix/geo/haversine-distance";
+import type { ProgressEvent } from "@osmix/shared/progress";
+import type { LonLat, OsmEntity, OsmNode, OsmRelation, OsmTags, OsmWay } from "@osmix/types";
+
+import { applyChangesetToOsm } from "./apply-changeset.ts";
+import { OsmChangeset } from "./changeset.ts";
+import { generateChangeset } from "./generate-changeset.ts";
+import { assertConflationPreservesBaseTopology } from "./integrity.ts";
+import type {
+ OsmConflationActionAssessment,
+ OsmConflationBulkDecisionRequest,
+ OsmConflationBulkDecisionResult,
+ OsmConflationCandidate,
+ OsmConflationCandidateFilter,
+ OsmConflationDecision,
+ OsmConflationDiscovery,
+ OsmConflationEffectiveStatus,
+ OsmConflationEvidence,
+ OsmConflationOptions,
+ OsmConflationReasonCode,
+ OsmConflationRoutingFamily,
+ OsmConflationSummary,
+ OsmConflationTagDiff,
+ OsmMergeOptions,
+ ResolvedOsmConflationOptions,
+} from "./types.ts";
+import { routingGradeSignature } from "./utils.ts";
+
+// Preserve the historical one-meter matching radius, but only inside this explicit,
+// cross-dataset workflow. Proximity alone never authorizes a topology change.
+const DEFAULT_MAX_DISTANCE_METERS = 1;
+const MAX_BEARING_DIFFERENCE_DEGREES = 30;
+const MAX_LENGTH_DIFFERENCE_RATIO = 0.05;
+const SAMPLE_INTERVAL_METERS = 5;
+
+const PEDESTRIAN_HIGHWAYS = new Set(["corridor", "footway", "path", "pedestrian", "steps"]);
+const BICYCLE_HIGHWAYS = new Set(["cycleway"]);
+const NON_MOTOR_HIGHWAYS = new Set([...PEDESTRIAN_HIGHWAYS, ...BICYCLE_HIGHWAYS, "bridleway"]);
+// Access and routing checks also recognize namespaced variants (for example
+// `access:conditional` and `maxspeed:forward`) so they cannot bypass review.
+const ACCESS_KEYS = [
+ "access",
+ "agricultural",
+ "atv",
+ "bicycle",
+ "bus",
+ "caravan",
+ "carriage",
+ "coach",
+ "emergency",
+ "foot",
+ "forestry",
+ "golf_cart",
+ "goods",
+ "horse",
+ "hgv",
+ "hgv_articulated",
+ "hov",
+ "inline_skates",
+ "mofa",
+ "moped",
+ "motorcycle",
+ "motor_vehicle",
+ "motorcar",
+ "motorhome",
+ "psv",
+ "ski",
+ "snowmobile",
+ "taxi",
+ "tourist_bus",
+ "trailer",
+ "vehicle",
+ "wheelchair",
+] as const;
+const PROTECTED_KEYS = new Set([
+ "area",
+ "bridge",
+ "covered",
+ "layer",
+ "level",
+ "restriction",
+ "tunnel",
+ "type",
+]);
+const ROUTING_KEYS = new Set([
+ ...ACCESS_KEYS,
+ "barrier",
+ "crossing",
+ "highway",
+ "junction",
+ "kerb",
+ "maxspeed",
+ "oneway",
+]);
+
+type EntityRelationContext = {
+ nodes: Set;
+ ways: Set;
+ restrictionNodes: Set;
+ restrictionWays: Set;
+};
+
+type DiscoveryContext = {
+ base: Osm;
+ patch: Osm;
+ options: ResolvedOsmConflationOptions;
+ baseWaysByNode: Map;
+ patchWaysByNode: Map;
+ baseRelations: EntityRelationContext;
+ patchRelations: EntityRelationContext;
+};
+
+// Trusted merge orchestrators keep untouched Osm objects and canonical discovery
+// in the same module instance. This weak registry lets that internal path reuse an
+// expensive discovery without weakening the public generation boundary, which
+// still recomputes candidates before it accepts caller-provided review data.
+const trustedDiscoveries = new WeakMap();
+const trustedCandidateCollections = new WeakSet();
+const trustedCandidateIds = new WeakMap>();
+
+function resolvedOptions(options: OsmConflationOptions): ResolvedOsmConflationOptions {
+ if (!Array.isArray(options.propertyKeys)) {
+ throw Error("Conflation propertyKeys must be an array");
+ }
+ if (options.propertyKeys.some((key) => typeof key !== "string" || key.length === 0)) {
+ throw Error("Conflation propertyKeys must contain only non-empty strings");
+ }
+ if (typeof options.attachNetwork !== "boolean") {
+ throw Error("Conflation attachNetwork must be a boolean");
+ }
+ if (options.automatic != null && !["high-confidence", "none"].includes(options.automatic)) {
+ throw Error("Conflation automatic must be high-confidence or none");
+ }
+ const maxDistanceMeters = options.maxDistanceMeters ?? DEFAULT_MAX_DISTANCE_METERS;
+ if (!Number.isFinite(maxDistanceMeters) || maxDistanceMeters <= 0) {
+ throw Error("Conflation maxDistanceMeters must be a positive finite number");
+ }
+ const propertyKeys = [...new Set(options.propertyKeys)].toSorted();
+ if (propertyKeys.length === 0 && !options.attachNetwork) {
+ throw Error("Conflation requires at least one property key or network attachment");
+ }
+ return {
+ propertyKeys,
+ attachNetwork: options.attachNetwork,
+ maxDistanceMeters,
+ automatic: options.automatic ?? "high-confidence",
+ };
+}
+
+function candidateId(entityType: "node" | "way", sourceId: number, targetId: number | null) {
+ return `${entityType}:${sourceId}->${targetId ?? "none"}`;
+}
+
+function uniqueReasons(reasons: readonly OsmConflationReasonCode[]) {
+ return [...new Set(reasons)].toSorted();
+}
+
+function roundEvidence(value: number) {
+ return Number(value.toFixed(6));
+}
+
+function waysByNode(osm: Osm) {
+ const result = new Map();
+ for (const way of osm.ways) {
+ for (const ref of new Set(way.refs)) {
+ const ways = result.get(ref) ?? [];
+ ways.push(way);
+ result.set(ref, ways);
+ }
+ }
+ return result;
+}
+
+function relationContext(osm: Osm): EntityRelationContext {
+ const context: EntityRelationContext = {
+ nodes: new Set(),
+ ways: new Set(),
+ restrictionNodes: new Set(),
+ restrictionWays: new Set(),
+ };
+ for (const relation of osm.relations) {
+ const restriction = relation.tags?.["type"] === "restriction";
+ for (const member of relation.members) {
+ if (member.type === "node") {
+ context.nodes.add(member.ref);
+ if (restriction) context.restrictionNodes.add(member.ref);
+ } else if (member.type === "way") {
+ context.ways.add(member.ref);
+ if (restriction) context.restrictionWays.add(member.ref);
+ }
+ }
+ }
+ return context;
+}
+
+function isAreaWay(way: OsmWay) {
+ if (String(way.tags?.["area"] ?? "") === "yes") return true;
+ if (way.refs.length < 4 || way.refs[0] !== way.refs.at(-1)) return false;
+ return ["building", "landuse", "natural", "boundary"].some((key) => way.tags?.[key] != null);
+}
+
+function wayRoutingFamily(way: OsmWay): OsmConflationRoutingFamily {
+ const highway = String(way.tags?.["highway"] ?? "");
+ if (!highway || isAreaWay(way)) return "non-routable";
+ if (
+ BICYCLE_HIGHWAYS.has(highway) ||
+ (highway === "path" && !["no", "private"].includes(String(way.tags?.["bicycle"] ?? "")))
+ ) {
+ return "bicycle-shared";
+ }
+ if (PEDESTRIAN_HIGHWAYS.has(highway)) return "pedestrian";
+ // Unknown highway values stay in the motor family. Treating a potentially
+ // drivable way as non-routable would make an unsafe attachment look harmless.
+ if (!NON_MOTOR_HIGHWAYS.has(highway)) return "motor-road";
+ return "non-routable";
+}
+
+function routingFamilies(ways: readonly OsmWay[]) {
+ const families = new Set(ways.map(wayRoutingFamily));
+ if (families.size > 1) families.delete("non-routable");
+ return [...families].toSorted() as OsmConflationRoutingFamily[];
+}
+
+function familyCompatible(a: OsmConflationRoutingFamily, b: OsmConflationRoutingFamily) {
+ if (a === b) return true;
+ return (
+ (a === "pedestrian" && b === "bicycle-shared") || (a === "bicycle-shared" && b === "pedestrian")
+ );
+}
+
+function accessSignature(tags: OsmTags | undefined) {
+ return Object.keys(tags ?? {})
+ .filter((key) =>
+ ACCESS_KEYS.some((accessKey) => key === accessKey || key.startsWith(`${accessKey}:`)),
+ )
+ .toSorted()
+ .map((key) => `${key}=${String(tags?.[key] ?? "")}`)
+ .join("|");
+}
+
+// These signatures intentionally compare both presence and value. Rewriting a
+// patch reference must not strand node-level routing semantics on the discarded node.
+function barrierSignature(tags: OsmTags | undefined) {
+ return Object.keys(tags ?? {})
+ .filter((key) => key === "barrier" || key.startsWith("barrier:"))
+ .toSorted()
+ .map((key) => `${key}=${String(tags?.[key] ?? "")}`)
+ .join("|");
+}
+
+function nodeRoutingSignature(tags: OsmTags | undefined) {
+ return Object.keys(tags ?? {})
+ .filter(
+ (key) =>
+ isRoutingProperty(key) &&
+ !ACCESS_KEYS.some((accessKey) => key === accessKey || key.startsWith(`${accessKey}:`)) &&
+ key !== "barrier" &&
+ !key.startsWith("barrier:"),
+ )
+ .toSorted()
+ .map((key) => `${key}=${String(tags?.[key] ?? "")}`)
+ .join("|");
+}
+
+function wayContextsCompatible(source: OsmWay, target: OsmWay) {
+ return (
+ familyCompatible(wayRoutingFamily(source), wayRoutingFamily(target)) &&
+ wayGradeAccessCompatible(source, target)
+ );
+}
+
+function wayGradeAccessCompatible(source: OsmWay, target: OsmWay) {
+ return (
+ routingGradeSignature(source.tags) === routingGradeSignature(target.tags) &&
+ accessSignature(source.tags) === accessSignature(target.tags)
+ );
+}
+
+function normalizedOneway(way: OsmWay) {
+ const value = String(way.tags?.["oneway"] ?? "").toLowerCase();
+ if (["yes", "true", "1"].includes(value)) return "forward";
+ if (["-1", "reverse"].includes(value)) return "reverse";
+ if (String(way.tags?.["junction"] ?? "") === "roundabout" && value !== "no") {
+ return "forward";
+ }
+ return "both";
+}
+
+function reversedOneway(value: ReturnType) {
+ return value === "forward" ? "reverse" : value === "reverse" ? "forward" : value;
+}
+
+function wayRoutingSemanticsCompatible(source: OsmWay, target: OsmWay, targetReversed: boolean) {
+ const targetOneway = normalizedOneway(target);
+ if (normalizedOneway(source) !== (targetReversed ? reversedOneway(targetOneway) : targetOneway)) {
+ return false;
+ }
+ const routingKeys = new Set(
+ [...Object.keys(source.tags ?? {}), ...Object.keys(target.tags ?? {})].filter(
+ (key) => isRoutingProperty(key) && key !== "oneway",
+ ),
+ );
+ if (
+ targetReversed &&
+ // Reversed geometry is safe only when no remaining routing tag has a direction
+ // whose meaning would also need to be inverted or swapped.
+ [...routingKeys].some(
+ (key) =>
+ key.startsWith("oneway:") ||
+ key.split(":").some((part) => ["backward", "forward", "left", "right"].includes(part)),
+ )
+ ) {
+ return false;
+ }
+ return [...routingKeys].every(
+ (key) => String(source.tags?.[key] ?? "") === String(target.tags?.[key] ?? ""),
+ );
+}
+
+function isProtectedProperty(key: string) {
+ return PROTECTED_KEYS.has(key) || key.startsWith("restriction:");
+}
+
+function isRoutingProperty(key: string) {
+ return [...ROUTING_KEYS].some(
+ (routingKey) => key === routingKey || key.startsWith(`${routingKey}:`),
+ );
+}
+
+function selectedTagDiff(
+ source: OsmEntity,
+ target: OsmEntity,
+ propertyKeys: readonly string[],
+): OsmConflationTagDiff[] {
+ const result: OsmConflationTagDiff[] = [];
+ for (const key of propertyKeys) {
+ const patchValue = source.tags?.[key];
+ if (patchValue == null || target.tags?.[key] === patchValue) continue;
+ result.push({
+ key,
+ patchValue,
+ baseValue: target.tags?.[key],
+ protected: isProtectedProperty(key),
+ routing: isRoutingProperty(key),
+ });
+ }
+ return result;
+}
+
+function propertyAssessment(
+ tagDiff: readonly OsmConflationTagDiff[],
+ options: ResolvedOsmConflationOptions,
+): OsmConflationActionAssessment {
+ if (tagDiff.length === 0) {
+ return { status: "blocked", reasons: ["no-transferable-properties"] };
+ }
+ const transferable = tagDiff.filter((diff) => !diff.protected);
+ if (transferable.length === 0) return { status: "blocked", reasons: ["protected-tag"] };
+
+ const reasons: OsmConflationReasonCode[] = [];
+ if (transferable.some((diff) => diff.routing)) reasons.push("routing-property");
+ if (tagDiff.some((diff) => diff.protected)) reasons.push("protected-tag");
+ if (reasons.length > 0 || options.automatic === "none") {
+ return { status: "review", reasons: uniqueReasons(reasons) };
+ }
+ return { status: "automatic", reasons: [] };
+}
+
+function nodePropertyAssessment(
+ context: DiscoveryContext,
+ patchWays: readonly OsmWay[],
+ baseWays: readonly OsmWay[],
+ tagDiff: readonly OsmConflationTagDiff[],
+) {
+ const assessment = propertyAssessment(tagDiff, context.options);
+ if (assessment.status === "blocked") return assessment;
+
+ const patchAreaOnly = patchWays.length > 0 && patchWays.every(isAreaWay);
+ const baseAreaOnly = baseWays.length > 0 && baseWays.every(isAreaWay);
+ const patchRoutable = patchWays.filter((way) => wayRoutingFamily(way) !== "non-routable");
+ const baseRoutable = baseWays.filter((way) => wayRoutingFamily(way) !== "non-routable");
+ const reasons = [...assessment.reasons];
+ let hardConflict = false;
+ if (patchAreaOnly !== baseAreaOnly && (patchAreaOnly || baseAreaOnly)) {
+ reasons.push("non-routing-target");
+ hardConflict = true;
+ }
+ if (patchRoutable.length > 0 && baseRoutable.length > 0) {
+ const patchFamilies = routingFamilies(patchRoutable);
+ const baseFamilies = routingFamilies(baseRoutable);
+ if (
+ !patchFamilies.every((family) =>
+ baseFamilies.some((baseFamily) => familyCompatible(family, baseFamily)),
+ )
+ ) {
+ reasons.push("routing-family-conflict");
+ }
+ if (
+ !patchRoutable.every((source) =>
+ baseRoutable.some((target) => source.tags?.["highway"] === target.tags?.["highway"]),
+ )
+ ) {
+ reasons.push("routing-family-conflict");
+ }
+ if (
+ !patchRoutable.every((source) =>
+ baseRoutable.some((target) => wayGradeAccessCompatible(source, target)),
+ )
+ ) {
+ reasons.push("grade-conflict");
+ hardConflict = true;
+ }
+ } else if (
+ (patchRoutable.length > 0 && baseWays.length > 0) ||
+ (baseRoutable.length > 0 && patchWays.length > 0)
+ ) {
+ reasons.push("non-routing-target");
+ hardConflict = true;
+ }
+ assessment.reasons = uniqueReasons(reasons);
+ if (hardConflict) assessment.status = "blocked";
+ else if (assessment.reasons.length > 0 && assessment.status === "automatic") {
+ assessment.status = "review";
+ }
+ return assessment;
+}
+
+function lineLength(coordinates: readonly LonLat[]) {
+ let total = 0;
+ for (let index = 1; index < coordinates.length; index++) {
+ total += haversineDistance(coordinates[index - 1]!, coordinates[index]!);
+ }
+ return total;
+}
+
+function interpolate(a: LonLat, b: LonLat, parameter: number): LonLat {
+ return [a[0] + (b[0] - a[0]) * parameter, a[1] + (b[1] - a[1]) * parameter];
+}
+
+function sampleLine(coordinates: readonly LonLat[]) {
+ if (coordinates.length <= 1) return [...coordinates];
+ const result: LonLat[] = [coordinates[0]!];
+ for (let index = 1; index < coordinates.length; index++) {
+ const start = coordinates[index - 1]!;
+ const end = coordinates[index]!;
+ const length = haversineDistance(start, end);
+ const samples = Math.floor(length / SAMPLE_INTERVAL_METERS);
+ for (let sample = 1; sample <= samples; sample++) {
+ const distance = sample * SAMPLE_INTERVAL_METERS;
+ if (distance >= length) break;
+ result.push(interpolate(start, end, distance / length));
+ }
+ result.push(end);
+ }
+ return result;
+}
+
+function pointSegmentDistance(point: LonLat, start: LonLat, end: LonLat) {
+ const latitudeRadians = (point[1] * Math.PI) / 180;
+ const xScale = 111_320 * Math.cos(latitudeRadians);
+ const yScale = 110_574;
+ const startX = (start[0] - point[0]) * xScale;
+ const startY = (start[1] - point[1]) * yScale;
+ const endX = (end[0] - point[0]) * xScale;
+ const endY = (end[1] - point[1]) * yScale;
+ const dx = endX - startX;
+ const dy = endY - startY;
+ const denominator = dx * dx + dy * dy;
+ const parameter =
+ denominator === 0 ? 0 : Math.max(0, Math.min(1, -(startX * dx + startY * dy) / denominator));
+ return Math.hypot(startX + parameter * dx, startY + parameter * dy);
+}
+
+function pointLineDistance(point: LonLat, line: readonly LonLat[]) {
+ let minimum = Number.POSITIVE_INFINITY;
+ for (let index = 1; index < line.length; index++) {
+ minimum = Math.min(minimum, pointSegmentDistance(point, line[index - 1]!, line[index]!));
+ }
+ return minimum;
+}
+
+function symmetricLineDistance(a: readonly LonLat[], b: readonly LonLat[]) {
+ let maximum = 0;
+ for (const point of sampleLine(a)) maximum = Math.max(maximum, pointLineDistance(point, b));
+ for (const point of sampleLine(b)) maximum = Math.max(maximum, pointLineDistance(point, a));
+ return maximum;
+}
+
+function wayCoordinates(osm: Osm, way: OsmWay) {
+ const index = osm.ways.ids.getIndexFromId(way.id);
+ return index < 0 ? [] : osm.ways.getResolvedCoordinates(index);
+}
+
+function lineBbox(
+ coordinates: readonly LonLat[],
+ paddingMeters: number,
+): [number, number, number, number] {
+ let minLon = Number.POSITIVE_INFINITY;
+ let minLat = Number.POSITIVE_INFINITY;
+ let maxLon = Number.NEGATIVE_INFINITY;
+ let maxLat = Number.NEGATIVE_INFINITY;
+ for (const [lon, lat] of coordinates) {
+ minLon = Math.min(minLon, lon);
+ minLat = Math.min(minLat, lat);
+ maxLon = Math.max(maxLon, lon);
+ maxLat = Math.max(maxLat, lat);
+ }
+ const middleLat = (minLat + maxLat) / 2;
+ const latPadding = paddingMeters / 110_574;
+ const lonPadding =
+ paddingMeters / (111_320 * Math.max(0.01, Math.cos((middleLat * Math.PI) / 180)));
+ return [minLon - lonPadding, minLat - latPadding, maxLon + lonPadding, maxLat + latPadding];
+}
+
+function bearing(from: LonLat, to: LonLat) {
+ const latitude1 = (from[1] * Math.PI) / 180;
+ const latitude2 = (to[1] * Math.PI) / 180;
+ const deltaLongitude = ((to[0] - from[0]) * Math.PI) / 180;
+ const y = Math.sin(deltaLongitude) * Math.cos(latitude2);
+ const x =
+ Math.cos(latitude1) * Math.sin(latitude2) -
+ Math.sin(latitude1) * Math.cos(latitude2) * Math.cos(deltaLongitude);
+ return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
+}
+
+function undirectedBearingDifference(a: number, b: number) {
+ const directed = Math.abs(a - b) % 360;
+ return Math.min(directed, 360 - directed, Math.abs(180 - directed));
+}
+
+function nodeSegments(osm: Osm, nodeId: number, ways: readonly OsmWay[]) {
+ const node = osm.nodes.getById(nodeId);
+ if (!node) return [];
+ const segments: { bearing: number; way: OsmWay }[] = [];
+ for (const way of ways) {
+ for (let index = 0; index < way.refs.length; index++) {
+ if (way.refs[index] !== nodeId) continue;
+ for (const neighborIndex of [index - 1, index + 1]) {
+ const neighborId = way.refs[neighborIndex];
+ if (neighborId == null || neighborId === nodeId) continue;
+ const neighbor = osm.nodes.getById(neighborId);
+ if (!neighbor) continue;
+ segments.push({
+ bearing: bearing([node.lon, node.lat], [neighbor.lon, neighbor.lat]),
+ way,
+ });
+ }
+ }
+ }
+ return segments;
+}
+
+function nodeAttachmentAssessment(
+ context: DiscoveryContext,
+ source: OsmNode,
+ target: OsmNode,
+ patchWays: readonly OsmWay[],
+ baseWays: readonly OsmWay[],
+): { assessment: OsmConflationActionAssessment; evidence: Partial } {
+ if (!context.options.attachNetwork)
+ return { assessment: { status: "blocked", reasons: [] }, evidence: {} };
+ const sourceWays = patchWays.filter(
+ (way) => !context.base.ways.ids.has(way.id) && wayRoutingFamily(way) !== "non-routable",
+ );
+ const targetWays = baseWays.filter((way) => wayRoutingFamily(way) !== "non-routable");
+ if (sourceWays.length === 0 || targetWays.length === 0) {
+ return {
+ assessment: { status: "blocked", reasons: ["non-routing-target"] },
+ evidence: { patchWayIds: sourceWays.map((way) => way.id).toSorted((a, b) => a - b) },
+ };
+ }
+
+ // Hard reasons describe invariants a manual decision cannot override. Review
+ // reasons are plausible matches whose routing intent still needs a person.
+ const hardReasons: OsmConflationReasonCode[] = [];
+ const reviewReasons: OsmConflationReasonCode[] = [];
+ if (routingGradeSignature(source.tags) !== routingGradeSignature(target.tags)) {
+ hardReasons.push("grade-conflict");
+ }
+ if (accessSignature(source.tags) !== accessSignature(target.tags)) {
+ hardReasons.push("routing-family-conflict");
+ }
+ const sourceBarrier = barrierSignature(source.tags);
+ const targetBarrier = barrierSignature(target.tags);
+ if (sourceBarrier !== targetBarrier) hardReasons.push("routing-family-conflict");
+ else if (sourceBarrier !== "") reviewReasons.push("node-context-conflict");
+ if (nodeRoutingSignature(source.tags) !== nodeRoutingSignature(target.tags)) {
+ hardReasons.push("routing-family-conflict");
+ }
+ if (
+ ["layer", "level", "bridge", "tunnel", "covered"].some(
+ (key) => source.tags?.[key] != null || target.tags?.[key] != null,
+ )
+ ) {
+ reviewReasons.push("node-context-conflict");
+ }
+ const restrictionMember =
+ context.patchRelations.restrictionNodes.has(source.id) ||
+ context.baseRelations.restrictionNodes.has(target.id) ||
+ sourceWays.some((way) => context.patchRelations.restrictionWays.has(way.id)) ||
+ targetWays.some((way) => context.baseRelations.restrictionWays.has(way.id));
+ const relationMember =
+ context.patchRelations.nodes.has(source.id) ||
+ context.baseRelations.nodes.has(target.id) ||
+ sourceWays.some((way) => context.patchRelations.ways.has(way.id)) ||
+ targetWays.some((way) => context.baseRelations.ways.has(way.id));
+ if (restrictionMember) hardReasons.push("relation-member");
+ else if (relationMember) reviewReasons.push("relation-member");
+
+ const sourceFamilies = routingFamilies(sourceWays);
+ const targetFamilies = routingFamilies(targetWays);
+ if (
+ !sourceFamilies.every((family) =>
+ targetFamilies.some((targetFamily) => familyCompatible(family, targetFamily)),
+ )
+ ) {
+ reviewReasons.push("routing-family-conflict");
+ }
+ if (sourceFamilies.includes("motor-road")) reviewReasons.push("drivable-network");
+ if (
+ !sourceWays.every((sourceWay) =>
+ targetWays.some((targetWay) => sourceWay.tags?.["highway"] === targetWay.tags?.["highway"]),
+ )
+ ) {
+ reviewReasons.push("routing-family-conflict");
+ }
+
+ const gradeCompatible = sourceWays.every((sourceWay) =>
+ targetWays.some(
+ (targetWay) =>
+ routingGradeSignature(sourceWay.tags) === routingGradeSignature(targetWay.tags) &&
+ accessSignature(sourceWay.tags) === accessSignature(targetWay.tags),
+ ),
+ );
+ if (!gradeCompatible) hardReasons.push("grade-conflict");
+
+ const sourceSegments = nodeSegments(context.patch, source.id, sourceWays);
+ const targetSegments = nodeSegments(context.base, target.id, targetWays);
+ let maximumMinimumBearingDifference = 0;
+ // Every imported incident segment needs at least one compatible base segment.
+ // Taking the worst best-match prevents one aligned arm from hiding another.
+ for (const sourceSegment of sourceSegments) {
+ const compatibleTargets = targetSegments.filter((targetSegment) =>
+ wayContextsCompatible(sourceSegment.way, targetSegment.way),
+ );
+ const minimum = compatibleTargets.reduce(
+ (value, targetSegment) =>
+ Math.min(value, undirectedBearingDifference(sourceSegment.bearing, targetSegment.bearing)),
+ Number.POSITIVE_INFINITY,
+ );
+ maximumMinimumBearingDifference = Math.max(maximumMinimumBearingDifference, minimum);
+ }
+ if (
+ sourceSegments.length === 0 ||
+ !Number.isFinite(maximumMinimumBearingDifference) ||
+ maximumMinimumBearingDifference > MAX_BEARING_DIFFERENCE_DEGREES
+ ) {
+ reviewReasons.push("bearing-mismatch");
+ }
+
+ for (const way of sourceWays) {
+ const replacedRefs = way.refs.map((ref) => (ref === source.id ? target.id : ref));
+ const adjacentDuplicate = replacedRefs.some(
+ (ref, index) => index > 0 && ref === replacedRefs[index - 1],
+ );
+ if (adjacentDuplicate || new Set(replacedRefs).size < 2) hardReasons.push("would-collapse-way");
+ }
+
+ const reasons = uniqueReasons([...hardReasons, ...reviewReasons]);
+ const status =
+ hardReasons.length > 0
+ ? "blocked"
+ : reviewReasons.length > 0 || context.options.automatic === "none"
+ ? "review"
+ : "automatic";
+ return {
+ assessment: { status, reasons },
+ evidence: {
+ patchWayIds: sourceWays.map((way) => way.id).toSorted((a, b) => a - b),
+ bearingDifferenceDegrees: Number.isFinite(maximumMinimumBearingDifference)
+ ? roundEvidence(maximumMinimumBearingDifference)
+ : undefined,
+ },
+ };
+}
+
+function overallAssessment(
+ property: OsmConflationActionAssessment,
+ attachment: OsmConflationActionAssessment | null,
+ options: ResolvedOsmConflationOptions,
+) {
+ const enabled = [
+ ...(options.propertyKeys.length > 0 ? [property] : []),
+ ...(options.attachNetwork && attachment ? [attachment] : []),
+ ];
+ const reasons = uniqueReasons(enabled.flatMap((assessment) => assessment.reasons));
+ if (enabled.some((assessment) => assessment.status === "review")) {
+ return { status: "review" as const, reasons };
+ }
+ if (enabled.some((assessment) => assessment.status === "automatic")) {
+ return { status: "automatic" as const, reasons };
+ }
+ return { status: "blocked" as const, reasons };
+}
+
+function addReviewReason(candidate: OsmConflationCandidate, reason: OsmConflationReasonCode) {
+ for (const assessment of [candidate.propertyTransfer, candidate.networkAttachment]) {
+ if (!assessment || assessment.status === "blocked" || assessment.status === "unmatched") {
+ continue;
+ }
+ if (assessment.status === "automatic") assessment.status = "review";
+ assessment.reasons = uniqueReasons([...assessment.reasons, reason]);
+ }
+ candidate.reasons = uniqueReasons([...candidate.reasons, reason]);
+ if (candidate.status === "automatic") candidate.status = "review";
+}
+
+function discoverNodeCandidates(context: DiscoveryContext) {
+ const candidates: OsmConflationCandidate[] = [];
+ for (const source of context.patch.nodes.sorted()) {
+ // Same-ID entities belong to ordinary merge semantics; fuzzy matching must not
+ // reinterpret an authoritative patch update.
+ if (context.base.nodes.ids.has(source.id)) continue;
+ const patchWays = context.patchWaysByNode.get(source.id) ?? [];
+ const eligible =
+ context.options.propertyKeys.some((key) => source.tags?.[key] != null) ||
+ (context.options.attachNetwork &&
+ patchWays.some((way) => !context.base.ways.ids.has(way.id)));
+ if (!eligible) continue;
+
+ const nearby = context.base.nodes
+ .findIndexesWithinRadius(source.lon, source.lat, context.options.maxDistanceMeters / 1_000)
+ .map((index) => context.base.nodes.getByIndex(index));
+ // A base ID also present in the patch is mutable under direct merge, so it is
+ // not an immutable target for a different imported entity.
+ const targets = nearby.filter((target) => !context.patch.nodes.ids.has(target.id));
+ if (targets.length === 0) {
+ candidates.push({
+ id: candidateId("node", source.id, null),
+ entityType: "node",
+ sourceId: source.id,
+ targetId: null,
+ status: "unmatched",
+ reasons: [],
+ propertyTransfer: { status: "unmatched", reasons: [] },
+ networkAttachment: context.options.attachNetwork
+ ? { status: "unmatched", reasons: [] }
+ : null,
+ evidence: {
+ distanceMeters: Number.POSITIVE_INFINITY,
+ sourceRoutingFamilies: routingFamilies(patchWays),
+ targetRoutingFamilies: [],
+ tagDiff: [],
+ },
+ });
+ continue;
+ }
+
+ for (const target of targets.toSorted((a, b) => a.id - b.id)) {
+ const baseWays = context.baseWaysByNode.get(target.id) ?? [];
+ const tagDiff = selectedTagDiff(source, target, context.options.propertyKeys);
+ const property = nodePropertyAssessment(context, patchWays, baseWays, tagDiff);
+ const attachment = nodeAttachmentAssessment(context, source, target, patchWays, baseWays);
+ if (targets.length > 1) {
+ if (property.status === "automatic") property.status = "review";
+ if (attachment.assessment.status === "automatic") attachment.assessment.status = "review";
+ property.reasons = uniqueReasons([...property.reasons, "multiple-targets"]);
+ attachment.assessment.reasons = uniqueReasons([
+ ...attachment.assessment.reasons,
+ "multiple-targets",
+ ]);
+ }
+ const overall = overallAssessment(property, attachment.assessment, context.options);
+ const distanceMeters = haversineDistance([source.lon, source.lat], [target.lon, target.lat]);
+ candidates.push({
+ id: candidateId("node", source.id, target.id),
+ entityType: "node",
+ sourceId: source.id,
+ targetId: target.id,
+ status: overall.status,
+ reasons: overall.reasons,
+ propertyTransfer: property,
+ networkAttachment: context.options.attachNetwork ? attachment.assessment : null,
+ evidence: {
+ distanceMeters: roundEvidence(distanceMeters),
+ sourceRoutingFamilies: routingFamilies(patchWays),
+ targetRoutingFamilies: routingFamilies(baseWays),
+ tagDiff,
+ ...attachment.evidence,
+ },
+ });
+ }
+ }
+ return candidates;
+}
+
+function endpointDistances(source: readonly LonLat[], target: readonly LonLat[]) {
+ const forward: [number, number] = [
+ haversineDistance(source[0]!, target[0]!),
+ haversineDistance(source.at(-1)!, target.at(-1)!),
+ ];
+ const reverse: [number, number] = [
+ haversineDistance(source[0]!, target.at(-1)!),
+ haversineDistance(source.at(-1)!, target[0]!),
+ ];
+ return Math.max(...forward) <= Math.max(...reverse)
+ ? { distances: forward, reversed: false }
+ : { distances: reverse, reversed: true };
+}
+
+function discoverWayCandidates(context: DiscoveryContext) {
+ const candidates: OsmConflationCandidate[] = [];
+ if (context.options.propertyKeys.length === 0) return candidates;
+ for (const source of context.patch.ways.sorted()) {
+ if (context.base.ways.ids.has(source.id)) continue;
+ if (!context.options.propertyKeys.some((key) => source.tags?.[key] != null)) continue;
+ const sourceCoordinates = wayCoordinates(context.patch, source);
+ if (sourceCoordinates.length < 2) continue;
+ const nearbyIndexes = context.base.ways.intersects(
+ lineBbox(sourceCoordinates, context.options.maxDistanceMeters),
+ );
+ const matches: {
+ target: OsmWay;
+ reasons: OsmConflationReasonCode[];
+ evidence: Pick<
+ OsmConflationEvidence,
+ | "distanceMeters"
+ | "endpointDistancesMeters"
+ | "lengthDifferenceRatio"
+ | "maxGeometryDistanceMeters"
+ >;
+ }[] = [];
+ for (const index of nearbyIndexes) {
+ const target = context.base.ways.getByIndex(index);
+ if (context.patch.ways.ids.has(target.id)) continue;
+ const targetCoordinates = wayCoordinates(context.base, target);
+ if (targetCoordinates.length < 2) continue;
+ const endpoints = endpointDistances(sourceCoordinates, targetCoordinates);
+ if (Math.max(...endpoints.distances) > context.options.maxDistanceMeters) continue;
+ const sourceLength = lineLength(sourceCoordinates);
+ const targetLength = lineLength(targetCoordinates);
+ const maximumLength = Math.max(sourceLength, targetLength);
+ const lengthDifferenceRatio =
+ maximumLength === 0 ? 0 : Math.abs(sourceLength - targetLength) / maximumLength;
+ const maxGeometryDistanceMeters = symmetricLineDistance(sourceCoordinates, targetCoordinates);
+ if (maxGeometryDistanceMeters > context.options.maxDistanceMeters) continue;
+ const reasons: OsmConflationReasonCode[] = [];
+ // Keep geometrically plausible conflicts as blocked candidate rows. Users need
+ // to see why a nearby way was rejected instead of seeing it as merely unmatched.
+ if (isAreaWay(source) !== isAreaWay(target)) reasons.push("geometry-mismatch");
+ if (lengthDifferenceRatio > MAX_LENGTH_DIFFERENCE_RATIO) reasons.push("length-mismatch");
+ if (routingGradeSignature(source.tags) !== routingGradeSignature(target.tags)) {
+ reasons.push("grade-conflict");
+ }
+ if (
+ !familyCompatible(wayRoutingFamily(source), wayRoutingFamily(target)) ||
+ accessSignature(source.tags) !== accessSignature(target.tags) ||
+ !wayRoutingSemanticsCompatible(source, target, endpoints.reversed)
+ ) {
+ reasons.push("routing-family-conflict");
+ }
+ matches.push({
+ target,
+ reasons: uniqueReasons(reasons),
+ evidence: {
+ distanceMeters: roundEvidence(maxGeometryDistanceMeters),
+ endpointDistancesMeters: endpoints.distances.map(roundEvidence) as [number, number],
+ lengthDifferenceRatio: roundEvidence(lengthDifferenceRatio),
+ maxGeometryDistanceMeters: roundEvidence(maxGeometryDistanceMeters),
+ },
+ });
+ }
+
+ if (matches.length === 0) {
+ // Multiple nearby base ways may represent a segmented equivalent. This version
+ // deliberately reports that case instead of guessing a one-to-many mapping.
+ const reasons: OsmConflationReasonCode[] =
+ nearbyIndexes.length > 1 ? ["unsupported-way-chain"] : [];
+ candidates.push({
+ id: candidateId("way", source.id, null),
+ entityType: "way",
+ sourceId: source.id,
+ targetId: null,
+ status: "unmatched",
+ reasons,
+ propertyTransfer: { status: "unmatched", reasons },
+ networkAttachment: null,
+ evidence: {
+ distanceMeters: Number.POSITIVE_INFINITY,
+ sourceRoutingFamilies: [wayRoutingFamily(source)],
+ targetRoutingFamilies: [],
+ tagDiff: [],
+ },
+ });
+ continue;
+ }
+
+ for (const match of matches.toSorted((a, b) => a.target.id - b.target.id)) {
+ const tagDiff = selectedTagDiff(source, match.target, context.options.propertyKeys);
+ const property = propertyAssessment(tagDiff, context.options);
+ if (match.reasons.length > 0) {
+ property.status = "blocked";
+ property.reasons = uniqueReasons([...property.reasons, ...match.reasons]);
+ }
+ if (matches.length > 1 && property.status === "automatic") property.status = "review";
+ if (matches.length > 1)
+ property.reasons = uniqueReasons([...property.reasons, "multiple-targets"]);
+ const sourceRelation = context.patchRelations.ways.has(source.id);
+ const targetRelation = context.baseRelations.ways.has(match.target.id);
+ const restriction =
+ context.patchRelations.restrictionWays.has(source.id) ||
+ context.baseRelations.restrictionWays.has(match.target.id);
+ if (sourceRelation || targetRelation) {
+ property.reasons = uniqueReasons([...property.reasons, "relation-member"]);
+ property.status = restriction ? "blocked" : "review";
+ }
+ candidates.push({
+ id: candidateId("way", source.id, match.target.id),
+ entityType: "way",
+ sourceId: source.id,
+ targetId: match.target.id,
+ status: property.status,
+ reasons: property.reasons,
+ propertyTransfer: property,
+ networkAttachment: null,
+ evidence: {
+ ...match.evidence,
+ sourceRoutingFamilies: [wayRoutingFamily(source)],
+ targetRoutingFamilies: [wayRoutingFamily(match.target)],
+ tagDiff,
+ },
+ });
+ }
+ }
+ return candidates;
+}
+
+function applyManyToOneClassification(candidates: OsmConflationCandidate[]) {
+ // Candidate discovery is local to each source. Enforce the batch-wide one-to-one
+ // invariant only after all otherwise plausible pairs are known.
+ const sourcesByTarget = new Map>();
+ for (const candidate of candidates) {
+ if (candidate.targetId == null) continue;
+ const key = `${candidate.entityType}:${candidate.targetId}`;
+ const sources = sourcesByTarget.get(key) ?? new Set();
+ sources.add(candidate.sourceId);
+ sourcesByTarget.set(key, sources);
+ }
+ for (const candidate of candidates) {
+ if (candidate.targetId == null) continue;
+ if ((sourcesByTarget.get(`${candidate.entityType}:${candidate.targetId}`)?.size ?? 0) <= 1)
+ continue;
+ addReviewReason(candidate, "many-to-one");
+ }
+}
+
+/** Discover fuzzy candidates strictly between untouched patch and immutable base inputs. */
+export function discoverConflationCandidates(
+ base: Osm,
+ patch: Osm,
+ options: OsmConflationOptions,
+): OsmConflationDiscovery {
+ const resolved = resolvedOptions(options);
+ const context: DiscoveryContext = {
+ base,
+ patch,
+ options: resolved,
+ baseWaysByNode: waysByNode(base),
+ patchWaysByNode: waysByNode(patch),
+ baseRelations: relationContext(base),
+ patchRelations: relationContext(patch),
+ };
+ const candidates = [
+ ...discoverNodeCandidates(context),
+ ...discoverWayCandidates(context),
+ ].toSorted(
+ (a, b) =>
+ a.entityType.localeCompare(b.entityType) ||
+ a.sourceId - b.sourceId ||
+ (a.targetId ?? Number.POSITIVE_INFINITY) - (b.targetId ?? Number.POSITIVE_INFINITY),
+ );
+ applyManyToOneClassification(candidates);
+ return {
+ baseOsmId: base.id,
+ patchOsmId: patch.id,
+ options: resolved,
+ candidates,
+ summary: summarizeConflationCandidates(candidates),
+ };
+}
+
+/**
+ * Discover canonical candidates for an in-process merge orchestrator.
+ *
+ * @internal This capability must stay inside a same-call merge path. Unlike the
+ * public generation functions, its companion generators trust the object
+ * identity registered here instead of rediscovering candidates from scratch.
+ */
+export function discoverConflationCandidatesForTrustedMerge(
+ base: Osm,
+ patch: Osm,
+ options: OsmConflationOptions,
+) {
+ const discovery = discoverConflationCandidates(base, patch, options);
+ trustedDiscoveries.set(discovery, { base, patch });
+ trustedCandidateCollections.add(discovery.candidates);
+ return discovery;
+}
+
+function decisionMap(decisions: readonly OsmConflationDecision[]) {
+ return new Map(decisions.map((decision) => [decision.candidateId, decision]));
+}
+
+function validatedDecisionMap(
+ candidates: readonly OsmConflationCandidate[],
+ decisions: readonly OsmConflationDecision[],
+) {
+ if (!Array.isArray(decisions)) throw Error("Conflation decisions must be an array");
+ if (decisions.length === 0) return new Map();
+ let candidateIds = trustedCandidateIds.get(candidates);
+ if (!candidateIds) {
+ candidateIds = new Set(candidates.map((candidate) => candidate.id));
+ // General callers may mutate their candidate arrays between validations.
+ // Cache IDs only for canonical collections retained by a trusted merge path.
+ if (trustedCandidateCollections.has(candidates)) {
+ trustedCandidateIds.set(candidates, candidateIds);
+ }
+ }
+ const result = new Map();
+ for (const decision of decisions) {
+ if (decision == null || typeof decision !== "object") {
+ throw Error("Conflation decision must be an object");
+ }
+ if (typeof decision.candidateId !== "string" || !candidateIds.has(decision.candidateId)) {
+ throw Error(`Unknown conflation candidate: ${String(decision.candidateId)}`);
+ }
+ if (result.has(decision.candidateId)) {
+ throw Error(`Duplicate conflation decision for ${decision.candidateId}`);
+ }
+ if (decision.action !== "accept" && decision.action !== "reject") {
+ throw Error(`Invalid conflation decision action for ${decision.candidateId}`);
+ }
+ if (
+ decision.transferProperties !== undefined &&
+ typeof decision.transferProperties !== "boolean"
+ ) {
+ throw Error(`Conflation transferProperties must be a boolean for ${decision.candidateId}`);
+ }
+ if (decision.attachNetwork !== undefined && typeof decision.attachNetwork !== "boolean") {
+ throw Error(`Conflation attachNetwork must be a boolean for ${decision.candidateId}`);
+ }
+ result.set(decision.candidateId, decision);
+ }
+ return result;
+}
+
+/** Validate review decisions against canonical candidates without mutating either input. */
+export function validateConflationDecisions(
+ candidates: readonly OsmConflationCandidate[],
+ decisions: readonly OsmConflationDecision[],
+) {
+ validatedDecisionMap(candidates, decisions);
+}
+
+/** Return a candidate's effective status without rerunning spatial discovery. */
+export function conflationEffectiveStatus(
+ candidate: OsmConflationCandidate,
+ decisions: readonly OsmConflationDecision[] = [],
+): OsmConflationEffectiveStatus {
+ const action = decisionMap(decisions).get(candidate.id)?.action;
+ if (action === "accept") return "accepted";
+ if (action === "reject") return "rejected";
+ return candidate.status;
+}
+
+/** Recompute review counts after lightweight decisions without rerunning discovery. */
+export function summarizeConflationCandidates(
+ candidates: readonly OsmConflationCandidate[],
+ decisions: readonly OsmConflationDecision[] = [],
+): OsmConflationSummary {
+ const decisionsById = validatedDecisionMap(candidates, decisions);
+ const summary: OsmConflationSummary = {
+ total: candidates.length,
+ accepted: 0,
+ automatic: 0,
+ review: 0,
+ blocked: 0,
+ unmatched: 0,
+ rejected: 0,
+ };
+ for (const candidate of candidates) {
+ const action = decisionsById.get(candidate.id)?.action;
+ const status =
+ action === "accept" ? "accepted" : action === "reject" ? "rejected" : candidate.status;
+ summary[status]++;
+ }
+ return summary;
+}
+
+/** Filter candidate rows deterministically, including effective rejected status. */
+export function filterConflationCandidates(
+ candidates: readonly OsmConflationCandidate[],
+ filter: OsmConflationCandidateFilter,
+ decisions: readonly OsmConflationDecision[] = [],
+) {
+ const decisionsById = decisionMap(decisions);
+ return candidates.filter((candidate) => {
+ if (filter.entityType != null && candidate.entityType !== filter.entityType) return false;
+ const action = decisionsById.get(candidate.id)?.action;
+ const status =
+ action === "accept" ? "accepted" : action === "reject" ? "rejected" : candidate.status;
+ if (filter.status != null && status !== filter.status) {
+ return false;
+ }
+ if (filter.reason != null && !candidate.reasons.includes(filter.reason)) return false;
+ if (filter.sourceId != null && candidate.sourceId !== filter.sourceId) return false;
+ if ("targetId" in filter && candidate.targetId !== filter.targetId) return false;
+ return true;
+ });
+}
+
+const BULK_AMBIGUITY_REASONS = new Set([
+ "many-to-one",
+ "multiple-targets",
+ "unsupported-way-chain",
+]);
+
+function bulkActionAssessment(
+ candidate: OsmConflationCandidate,
+ action: OsmConflationBulkDecisionRequest["action"],
+) {
+ if (action === "transfer-properties") return candidate.propertyTransfer;
+ if (action === "attach-network") return candidate.networkAttachment;
+ return null;
+}
+
+function bulkActionEligible(
+ candidate: OsmConflationCandidate,
+ action: OsmConflationBulkDecisionRequest["action"],
+) {
+ if (action === "reject") return true;
+ if (candidate.status === "blocked" || candidate.status === "unmatched") return false;
+ if (candidate.targetId == null) return false;
+ if (candidate.reasons.some((reason) => BULK_AMBIGUITY_REASONS.has(reason))) return false;
+ const assessment = bulkActionAssessment(candidate, action);
+ if (!assessment || assessment.status === "blocked" || assessment.status === "unmatched") {
+ return false;
+ }
+ return action !== "transfer-properties" || candidate.evidence.tagDiff.length > 0;
+}
+
+function bulkAcceptDecision(
+ candidate: OsmConflationCandidate,
+ current: OsmConflationDecision | undefined,
+ action: Exclude,
+): OsmConflationDecision {
+ const preserveCurrentActions = current?.action !== "reject";
+ const transferProperties =
+ action === "transfer-properties" ||
+ (preserveCurrentActions && acceptedAction(candidate, "propertyTransfer", current));
+ const attachNetwork =
+ action === "attach-network" ||
+ (preserveCurrentActions && acceptedAction(candidate, "networkAttachment", current));
+ return {
+ candidateId: candidate.id,
+ action: "accept",
+ transferProperties,
+ attachNetwork,
+ };
+}
+
+function decisionsHaveSameEffect(
+ candidate: OsmConflationCandidate,
+ current: OsmConflationDecision | undefined,
+ next: OsmConflationDecision,
+) {
+ if (!current || current.action !== next.action) return false;
+ if (current.action === "reject") return true;
+ return (
+ acceptedAction(candidate, "propertyTransfer", current) ===
+ acceptedAction(candidate, "propertyTransfer", next) &&
+ acceptedAction(candidate, "networkAttachment", current) ===
+ acceptedAction(candidate, "networkAttachment", next)
+ );
+}
+
+/** Build one atomic decision update for every candidate matching a filter. */
+export function buildConflationBulkDecisionResult(
+ candidates: readonly OsmConflationCandidate[],
+ decisions: readonly OsmConflationDecision[],
+ request: OsmConflationBulkDecisionRequest,
+): OsmConflationBulkDecisionResult {
+ if (request == null || typeof request !== "object") {
+ throw Error("Conflation bulk decision request must be an object");
+ }
+ if (!new Set(["transfer-properties", "attach-network", "reject"]).has(request.action)) {
+ throw Error(`Invalid conflation bulk action: ${String(request.action)}`);
+ }
+ if (request.filter == null || typeof request.filter !== "object") {
+ throw Error("Conflation bulk decision filter must be an object");
+ }
+
+ const currentById = validatedDecisionMap(candidates, decisions);
+ const nextById = new Map(currentById);
+ const filtered = filterConflationCandidates(candidates, request.filter, decisions);
+ let eligibleCandidates = 0;
+ let changedCandidates = 0;
+ let automaticCandidates = 0;
+ let reviewCandidates = 0;
+ let overriddenDecisions = 0;
+
+ for (const candidate of filtered) {
+ if (!bulkActionEligible(candidate, request.action)) continue;
+ eligibleCandidates++;
+ if (candidate.status === "automatic") automaticCandidates++;
+ if (candidate.status === "review") reviewCandidates++;
+
+ const current = currentById.get(candidate.id);
+ const next =
+ request.action === "reject"
+ ? ({ candidateId: candidate.id, action: "reject" } as const)
+ : bulkAcceptDecision(candidate, current, request.action);
+ if (decisionsHaveSameEffect(candidate, current, next)) continue;
+ changedCandidates++;
+ if (current) overriddenDecisions++;
+ nextById.set(candidate.id, next);
+ }
+
+ const nextDecisions = [...nextById.values()].toSorted((a, b) =>
+ a.candidateId.localeCompare(b.candidateId),
+ );
+ validateConflationDecisions(candidates, nextDecisions);
+ const preview = {
+ action: request.action,
+ filteredCandidates: filtered.length,
+ eligibleCandidates,
+ changedCandidates,
+ skippedCandidates: filtered.length - eligibleCandidates,
+ automaticCandidates,
+ reviewCandidates,
+ overriddenDecisions,
+ };
+ return {
+ decisions: nextDecisions,
+ preview,
+ summary: summarizeConflationCandidates(candidates, nextDecisions),
+ };
+}
+
+function currentEntity(changeset: OsmChangeset, type: T, id: number) {
+ const change = changeset.changes(type)[id];
+ if (change?.changeType === "delete") return null;
+ return change?.entity ?? changeset.getEntity(type, id) ?? null;
+}
+
+function currentRelations(changeset: OsmChangeset) {
+ const relations = new Map();
+ for (const relation of changeset.osm.relations) {
+ const change = changeset.relationChanges[relation.id];
+ if (change?.changeType !== "delete") relations.set(relation.id, change?.entity ?? relation);
+ }
+ for (const change of Object.values(changeset.relationChanges)) {
+ if (change.changeType === "delete") relations.delete(change.entity.id);
+ else relations.set(change.entity.id, change.entity);
+ }
+ return relations.values();
+}
+
+function currentWays(changeset: OsmChangeset) {
+ const ways = new Map();
+ for (const way of changeset.osm.ways) {
+ const change = changeset.wayChanges[way.id];
+ if (change?.changeType !== "delete") ways.set(way.id, change?.entity ?? way);
+ }
+ for (const change of Object.values(changeset.wayChanges)) {
+ if (change.changeType === "delete") ways.delete(change.entity.id);
+ else ways.set(change.entity.id, change.entity);
+ }
+ return ways.values();
+}
+
+function removeCurrentEntity(changeset: OsmChangeset, entity: OsmEntity) {
+ const type = "lon" in entity ? "node" : "refs" in entity ? "way" : "relation";
+ const change = changeset.changes(type)[entity.id];
+ if (change?.changeType === "create") delete changeset.changes(type)[entity.id];
+ else changeset.delete(entity);
+}
+
+function acceptedAction(
+ candidate: OsmConflationCandidate,
+ action: "propertyTransfer" | "networkAttachment",
+ decision: OsmConflationDecision | undefined,
+) {
+ if (decision?.action === "reject") return false;
+ const assessment = candidate[action];
+ // Manual review can select among reviewable actions, but it cannot override a
+ // blocked invariant or manufacture a match for an unmatched candidate.
+ if (!assessment || assessment.status === "blocked" || assessment.status === "unmatched")
+ return false;
+ const selected =
+ action === "propertyTransfer" ? decision?.transferProperties : decision?.attachNetwork;
+ if (decision?.action === "accept") return selected ?? true;
+ return assessment.status === "automatic";
+}
+
+function transferSelectedProperties(
+ changeset: OsmChangeset,
+ candidate: OsmConflationCandidate,
+ source: OsmEntity,
+) {
+ if (candidate.targetId == null) return;
+ const type = candidate.entityType;
+ changeset.modify(type, candidate.targetId, (target) => {
+ const tags = { ...target.tags };
+ for (const diff of candidate.evidence.tagDiff) {
+ if (diff.protected) continue;
+ tags[diff.key] = source.tags![diff.key]!;
+ }
+ return { ...target, tags };
+ });
+}
+
+function validateAcceptedMappings(
+ candidates: readonly OsmConflationCandidate[],
+ decisions: ReadonlyMap,
+) {
+ const sourceActions = new Set();
+ const attachmentTargets = new Set();
+ const wayTargets = new Set();
+ for (const candidate of candidates) {
+ const decision = decisions.get(candidate.id);
+ const transfer = acceptedAction(candidate, "propertyTransfer", decision);
+ const attach = acceptedAction(candidate, "networkAttachment", decision);
+ if (!transfer && !attach) continue;
+ const sourceKey = `${candidate.entityType}:${candidate.sourceId}`;
+ if (sourceActions.has(sourceKey)) {
+ throw Error(`Conflation accepted multiple targets for ${sourceKey}`);
+ }
+ sourceActions.add(sourceKey);
+ if (candidate.targetId == null)
+ throw Error(`Conflation accepted unmatched candidate ${candidate.id}`);
+ if (attach) {
+ if (attachmentTargets.has(candidate.targetId)) {
+ throw Error(`Conflation accepted multiple node attachments to ${candidate.targetId}`);
+ }
+ attachmentTargets.add(candidate.targetId);
+ }
+ if (candidate.entityType === "way" && transfer) {
+ if (wayTargets.has(candidate.targetId)) {
+ throw Error(`Conflation accepted multiple ways for target ${candidate.targetId}`);
+ }
+ wayTargets.add(candidate.targetId);
+ }
+ }
+}
+
+function cleanupUnreferencedPatchNodes(
+ changeset: OsmChangeset,
+ patch: Osm,
+ originalBase: Osm,
+ cleanupCandidateIds: ReadonlySet,
+) {
+ // Cleanup is intentionally limited to nodes from a suppressed matched patch way.
+ // Removing every orphan patch node would violate direct merge preservation.
+ const referenced = new Set();
+ for (const way of currentWays(changeset)) for (const ref of way.refs) referenced.add(ref);
+ for (const relation of currentRelations(changeset)) {
+ for (const member of relation.members) if (member.type === "node") referenced.add(member.ref);
+ }
+ for (const nodeId of cleanupCandidateIds) {
+ const node = patch.nodes.getById(nodeId);
+ if (!node) continue;
+ if (originalBase.nodes.ids.has(node.id) || node.tags != null || referenced.has(node.id))
+ continue;
+ const current = currentEntity(changeset, "node", node.id);
+ if (current) removeCurrentEntity(changeset, current);
+ }
+}
+
+function applyDiscoveredConflation(
+ changeset: OsmChangeset,
+ patch: Osm,
+ discovery: OsmConflationDiscovery,
+ decisions: readonly OsmConflationDecision[],
+ originalBase: Osm,
+) {
+ if (patch.id !== discovery.patchOsmId) {
+ throw Error(`Conflation discovery patch ${discovery.patchOsmId} does not match ${patch.id}`);
+ }
+ const decisionsById = validatedDecisionMap(discovery.candidates, decisions);
+ validateAcceptedMappings(discovery.candidates, decisionsById);
+
+ const attachments = new Map();
+ const patchWayIds = new Set();
+ const cleanupCandidateNodeIds = new Set();
+ for (const candidate of discovery.candidates) {
+ const decision = decisionsById.get(candidate.id);
+ if (!acceptedAction(candidate, "networkAttachment", decision) || candidate.targetId == null) {
+ continue;
+ }
+ attachments.set(candidate.sourceId, candidate.targetId);
+ for (const wayId of candidate.evidence.patchWayIds ?? []) patchWayIds.add(wayId);
+ }
+ for (const wayId of patchWayIds) {
+ // Only patch-created ways are listed in attachment evidence. Base way refs are
+ // never rewritten, even when the nearby patch node is accepted.
+ const way = currentEntity(changeset, "way", wayId);
+ if (!way) continue;
+ const refs = way.refs.map((ref) => attachments.get(ref) ?? ref);
+ if (refs.some((ref, index) => index > 0 && ref === refs[index - 1])) {
+ throw Error(`Conflation attachment would create duplicate adjacent refs in way ${wayId}`);
+ }
+ if (way.tags?.["highway"] != null && new Set(refs).size < 2) {
+ throw Error(`Conflation attachment would collapse highway way ${wayId}`);
+ }
+ changeset.modify("way", wayId, (current) => ({ ...current, refs }));
+ }
+
+ for (const candidate of discovery.candidates) {
+ const decision = decisionsById.get(candidate.id);
+ if (!acceptedAction(candidate, "propertyTransfer", decision) || candidate.targetId == null) {
+ continue;
+ }
+ const source =
+ candidate.entityType === "node"
+ ? patch.nodes.getById(candidate.sourceId)
+ : patch.ways.getById(candidate.sourceId);
+ if (!source)
+ throw Error(`Conflation source ${candidate.entityType} ${candidate.sourceId} is missing`);
+ transferSelectedProperties(changeset, candidate, source);
+ if (
+ candidate.entityType !== "way" ||
+ candidate.reasons.includes("relation-member") ||
+ candidate.reasons.includes("protected-tag")
+ ) {
+ continue;
+ }
+ const current = currentEntity(changeset, "way", candidate.sourceId);
+ if (current) {
+ // An equivalent one-to-one patch way is suppressed after property transfer.
+ // Its nodes become cleanup candidates, not unconditional deletions.
+ for (const ref of current.refs) cleanupCandidateNodeIds.add(ref);
+ removeCurrentEntity(changeset, current);
+ }
+ }
+ cleanupUnreferencedPatchNodes(changeset, patch, originalBase, cleanupCandidateNodeIds);
+}
+
+function generateConflationApplicationArtifacts(
+ baseline: Osm,
+ patch: Osm,
+ canonicalDiscovery: OsmConflationDiscovery,
+ originalBase: Osm,
+ decisions: readonly OsmConflationDecision[] = [],
+) {
+ if (patch.id !== canonicalDiscovery.patchOsmId) {
+ throw Error(
+ `Conflation discovery patch ${canonicalDiscovery.patchOsmId} does not match ${patch.id}`,
+ );
+ }
+ if (originalBase.id !== canonicalDiscovery.baseOsmId) {
+ throw Error(
+ `Conflation discovery base ${canonicalDiscovery.baseOsmId} does not match ${originalBase.id}`,
+ );
+ }
+ const changeset = new OsmChangeset(baseline);
+ applyDiscoveredConflation(changeset, patch, canonicalDiscovery, decisions, originalBase);
+ const result = applyChangesetToOsm(changeset);
+ assertConflationPreservesBaseTopology(originalBase, baseline, result);
+ return { changeset, result };
+}
+
+/** Generate fuzzy-only changes over an already applied ordinary direct/exact merge baseline. */
+export function generateConflationApplicationChangeset(
+ baseline: Osm,
+ patch: Osm,
+ discovery: OsmConflationDiscovery,
+ originalBase: Osm,
+ decisions: readonly OsmConflationDecision[] = [],
+) {
+ // Reject review data from another merge session before rediscovery. The
+ // candidate evidence is deliberately untrusted, but its input IDs are still
+ // part of the public API's stale-session guard.
+ if (patch.id !== discovery.patchOsmId) {
+ throw Error(`Conflation discovery patch ${discovery.patchOsmId} does not match ${patch.id}`);
+ }
+ if (originalBase.id !== discovery.baseOsmId) {
+ throw Error(
+ `Conflation discovery base ${discovery.baseOsmId} does not match ${originalBase.id}`,
+ );
+ }
+ // Recompute from untouched entities before applying. Candidate records returned
+ // to callers are review data, not trusted instructions for mutating topology.
+ const canonicalDiscovery = discoverConflationCandidates(originalBase, patch, discovery.options);
+ return generateConflationApplicationArtifacts(
+ baseline,
+ patch,
+ canonicalDiscovery,
+ originalBase,
+ decisions,
+ ).changeset;
+}
+
+function validateCumulativeConflationOptions(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ discovery: OsmConflationDiscovery,
+) {
+ if (!options.conflation) throw Error("generateConflationChangeset requires conflation options");
+ if (!options.directMerge)
+ throw Error("Fuzzy conflation requires directMerge to preserve unmatched patch entities");
+ if (options.createIntersections) {
+ throw Error(
+ "generateConflationChangeset cannot create intersections in the cumulative changeset",
+ );
+ }
+ if (discovery.baseOsmId !== base.id || discovery.patchOsmId !== patch.id) {
+ throw Error("Conflation discovery does not match the untouched merge inputs");
+ }
+ const expectedOptions = resolvedOptions(options.conflation);
+ if (
+ discovery.options.attachNetwork !== expectedOptions.attachNetwork ||
+ discovery.options.automatic !== expectedOptions.automatic ||
+ discovery.options.maxDistanceMeters !== expectedOptions.maxDistanceMeters ||
+ discovery.options.propertyKeys.length !== expectedOptions.propertyKeys.length ||
+ discovery.options.propertyKeys.some((key, index) => key !== expectedOptions.propertyKeys[index])
+ ) {
+ throw Error("Conflation discovery options do not match generation options");
+ }
+}
+
+function generateCumulativeConflationArtifacts(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ decisions: readonly OsmConflationDecision[],
+ canonicalDiscovery: OsmConflationDiscovery,
+ onProgress?: (progress: ProgressEvent) => void,
+) {
+ validateCumulativeConflationOptions(base, patch, options, canonicalDiscovery);
+ const ordinaryOptions = {
+ directMerge: true,
+ deduplicateNodes: options.deduplicateNodes ?? false,
+ deduplicateWays: options.deduplicateWays ?? false,
+ createIntersections: false,
+ };
+ // Applying does not consume a changeset. Build the ordinary changes once, use
+ // them to materialize the comparison baseline, then add fuzzy changes to that
+ // same cumulative changeset.
+ const changeset = onProgress
+ ? generateChangeset(base, patch, ordinaryOptions, onProgress)
+ : generateChangeset(base, patch, ordinaryOptions);
+ const ordinaryBaseline = applyChangesetToOsm(changeset);
+ applyDiscoveredConflation(changeset, patch, canonicalDiscovery, decisions, base);
+ const result = applyChangesetToOsm(changeset);
+ assertConflationPreservesBaseTopology(base, ordinaryBaseline, result);
+ return { changeset, ordinaryBaseline, result };
+}
+
+/**
+ * Generate a cumulative direct/exact/fuzzy changeset from untouched inputs.
+ * Intersection creation remains a later stage because newly created ways are not indexed yet.
+ */
+export function generateConflationChangeset(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ decisions: readonly OsmConflationDecision[] = options.conflation?.decisions ?? [],
+ discovery?: OsmConflationDiscovery,
+) {
+ if (!options.conflation) throw Error("generateConflationChangeset requires conflation options");
+ if (!options.directMerge)
+ throw Error("Fuzzy conflation requires directMerge to preserve unmatched patch entities");
+ if (options.createIntersections) {
+ throw Error(
+ "generateConflationChangeset cannot create intersections in the cumulative changeset",
+ );
+ }
+ // Generation never trusts possibly stale or caller-mutated candidate evidence.
+ // Stable decisions are replayed against a fresh discovery from untouched inputs.
+ const canonicalDiscovery = discoverConflationCandidates(base, patch, options.conflation);
+ const suppliedDiscovery = discovery ?? canonicalDiscovery;
+ // Validate the supplied review snapshot even though the fresh canonical
+ // discovery remains the only source of mutation instructions.
+ validateCumulativeConflationOptions(base, patch, options, suppliedDiscovery);
+ return generateCumulativeConflationArtifacts(base, patch, options, decisions, canonicalDiscovery)
+ .changeset;
+}
+
+/**
+ * Generate cumulative artifacts from a canonical same-process discovery.
+ *
+ * @internal Public generation must use {@link generateConflationChangeset}, which
+ * deliberately rediscovers candidates before applying caller-supplied decisions.
+ */
+export function generateConflationArtifactsFromTrustedDiscovery(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ decisions: readonly OsmConflationDecision[],
+ discovery: OsmConflationDiscovery,
+ onProgress: (progress: ProgressEvent) => void,
+) {
+ const inputs = trustedDiscoveries.get(discovery);
+ if (inputs?.base !== base || inputs.patch !== patch) {
+ throw Error("Conflation discovery is not owned by this trusted merge session");
+ }
+ return generateCumulativeConflationArtifacts(
+ base,
+ patch,
+ options,
+ decisions,
+ discovery,
+ onProgress,
+ );
+}
+
+/**
+ * Generate fuzzy-only artifacts from a canonical same-process discovery.
+ *
+ * @internal Used for the automatic network-attachment CAR safety projection.
+ */
+export function generateConflationApplicationArtifactsFromTrustedDiscovery(
+ baseline: Osm,
+ patch: Osm,
+ discovery: OsmConflationDiscovery,
+ originalBase: Osm,
+ decisions: readonly OsmConflationDecision[],
+) {
+ const inputs = trustedDiscoveries.get(discovery);
+ if (inputs?.base !== originalBase || inputs.patch !== patch) {
+ throw Error("Conflation discovery is not owned by this trusted merge session");
+ }
+ return generateConflationApplicationArtifacts(
+ baseline,
+ patch,
+ discovery,
+ originalBase,
+ decisions,
+ );
+}
diff --git a/packages/change/src/generate-changeset.ts b/packages/change/src/generate-changeset.ts
index 4f1a4a1a..cdc806a4 100644
--- a/packages/change/src/generate-changeset.ts
+++ b/packages/change/src/generate-changeset.ts
@@ -25,6 +25,9 @@ import type { OsmMergeOptions } from "./types.ts";
* @param options - Options controlling which operations to run.
* @param onProgress - Callback for progress updates (throttled for way operations).
* @returns The populated OsmChangeset ready for application or inspection.
+ * @throws When direct merge and intersection creation are requested together. Newly created
+ * patch ways are not spatially indexed until the direct changeset is applied; use `merge()` or
+ * apply the direct changes before generating an intersection-only changeset.
*
* @example
* ```ts
@@ -42,6 +45,12 @@ export function generateChangeset(
options: Partial = {},
onProgress: (progress: ProgressEvent) => void = logProgress,
) {
+ if (options.directMerge && options.createIntersections) {
+ throw Error(
+ "generateChangeset cannot combine directMerge with createIntersections because new patch ways are not indexed; use merge() or apply direct changes before generating intersections",
+ );
+ }
+
const patchId = patch.id;
const baseId = base.id;
@@ -55,38 +64,39 @@ export function generateChangeset(
changeset.generateDirectChanges(patch);
}
+ if (options.deduplicateNodes) {
+ log(`Reconciling nodes from ${patchId} with ${baseId}...`);
+ changeset.deduplicateNodes(patch.nodes);
+ log(
+ `Node deduplication results: ${changeset.deduplicatedNodes} de-duplicated nodes, ${changeset.deduplicatedNodesReplaced} nodes replaced`,
+ );
+ }
+
if (options.deduplicateWays) {
let checkedWays = 0;
let dedpulicatedWays = 0;
- log(`Deduplicating ways from ${patchId}...`);
+ log(`Reconciling ways from ${patchId} with ${baseId}...`);
for (const wayStats of changeset.deduplicateWaysGenerator(patch.ways)) {
checkedWays++;
dedpulicatedWays += wayStats;
logEverySecond(
- `Deduplicating ways: ${checkedWays.toLocaleString()} ways checked, ${dedpulicatedWays.toLocaleString()} ways deduplicated`,
+ `Way reconciliation: ${checkedWays.toLocaleString()} ways checked, ${dedpulicatedWays.toLocaleString()} ways reconciled`,
);
}
}
- if (options.deduplicateNodes) {
- log(`Deduplicating nodes from ${patchId}...`);
- changeset.deduplicateNodes(patch.nodes);
- log(
- `Node deduplication results: ${changeset.deduplicatedNodes} de-duplicated nodes, ${changeset.deduplicatedNodesReplaced} nodes replaced`,
- );
- }
-
if (options.createIntersections) {
let checkedWays = 0;
log(`Creating intersections from ${patchId}...`);
+ const progressMessage = () =>
+ `Intersection creation progress: ${checkedWays.toLocaleString()} of ${patch.ways.size.toLocaleString()} ways checked`;
// This will check if the osm dataset has the way before trying to create intersections for it.
for (const _wayStats of changeset.createIntersectionsForWaysGenerator(patch.ways)) {
checkedWays++;
- logEverySecond(
- `Intersection creation progress: ${checkedWays.toLocaleString()} ways checked`,
- );
+ logEverySecond(progressMessage());
}
+ if (checkedWays > 0) log(progressMessage());
}
return changeset;
diff --git a/packages/change/src/index.ts b/packages/change/src/index.ts
index e70489cc..2522d68f 100644
--- a/packages/change/src/index.ts
+++ b/packages/change/src/index.ts
@@ -18,8 +18,8 @@
*
* // Manual changeset workflow
* const changeset = new OsmChangeset(baseOsm)
- * changeset.deduplicateNodes(baseOsm.nodes)
* changeset.generateDirectChanges(patchOsm)
+ * changeset.deduplicateNodes(patchOsm.nodes)
* const merged = applyChangesToOsm(changeset)
*
* // Or use the high-level merge function
@@ -34,6 +34,16 @@
export * from "./apply-changeset.ts";
export * from "./changeset.ts";
+export {
+ buildConflationBulkDecisionResult,
+ conflationEffectiveStatus,
+ discoverConflationCandidates,
+ filterConflationCandidates,
+ generateConflationApplicationChangeset,
+ generateConflationChangeset,
+ summarizeConflationCandidates,
+ validateConflationDecisions,
+} from "./conflation.ts";
export * from "./generate-changeset.ts";
export * from "./merge.ts";
export * from "./osc.ts";
diff --git a/packages/change/src/integrity.ts b/packages/change/src/integrity.ts
new file mode 100644
index 00000000..e5e5c06a
--- /dev/null
+++ b/packages/change/src/integrity.ts
@@ -0,0 +1,329 @@
+import type { Osm } from "@osmix/core";
+import type { OsmRelation, OsmWay } from "@osmix/types";
+
+import { routingGradeSignature } from "./utils.ts";
+
+type IntegrityIssue = {
+ key: string;
+ description: string;
+};
+
+type IncidentHighway = {
+ way: OsmWay;
+ gradeSignature: string;
+ interior: boolean;
+ endpoint: boolean;
+};
+
+// Finalized Osm indexes are immutable. Keep their ordered analysis by object
+// identity so adjacent merge stages do not rescan the same million-entity
+// dataset. Never key this cache by the user-facing OSM ID: an ID may be reused
+// for a newly merged dataset with different contents.
+const routingIntegrityIssuesByOsm = new WeakMap();
+
+const SURFACE_GRADE_SIGNATURE = "layer=0|level=|bridge=no|tunnel=no|covered=no";
+
+function sharesNode(a: OsmWay, b: OsmWay) {
+ const aRefs = new Set(a.refs);
+ return b.refs.some((ref) => aRefs.has(ref));
+}
+
+/**
+ * A bridge or tunnel can legitimately terminate at a portal node on a surface network.
+ * When an interior way also touches that portal (for example a crossing footway), the
+ * same-grade endpoint continuation proves that the interior way is connected to the
+ * surface side, not spliced into the grade-separated segment.
+ */
+function hasSameGradeEndpointContinuation(
+ ways: readonly IncidentHighway[],
+ left: IncidentHighway,
+ right: IncidentHighway,
+) {
+ if (left.interior === right.interior) return false;
+
+ const interiorWay = left.interior ? left : right;
+ const interiorSignature = interiorWay.gradeSignature;
+ // A continuation only proves a normal portal when the interior way is on the
+ // default surface level. It must not legitimize a new surface endpoint spliced
+ // into the middle of a tunnel or bridge.
+ if (interiorSignature !== SURFACE_GRADE_SIGNATURE) return false;
+ return ways.some(
+ (candidate) =>
+ candidate.way.id !== left.way.id &&
+ candidate.way.id !== right.way.id &&
+ candidate.endpoint &&
+ candidate.gradeSignature === interiorSignature,
+ );
+}
+
+function isAbsoluteIntegrityIssue(issue: IntegrityIssue) {
+ return (
+ /^way:[^:]+:missing-node:/.test(issue.key) ||
+ /^way:[^:]+:degenerate-highway$/.test(issue.key) ||
+ /^relation:[^:]+:missing-/.test(issue.key)
+ );
+}
+
+function restrictionIssues(osm: Osm, relation: OsmRelation): IntegrityIssue[] {
+ if (relation.tags?.["type"] !== "restriction") return [];
+
+ const issues: IntegrityIssue[] = [];
+ const fromWays = relation.members
+ .filter((member) => member.type === "way" && member.role === "from")
+ .map((member) => osm.ways.getById(member.ref))
+ .filter((way): way is OsmWay => way != null);
+ const toWays = relation.members
+ .filter((member) => member.type === "way" && member.role === "to")
+ .map((member) => osm.ways.getById(member.ref))
+ .filter((way): way is OsmWay => way != null);
+ const viaNodes = relation.members.filter(
+ (member) => member.type === "node" && member.role === "via",
+ );
+ const viaWays = relation.members
+ .filter((member) => member.type === "way" && member.role === "via")
+ .map((member) => osm.ways.getById(member.ref))
+ .filter((way): way is OsmWay => way != null);
+
+ if (fromWays.length === 0) {
+ issues.push({
+ key: `restriction:${relation.id}:missing-from`,
+ description: `restriction ${relation.id} has no existing from way`,
+ });
+ }
+ if (toWays.length === 0) {
+ issues.push({
+ key: `restriction:${relation.id}:missing-to`,
+ description: `restriction ${relation.id} has no existing to way`,
+ });
+ }
+ if (viaNodes.length === 0 && viaWays.length === 0) {
+ issues.push({
+ key: `restriction:${relation.id}:missing-via`,
+ description: `restriction ${relation.id} has no existing via member`,
+ });
+ }
+
+ for (const viaNode of viaNodes) {
+ const belongsToFrom = fromWays.some((way) => way.refs.includes(viaNode.ref));
+ const belongsToTo = toWays.some((way) => way.refs.includes(viaNode.ref));
+ if (!belongsToFrom || !belongsToTo) {
+ issues.push({
+ key: `restriction:${relation.id}:detached-via-node:${viaNode.ref}`,
+ description: `restriction ${relation.id} via node ${viaNode.ref} is detached from its from/to ways`,
+ });
+ }
+ }
+
+ if (viaWays.length > 0 && fromWays.length > 0 && toWays.length > 0) {
+ const connectedFrom = fromWays.some((way) => sharesNode(way, viaWays[0]!));
+ const connectedTo = toWays.some((way) => sharesNode(viaWays.at(-1)!, way));
+ const connectedChain = viaWays.every(
+ (way, index) => index === 0 || sharesNode(viaWays[index - 1]!, way),
+ );
+ if (!connectedFrom || !connectedChain || !connectedTo) {
+ issues.push({
+ key: `restriction:${relation.id}:detached-via-way-chain`,
+ description: `restriction ${relation.id} has a disconnected via-way chain`,
+ });
+ }
+ }
+
+ return issues;
+}
+
+function collectRoutingIntegrityIssues(osm: Osm): readonly IntegrityIssue[] {
+ const cachedIssues = routingIntegrityIssuesByOsm.get(osm);
+ if (cachedIssues) return cachedIssues;
+
+ const issues: IntegrityIssue[] = [];
+ const highwayWaysByNode = new Map();
+
+ for (const way of osm.ways) {
+ for (const ref of way.refs) {
+ if (osm.nodes.ids.has(ref)) continue;
+ issues.push({
+ key: `way:${way.id}:missing-node:${ref}`,
+ description: `way ${way.id} references missing node ${ref}`,
+ });
+ }
+ const distinctRefs = new Set(way.refs);
+ if (way.tags?.["highway"] != null && distinctRefs.size < 2) {
+ issues.push({
+ key: `way:${way.id}:degenerate-highway`,
+ description: `highway way ${way.id} has fewer than two distinct nodes`,
+ });
+ }
+ if (way.tags?.["highway"] != null) {
+ const gradeSignature = routingGradeSignature(way.tags);
+ const interiorRefs = new Set(way.refs.slice(1, -1));
+ const endpointRefs = new Set([way.refs[0], way.refs.at(-1)]);
+ for (const ref of distinctRefs) {
+ const incidentWays = highwayWaysByNode.get(ref) ?? [];
+ incidentWays.push({
+ way,
+ gradeSignature,
+ interior: interiorRefs.has(ref),
+ endpoint: endpointRefs.has(ref),
+ });
+ highwayWaysByNode.set(ref, incidentWays);
+ }
+ }
+ }
+
+ for (const [nodeId, ways] of highwayWaysByNode) {
+ for (let leftIndex = 0; leftIndex < ways.length; leftIndex++) {
+ for (let rightIndex = leftIndex + 1; rightIndex < ways.length; rightIndex++) {
+ const left = ways[leftIndex]!;
+ const right = ways[rightIndex]!;
+ if (left.gradeSignature === right.gradeSignature) continue;
+ if (!left.interior && !right.interior) continue;
+ if (hasSameGradeEndpointContinuation(ways, left, right)) continue;
+ const [firstWayId, secondWayId] = [left.way.id, right.way.id].toSorted((a, b) => a - b);
+ issues.push({
+ key: `node:${nodeId}:incompatible-grade:${firstWayId}:${secondWayId}`,
+ description: `node ${nodeId} newly connects grade-separated highways ${firstWayId} and ${secondWayId}`,
+ });
+ }
+ }
+ }
+
+ for (const relation of osm.relations) {
+ for (const member of relation.members) {
+ const exists =
+ member.type === "node"
+ ? osm.nodes.ids.has(member.ref)
+ : member.type === "way"
+ ? osm.ways.ids.has(member.ref)
+ : osm.relations.ids.has(member.ref);
+ if (exists) continue;
+ issues.push({
+ key: `relation:${relation.id}:missing-${member.type}:${member.ref}`,
+ description: `relation ${relation.id} references missing ${member.type} ${member.ref}`,
+ });
+ }
+ issues.push(...restrictionIssues(osm, relation));
+ }
+
+ if (osm.isReady()) routingIntegrityIssuesByOsm.set(osm, issues);
+ return issues;
+}
+
+export function routingIntegrityIssueKeys(osm: Osm) {
+ return new Set(collectRoutingIntegrityIssues(osm).map((issue) => issue.key));
+}
+
+/** Reuse analysis only when two finalized wrappers reference identical entity buffers. */
+export function reuseRoutingIntegrityAnalysis(source: Osm, target: Osm) {
+ const issues = collectRoutingIntegrityIssues(source);
+ if (target.isReady()) routingIntegrityIssuesByOsm.set(target, issues);
+}
+
+/**
+ * Combine inherited issues from both inputs while treating same-ID patch entities as
+ * modifications that must remain valid when their base counterpart was valid.
+ */
+export function inheritedRoutingIntegrityIssueKeys(
+ base: Osm,
+ patch: Osm,
+ baseKeys: ReadonlySet = routingIntegrityIssueKeys(base),
+) {
+ const keys = new Set(baseKeys);
+ for (const issue of collectRoutingIntegrityIssues(patch)) {
+ // Missing references and degenerate highways in a patch are never inherited:
+ // accepting them would allow malformed input to pass through unchanged.
+ if (isAbsoluteIntegrityIssue(issue)) continue;
+ const [kind, idText] = issue.key.split(":");
+ // Restriction topology must be evaluated in the merged entity context. A patch
+ // relation may legitimately reference base ways, so its patch-only issue is not
+ // evidence of a pre-existing defect and must never suppress merged validation.
+ if (kind === "restriction") continue;
+ const id = Number(idText);
+ const collidesWithBase =
+ kind === "node"
+ ? base.nodes.ids.has(id)
+ : kind === "way"
+ ? base.ways.ids.has(id)
+ : kind === "relation" || kind === "restriction"
+ ? base.relations.ids.has(id)
+ : false;
+ if (!collidesWithBase) keys.add(issue.key);
+ }
+ return keys;
+}
+
+/** Throw when a merge introduces routing-integrity issues not present in the base dataset. */
+export function assertNoNewRoutingIntegrityIssues(baselineKeys: ReadonlySet, merged: Osm) {
+ const newIssues = collectRoutingIntegrityIssues(merged).filter(
+ (issue) => !baselineKeys.has(issue.key),
+ );
+ if (newIssues.length === 0) return;
+
+ const descriptions = newIssues.slice(0, 10).map((issue) => issue.description);
+ const omitted = newIssues.length - descriptions.length;
+ const suffix = omitted > 0 ? `; and ${omitted} more` : "";
+ throw Error(`Merge introduced routing-integrity problems: ${descriptions.join("; ")}${suffix}`);
+}
+
+/**
+ * Ensure fuzzy conflation did not rewrite geometry or relation topology that already existed in
+ * the base. Same-ID patch updates are compared at the ordinary-merge baseline, not the raw base.
+ */
+export function assertConflationPreservesBaseTopology(
+ originalBase: Osm,
+ ordinaryBaseline: Osm,
+ conflated: Osm,
+) {
+ const violations: string[] = [];
+ for (const original of originalBase.nodes) {
+ const baseline = ordinaryBaseline.nodes.getById(original.id);
+ const result = conflated.nodes.getById(original.id);
+ if (!baseline || !result) {
+ violations.push(`base node ${original.id} was removed`);
+ continue;
+ }
+ if (baseline.lon !== result.lon || baseline.lat !== result.lat) {
+ violations.push(`base node ${original.id} coordinates changed`);
+ }
+ }
+ for (const original of originalBase.ways) {
+ const baseline = ordinaryBaseline.ways.getById(original.id);
+ const result = conflated.ways.getById(original.id);
+ if (!baseline || !result) {
+ violations.push(`base way ${original.id} was removed`);
+ continue;
+ }
+ if (
+ baseline.refs.length !== result.refs.length ||
+ baseline.refs.some((ref, index) => ref !== result.refs[index])
+ ) {
+ violations.push(`base way ${original.id} references changed`);
+ }
+ }
+ for (const original of originalBase.relations) {
+ const baseline = ordinaryBaseline.relations.getById(original.id);
+ const result = conflated.relations.getById(original.id);
+ if (!baseline || !result) {
+ violations.push(`base relation ${original.id} was removed`);
+ continue;
+ }
+ if (
+ baseline.members.length !== result.members.length ||
+ baseline.members.some((member, index) => {
+ const resultMember = result.members[index];
+ return (
+ !resultMember ||
+ member.type !== resultMember.type ||
+ member.ref !== resultMember.ref ||
+ member.role !== resultMember.role
+ );
+ })
+ ) {
+ violations.push(`base relation ${original.id} members changed`);
+ }
+ }
+ if (violations.length === 0) return;
+ const descriptions = violations.slice(0, 10);
+ const omitted = violations.length - descriptions.length;
+ const suffix = omitted > 0 ? `; and ${omitted} more` : "";
+ throw Error(`Conflation changed protected base topology: ${descriptions.join("; ")}${suffix}`);
+}
diff --git a/packages/change/src/internal/conflation.ts b/packages/change/src/internal/conflation.ts
new file mode 100644
index 00000000..222e8037
--- /dev/null
+++ b/packages/change/src/internal/conflation.ts
@@ -0,0 +1,15 @@
+/**
+ * Same-process conflation capabilities for trusted merge orchestrators.
+ *
+ * This module is deliberately absent from the package entry point. Its callers
+ * own both untouched inputs and the canonical discovery object for the lifetime
+ * of one merge. General callers must use the defensive public generators, which
+ * rediscover candidates before changing topology.
+ *
+ * @internal
+ */
+export {
+ discoverConflationCandidatesForTrustedMerge,
+ generateConflationApplicationArtifactsFromTrustedDiscovery,
+ generateConflationArtifactsFromTrustedDiscovery,
+} from "../conflation.ts";
diff --git a/packages/change/src/merge.ts b/packages/change/src/merge.ts
index 7720ccd0..867c53ab 100644
--- a/packages/change/src/merge.ts
+++ b/packages/change/src/merge.ts
@@ -1,8 +1,8 @@
/**
* High-level merge pipeline for OSM datasets.
*
- * Orchestrates a complete merge workflow including deduplication of nodes and ways
- * in both datasets, direct change generation, and optional intersection creation.
+ * Orchestrates direct change generation, conservative cross-dataset reconciliation,
+ * and optional intersection creation.
*
* @module
*/
@@ -11,7 +11,11 @@ import type { Osm } from "@osmix/core";
import { logProgress, type ProgressEvent, progressEvent } from "@osmix/shared/progress";
import { applyChangesetToOsm } from "./apply-changeset.ts";
-import { OsmChangeset } from "./changeset.ts";
+import { generateChangeset } from "./generate-changeset.ts";
+import {
+ discoverConflationCandidatesForTrustedMerge,
+ generateConflationApplicationArtifactsFromTrustedDiscovery,
+} from "./internal/conflation.ts";
import type { OsmMergeOptions } from "./types.ts";
import { changeStatsSummary } from "./utils.ts";
@@ -19,10 +23,10 @@ import { changeStatsSummary } from "./utils.ts";
* Run a full merge pipeline on two OSM datasets.
*
* Executes a multi-stage merge process:
- * 1. Deduplicates nodes and ways in both base and patch datasets
- * 2. Optionally generates direct changes from patch to base (`directMerge`)
- * 3. Optionally deduplicates nodes/ways in the final merged dataset
- * 4. Optionally creates intersection nodes where ways cross
+ * 1. Optionally generates direct changes from patch to base (`directMerge`)
+ * 2. Optionally reconciles coincident patch nodes/ways with base entities
+ * 3. Optionally creates intersection nodes where ways cross
+ * 4. Verifies that the merge introduced no new routing-integrity problems
*
* @param base - The base OSM dataset to merge into.
* @param patch - The patch OSM dataset to merge from.
@@ -47,61 +51,59 @@ export async function merge(
onProgress: (progress: ProgressEvent) => void = logProgress,
) {
const log = (msg: string) => onProgress(progressEvent(msg));
- // De-duplicate nodes and ways in original datasets
- log("Deduplicating ways in base OSM...");
- let changeset = new OsmChangeset(base);
- changeset.deduplicateWays(base.ways);
- log(changeStatsSummary(changeset.stats));
- let modifiedBase = applyChangesetToOsm(changeset);
+ let modifiedBase = base;
- log("Deduplicating nodes in base OSM...");
- changeset = new OsmChangeset(modifiedBase);
- changeset.deduplicateNodes(modifiedBase.nodes);
- log(changeStatsSummary(changeset.stats));
- modifiedBase = applyChangesetToOsm(changeset);
-
- log("Deduplicating ways in patch OSM...");
- changeset = new OsmChangeset(patch);
- changeset.deduplicateWays(patch.ways);
- log(changeStatsSummary(changeset.stats));
- let modifiedPatch = applyChangesetToOsm(changeset);
-
- log("Deduplicating nodes in patch OSM...");
- changeset = new OsmChangeset(modifiedPatch);
- changeset.deduplicateNodes(modifiedPatch.nodes);
- log(changeStatsSummary(changeset.stats));
- modifiedPatch = applyChangesetToOsm(changeset);
-
- // Generate direct changes
- if (options.directMerge) {
- log("Generating direct changes from patch OSM to base OSM...");
- changeset = new OsmChangeset(modifiedBase);
- changeset.generateDirectChanges(modifiedPatch);
+ // Generate direct changes and reconcile against the original, immutable base in
+ // one changeset. This keeps patch entities out of the base candidate pool.
+ if (options.directMerge || options.deduplicateNodes || options.deduplicateWays) {
+ const changeset = generateChangeset(
+ base,
+ patch,
+ {
+ directMerge: options.directMerge ?? false,
+ deduplicateNodes: options.deduplicateNodes ?? false,
+ deduplicateWays: options.deduplicateWays ?? false,
+ createIntersections: false,
+ },
+ onProgress,
+ );
log(changeStatsSummary(changeset.stats));
modifiedBase = applyChangesetToOsm(changeset);
}
- // De-duplicate nodes and ways in final dataset
- if (options.deduplicateWays) {
- log("Deduplicating ways in final dataset...");
- changeset = new OsmChangeset(modifiedBase);
- changeset.deduplicateWays(modifiedPatch.ways);
- log(changeStatsSummary(changeset.stats));
- modifiedBase = applyChangesetToOsm(changeset);
- }
- if (options.deduplicateNodes) {
- log("Deduplicating nodes in final dataset...");
- changeset = new OsmChangeset(modifiedBase);
- changeset.deduplicateNodes(modifiedPatch.nodes);
- log(changeStatsSummary(changeset.stats));
- modifiedBase = applyChangesetToOsm(changeset);
+ if (options.conflation) {
+ if (!options.directMerge) {
+ throw Error("Fuzzy conflation requires directMerge to preserve unmatched patch entities");
+ }
+ log(`Discovering imported-data matches from ${patch.id} against ${base.id}...`);
+ // Fuzzy discovery always sees untouched inputs. The ordinary result is only the
+ // application baseline, preventing transitive matches through imported entities.
+ const discovery = discoverConflationCandidatesForTrustedMerge(base, patch, options.conflation);
+ const ordinaryBaseline = modifiedBase;
+ const conflation = generateConflationApplicationArtifactsFromTrustedDiscovery(
+ ordinaryBaseline,
+ patch,
+ discovery,
+ base,
+ options.conflation.decisions ?? [],
+ );
+ // Generation already materialized and validated this exact result. Installing
+ // it directly avoids a second full decode, index build, and integrity pass.
+ modifiedBase = conflation.result;
+ log(
+ `Conflation candidates: ${discovery.summary.automatic.toLocaleString()} automatic, ${discovery.summary.review.toLocaleString()} review, ${discovery.summary.blocked.toLocaleString()} blocked, ${discovery.summary.unmatched.toLocaleString()} unmatched`,
+ );
}
- // Create intersections
+ // Intersections run after conflation so accepted patch attachments participate in
+ // crossing insertion, while candidate discovery remains based on untouched inputs.
if (options.createIntersections) {
- log("Creating intersections in final dataset...");
- changeset = new OsmChangeset(modifiedBase);
- changeset.createIntersectionsForWays(modifiedPatch.ways);
+ const changeset = generateChangeset(
+ modifiedBase,
+ patch,
+ { createIntersections: true },
+ onProgress,
+ );
log(changeStatsSummary(changeset.stats));
modifiedBase = applyChangesetToOsm(changeset);
}
diff --git a/packages/change/src/sweepline-intersections.ts b/packages/change/src/sweepline-intersections.ts
index f5bfba57..d1a1c907 100644
--- a/packages/change/src/sweepline-intersections.ts
+++ b/packages/change/src/sweepline-intersections.ts
@@ -253,37 +253,45 @@ function processFeature(
for (let i = 0; i < coords.length; i++) {
for (let ii = 0; ii < coords[i]!.length; ii++) {
- const ring = coords[i]![ii]!;
- let currentP = ring[0]!;
- let nextP: Position | null = null;
- ringId = ringId + 1;
- for (let iii = 0; iii < ring.length - 1; iii++) {
- nextP = ring[iii + 1]!;
-
- const e1 = new Event(currentP, featureId, ringId, eventId);
- const e2 = new Event(nextP, featureId, ringId, eventId + 1);
-
- e1.otherEvent = e2;
- e2.otherEvent = e1;
-
- if (checkWhichEventIsLeft(e1, e2) > 0) {
- e2.isLeftEndpoint = true;
- e1.isLeftEndpoint = false;
- } else {
- e1.isLeftEndpoint = true;
- e2.isLeftEndpoint = false;
- }
- eventQueue.push(e1);
- eventQueue.push(e2);
-
- currentP = nextP;
- eventId = eventId + 1;
- }
+ fillLineEventQueue(coords[i]![ii]!, eventQueue);
}
}
featureId = featureId + 1;
}
+/**
+ * Add one line to the same event queue used by the GeoJSON-compatible entry point.
+ * Keeping this as the single event-construction path lets the merge hot path avoid
+ * temporary GeoJSON wrappers without changing the robust intersection kernel.
+ */
+function fillLineEventQueue(line: readonly Position[], eventQueue: TinyQueue): void {
+ let currentP = line[0]!;
+ let nextP: Position | null = null;
+ ringId = ringId + 1;
+ for (let index = 0; index < line.length - 1; index++) {
+ nextP = line[index + 1]!;
+
+ const e1 = new Event(currentP, featureId, ringId, eventId);
+ const e2 = new Event(nextP, featureId, ringId, eventId + 1);
+
+ e1.otherEvent = e2;
+ e2.otherEvent = e1;
+
+ if (checkWhichEventIsLeft(e1, e2) > 0) {
+ e2.isLeftEndpoint = true;
+ e1.isLeftEndpoint = false;
+ } else {
+ e1.isLeftEndpoint = true;
+ e2.isLeftEndpoint = false;
+ }
+ eventQueue.push(e1);
+ eventQueue.push(e2);
+
+ currentP = nextP;
+ eventId = eventId + 1;
+ }
+}
+
class Segment {
leftSweepEvent: Event;
rightSweepEvent: Event;
@@ -695,3 +703,22 @@ export default function sweeplineIntersections(
): [number, number][] {
return sweeplineIntersectionsRuntime(geojson, ignoreSelfIntersections);
}
+
+/**
+ * Check two lines with the same event ordering and robust predicates as the
+ * GeoJSON-compatible runtime, but without allocating wrapper features.
+ *
+ * This intentionally remains internal to `@osmix/change`: callers that need
+ * general GeoJSON support should use the default entry point above.
+ */
+export function sweeplineLineIntersections(
+ lineA: readonly Point[],
+ lineB: readonly Point[],
+): [number, number][] {
+ const eventQueue = new TinyQueue([], checkWhichEventIsLeft);
+ fillLineEventQueue(lineA, eventQueue);
+ featureId++;
+ fillLineEventQueue(lineB, eventQueue);
+ featureId++;
+ return runCheck(eventQueue, true);
+}
diff --git a/packages/change/src/types.ts b/packages/change/src/types.ts
index eb23318b..67a01d70 100644
--- a/packages/change/src/types.ts
+++ b/packages/change/src/types.ts
@@ -51,6 +51,172 @@ export interface OsmMergeOptions {
deduplicateNodes: boolean;
deduplicateWays: boolean;
createIntersections: boolean;
+
+ /** Optional, explicitly configured cross-dataset proximity conflation. */
+ conflation?: OsmConflationOptions;
+}
+
+/** Entity kinds supported by fuzzy conflation. */
+export type OsmConflationEntityType = "node" | "way";
+
+/** Whether high-confidence candidates should be accepted without a review decision. */
+export type OsmConflationAutomatic = "high-confidence" | "none";
+
+/** Intrinsic classification of a discovered source/target match. */
+export type OsmConflationStatus = "automatic" | "review" | "blocked" | "unmatched";
+
+/** Candidate status after applying an optional user decision. */
+export type OsmConflationEffectiveStatus = OsmConflationStatus | "accepted" | "rejected";
+
+/** Stable, machine-readable explanations for a conflation classification. */
+export type OsmConflationReasonCode =
+ | "bearing-mismatch"
+ | "drivable-network"
+ | "exact-match"
+ | "geometry-mismatch"
+ | "grade-conflict"
+ | "length-mismatch"
+ | "many-to-one"
+ | "multiple-targets"
+ | "no-transferable-properties"
+ | "node-context-conflict"
+ | "non-routing-target"
+ | "protected-tag"
+ | "relation-member"
+ | "routing-family-conflict"
+ | "routing-property"
+ | "same-id"
+ | "unsupported-way-chain"
+ | "would-collapse-way";
+
+/** A selected patch tag and the value it would replace on the base entity. */
+export interface OsmConflationTagDiff {
+ key: string;
+ patchValue: string | number;
+ baseValue?: string | number;
+ protected: boolean;
+ routing: boolean;
+}
+
+/** Serializable matching evidence used by the UI and deterministic tests. */
+export interface OsmConflationEvidence {
+ distanceMeters: number;
+ sourceRoutingFamilies: OsmConflationRoutingFamily[];
+ targetRoutingFamilies: OsmConflationRoutingFamily[];
+ tagDiff: OsmConflationTagDiff[];
+ patchWayIds?: number[];
+ bearingDifferenceDegrees?: number;
+ endpointDistancesMeters?: [number, number];
+ lengthDifferenceRatio?: number;
+ maxGeometryDistanceMeters?: number;
+}
+
+/** Normalized routing contexts used to compare imported and base geometry. */
+export type OsmConflationRoutingFamily =
+ | "bicycle-shared"
+ | "motor-road"
+ | "non-routable"
+ | "pedestrian";
+
+/** Classification for one independently selectable conflation action. */
+export interface OsmConflationActionAssessment {
+ status: OsmConflationStatus;
+ reasons: OsmConflationReasonCode[];
+}
+
+/** One stable source/target candidate. Ambiguous sources have one row per target. */
+export interface OsmConflationCandidate {
+ id: string;
+ entityType: OsmConflationEntityType;
+ sourceId: number;
+ targetId: number | null;
+ status: OsmConflationStatus;
+ reasons: OsmConflationReasonCode[];
+ propertyTransfer: OsmConflationActionAssessment;
+ networkAttachment: OsmConflationActionAssessment | null;
+ evidence: OsmConflationEvidence;
+}
+
+/** Explicit fuzzy-conflation configuration. Property transfer is disabled by an empty key list. */
+export interface OsmConflationOptions {
+ propertyKeys: string[];
+ attachNetwork: boolean;
+ maxDistanceMeters?: number;
+ automatic?: OsmConflationAutomatic;
+ decisions?: OsmConflationDecision[];
+}
+
+/** Fully defaulted options captured with a deterministic discovery result. */
+export interface ResolvedOsmConflationOptions {
+ propertyKeys: string[];
+ attachNetwork: boolean;
+ maxDistanceMeters: number;
+ automatic: OsmConflationAutomatic;
+}
+
+/** A user's explicit choice for a discovered source/target pair. */
+export interface OsmConflationDecision {
+ candidateId: string;
+ action: "accept" | "reject";
+ transferProperties?: boolean;
+ attachNetwork?: boolean;
+}
+
+/** A filter-wide review operation performed atomically in the conflation worker. */
+export type OsmConflationBulkAction = "transfer-properties" | "attach-network" | "reject";
+
+/** Stable input for applying one bulk decision to all candidates matching a filter. */
+export interface OsmConflationBulkDecisionRequest {
+ action: OsmConflationBulkAction;
+ filter: OsmConflationCandidateFilter;
+}
+
+/** Counts shown before confirming a filter-wide decision. */
+export interface OsmConflationBulkDecisionPreview {
+ action: OsmConflationBulkAction;
+ filteredCandidates: number;
+ eligibleCandidates: number;
+ changedCandidates: number;
+ skippedCandidates: number;
+ automaticCandidates: number;
+ reviewCandidates: number;
+ overriddenDecisions: number;
+}
+
+/** Atomic result returned after a filter-wide decision is applied. */
+export interface OsmConflationBulkDecisionResult {
+ decisions: OsmConflationDecision[];
+ preview: OsmConflationBulkDecisionPreview;
+ summary: OsmConflationSummary;
+}
+
+/** Counts used to present discovery and review progress. */
+export interface OsmConflationSummary {
+ total: number;
+ accepted: number;
+ automatic: number;
+ review: number;
+ blocked: number;
+ unmatched: number;
+ rejected: number;
+}
+
+/** Deterministic discovery result produced only from untouched inputs. */
+export interface OsmConflationDiscovery {
+ baseOsmId: string;
+ patchOsmId: string;
+ options: ResolvedOsmConflationOptions;
+ candidates: OsmConflationCandidate[];
+ summary: OsmConflationSummary;
+}
+
+/** Serializable filters used by paged worker APIs. */
+export interface OsmConflationCandidateFilter {
+ entityType?: OsmConflationEntityType;
+ status?: OsmConflationEffectiveStatus;
+ reason?: OsmConflationReasonCode;
+ sourceId?: number;
+ targetId?: number | null;
}
/**
diff --git a/packages/change/src/utils.ts b/packages/change/src/utils.ts
index 1b973b33..50795e2d 100644
--- a/packages/change/src/utils.ts
+++ b/packages/change/src/utils.ts
@@ -13,7 +13,7 @@
import { haversineDistance } from "@osmix/geo/haversine-distance";
import type { OsmEntity, OsmRelation, OsmTags, OsmWay } from "@osmix/types";
-import sweeplineIntersections from "./sweepline-intersections.ts";
+import { sweeplineLineIntersections } from "./sweepline-intersections.ts";
import type { OsmChangesetStats } from "./types.ts";
const XML_ATTRIBUTE_ESCAPES: Record = {
@@ -106,11 +106,30 @@ const isFootish = (t: OsmTags) =>
["footway", "path", "cycleway", "bridleway", "steps"].includes(String(t["highway"]));
const isPolygonish = (t: OsmTags) => !!(t["building"] || t["landuse"] || t["natural"]);
+function normalizedGradeValue(value: number | string | undefined, defaultValue: string) {
+ const normalized = String(value ?? "");
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "no") {
+ return defaultValue;
+ }
+ return normalized;
+}
+
+/** Normalize the routing-relevant vertical context of a way for safe comparisons. */
+export function routingGradeSignature(tags?: OsmTags) {
+ return [
+ `layer=${String(tags?.["layer"] ?? "0")}`,
+ `level=${String(tags?.["level"] ?? "")}`,
+ `bridge=${normalizedGradeValue(tags?.["bridge"], "no")}`,
+ `tunnel=${normalizedGradeValue(tags?.["tunnel"], "no")}`,
+ `covered=${normalizedGradeValue(tags?.["covered"], "no")}`,
+ ].join("|");
+}
+
/**
* Determine if two ways should be connected based on their tags.
* Connection logic:
* - Never connect if either is an area (building, landuse, etc).
- * - Never connect if separated by bridge/tunnel/layer.
+ * - Never connect if layer, level, bridge, tunnel, or covered context differs.
* - Connect highway-highway, highway-footway, footway-footway.
*/
export function waysShouldConnect(tagsA?: OsmTags, tagsB?: OsmTags) {
@@ -118,9 +137,7 @@ export function waysShouldConnect(tagsA?: OsmTags, tagsB?: OsmTags) {
const b = tagsB || {};
if (isPolygonish(a) || isPolygonish(b)) return false;
- const isSeparated = !!(a["bridge"] || a["tunnel"] || b["bridge"] || b["tunnel"]);
- const diffLayer = (a["layer"] ?? "0") !== (b["layer"] ?? "0");
- if (isSeparated || diffLayer) return false;
+ if (routingGradeSignature(a) !== routingGradeSignature(b)) return false;
if (isHighway(a) && isHighway(b)) return true;
if (isHighway(a) && isFootish(b)) return true;
@@ -133,8 +150,13 @@ export function waysShouldConnect(tagsA?: OsmTags, tagsB?: OsmTags) {
/**
* Determine if a way is a candidate for connecting to another way
*/
+export function areWayTagsIntersectionCandidate(tags?: OsmTags) {
+ return !!tags && (isHighway(tags) || isFootish(tags)) && !isPolygonish(tags);
+}
+
+/** Determine if a complete way is a candidate for connecting to another way. */
export function isWayIntersectionCandidate(way: OsmWay) {
- return way.tags && (isHighway(way.tags) || isFootish(way.tags)) && !isPolygonish(way.tags);
+ return areWayTagsIntersectionCandidate(way.tags);
}
/**
@@ -172,30 +194,7 @@ export function waysIntersect(
wayA: [number, number][],
wayB: [number, number][],
): [number, number][] {
- const intersections = sweeplineIntersections(
- {
- type: "FeatureCollection",
- features: [
- {
- type: "Feature",
- geometry: {
- type: "LineString",
- coordinates: wayA,
- },
- properties: {},
- },
- {
- type: "Feature",
- geometry: {
- type: "LineString",
- coordinates: wayB,
- },
- properties: {},
- },
- ],
- },
- true,
- );
+ const intersections = sweeplineLineIntersections(wayA, wayB);
const uniqueFeatures: [number, number][] = [];
const seen = new Set();
diff --git a/packages/change/test/apply-changeset.test.ts b/packages/change/test/apply-changeset.test.ts
index 931b0b2f..f0311d5b 100644
--- a/packages/change/test/apply-changeset.test.ts
+++ b/packages/change/test/apply-changeset.test.ts
@@ -67,6 +67,33 @@ function serializeEntities(osm: OsmType) {
}
describe("applyChangesetToOsm", () => {
+ it("reuses immutable buffers for an empty changeset while returning a fresh wrapper", () => {
+ const base = createBaseOsm();
+ base.buildSpatialIndexes();
+ const changeset = new OsmChangeset(base);
+
+ const result = applyChangesetToOsm(changeset, "empty-result");
+
+ expect(result).not.toBe(base);
+ expect(result.id).toBe("empty-result");
+ expect(result.contentHash()).toBe(base.contentHash());
+ expect(result.hasSpatialIndexes()).toBe(true);
+ expect(serializeEntities(result)).toEqual(serializeEntities(base));
+ expect(result.nodes.transferables().ids).toBe(base.nodes.transferables().ids);
+ expect(result.ways.transferables().refs).toBe(base.ways.transferables().refs);
+ });
+
+ it("builds missing spatial indexes when an empty base is not fully indexed", () => {
+ const base = createBaseOsm();
+ const changeset = new OsmChangeset(base);
+
+ const result = applyChangesetToOsm(changeset);
+
+ expect(base.hasSpatialIndexes()).toBe(false);
+ expect(result.hasSpatialIndexes()).toBe(true);
+ expect(serializeEntities(result)).toEqual(serializeEntities(base));
+ });
+
it("preserves changeset records and supports applying the same object twice", () => {
const base = createBaseOsm();
const changeset = createChangeset(base);
@@ -162,4 +189,18 @@ describe("applyChangesetToOsm", () => {
expect(JSON.stringify(way)).toBe(before);
});
+
+ it("preserves the invalid-stage error for a change whose ID is absent from the base", () => {
+ const base = createBaseOsm();
+ const changeset = new OsmChangeset(base);
+ changeset.nodeChanges[999] = {
+ changeType: "modify",
+ entity: { id: 999, lon: -120, lat: 46 },
+ osmId: base.id,
+ };
+
+ expect(() => applyChangesetToOsm(changeset)).toThrow(
+ "Changeset still contains node changes in incorrect stage.",
+ );
+ });
});
diff --git a/packages/change/test/conflation.test.ts b/packages/change/test/conflation.test.ts
new file mode 100644
index 00000000..2c594c34
--- /dev/null
+++ b/packages/change/test/conflation.test.ts
@@ -0,0 +1,1264 @@
+import { Osm } from "@osmix/core";
+import type { OsmNode, OsmRelation, OsmWay } from "@osmix/types";
+import { describe, expect, it, vi } from "vitest";
+
+import { applyChangesetToOsm } from "../src/apply-changeset.ts";
+import {
+ buildConflationBulkDecisionResult,
+ discoverConflationCandidates,
+ filterConflationCandidates,
+ generateConflationApplicationChangeset,
+ generateConflationChangeset,
+ summarizeConflationCandidates,
+ validateConflationDecisions,
+} from "../src/conflation.ts";
+import { generateChangeset } from "../src/generate-changeset.ts";
+import * as publicChangeApi from "../src/index.ts";
+import { merge } from "../src/merge.ts";
+import type {
+ OsmConflationCandidate,
+ OsmConflationDecision,
+ OsmConflationOptions,
+} from "../src/types.ts";
+
+function createOsm(
+ id: string,
+ nodes: OsmNode[],
+ ways: OsmWay[] = [],
+ relations: OsmRelation[] = [],
+) {
+ const osm = new Osm({ id });
+ for (const node of nodes) osm.nodes.addNode(node);
+ for (const way of ways) osm.ways.addWay(way);
+ for (const relation of relations) osm.relations.addRelation(relation);
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ return osm;
+}
+
+const silent = () => {};
+
+const attachmentOptions: OsmConflationOptions = {
+ propertyKeys: [],
+ attachNetwork: true,
+};
+
+describe("safe fuzzy conflation discovery", () => {
+ it("keeps trusted generation capabilities out of the public package API", () => {
+ expect(publicChangeApi).not.toHaveProperty("discoverConflationCandidatesForTrustedMerge");
+ expect(publicChangeApi).not.toHaveProperty("generateConflationArtifactsFromTrustedDiscovery");
+ expect(publicChangeApi).not.toHaveProperty(
+ "generateConflationApplicationArtifactsFromTrustedDiscovery",
+ );
+ });
+
+ it("automatically attaches a unique aligned imported sidewalk without moving the base", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+
+ const discovery = discoverConflationCandidates(base, patch, attachmentOptions);
+ const match = discovery.candidates.find((candidate) => candidate.sourceId === 101);
+ expect(match).toMatchObject({
+ id: "node:101->1",
+ status: "automatic",
+ networkAttachment: { status: "automatic" },
+ });
+ expect(match?.evidence.distanceMeters).toBeGreaterThan(0.5);
+ expect(match?.evidence.distanceMeters).toBeLessThan(0.6);
+
+ const buildIndexes = vi.spyOn(Osm.prototype, "buildIndexes");
+ let result!: Osm;
+ try {
+ result = await merge(
+ base,
+ patch,
+ { directMerge: true, conflation: attachmentOptions },
+ silent,
+ );
+ expect(buildIndexes).toHaveBeenCalledTimes(2);
+ } finally {
+ buildIndexes.mockRestore();
+ }
+ expect(result.nodes.getById(1)).toMatchObject({ lon: 0, lat: 0 });
+ expect(result.nodes.ids.has(101)).toBe(true);
+ expect(result.ways.getById(10)?.refs).toEqual([2, 1]);
+ expect(result.ways.getById(20)?.refs).toEqual([1, 102]);
+ const cumulative = applyChangesetToOsm(
+ generateConflationChangeset(base, patch, {
+ directMerge: true,
+ conflation: attachmentOptions,
+ }),
+ );
+ expect([...cumulative.nodes].map((node) => node.id)).toEqual(
+ [...result.nodes].map((node) => node.id),
+ );
+ expect(cumulative.ways.getById(20)?.refs).toEqual(result.ways.getById(20)?.refs);
+ });
+
+ it("blocks an area-only school boundary vertex near a routing node", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ { id: 103, lon: 0.001, lat: 0.001 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102, 103, 101],
+ tags: { boundary: "school", area: "yes" },
+ },
+ ],
+ );
+
+ const match = discoverConflationCandidates(base, patch, attachmentOptions).candidates.find(
+ (candidate) => candidate.sourceId === 101,
+ );
+ expect(match?.status).toBe("blocked");
+ expect(match?.reasons).toContain("non-routing-target");
+ });
+
+ it("does not automatically transfer properties between a footway and school boundary", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0, tags: { name: "School boundary" } },
+ { id: 102, lon: 0.001, lat: 0 },
+ { id: 103, lon: 0.001, lat: 0.001 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102, 103, 101],
+ tags: { boundary: "school", area: "yes" },
+ },
+ ],
+ );
+ const candidate = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ }).candidates.find((item) => item.sourceId === 101);
+ expect(candidate?.propertyTransfer.status).toBe("blocked");
+ expect(candidate?.propertyTransfer.reasons).toContain("non-routing-target");
+ });
+
+ it("classifies multiple targets and many-to-one matches for review", () => {
+ const base = createOsm("base", [
+ { id: 1, lon: -0.000003, lat: 0, tags: { name: "A" } },
+ { id: 2, lon: 0.000003, lat: 0, tags: { name: "B" } },
+ { id: 3, lon: 0.001, lat: 0, tags: { name: "C" } },
+ ]);
+ const patch = createOsm("patch", [
+ { id: 101, lon: 0, lat: 0, tags: { name: "Imported A" } },
+ { id: 102, lon: 0.001005, lat: 0, tags: { name: "Imported C 1" } },
+ { id: 103, lon: 0.000995, lat: 0, tags: { name: "Imported C 2" } },
+ ]);
+
+ const discovery = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+ const ambiguous = discovery.candidates.filter((candidate) => candidate.sourceId === 101);
+ expect(ambiguous).toHaveLength(2);
+ expect(ambiguous.every((candidate) => candidate.status === "review")).toBe(true);
+ expect(ambiguous.every((candidate) => candidate.reasons.includes("multiple-targets"))).toBe(
+ true,
+ );
+ const manyToOne = discovery.candidates.filter((candidate) => candidate.targetId === 3);
+ expect(manyToOne).toHaveLength(2);
+ expect(manyToOne.every((candidate) => candidate.reasons.includes("many-to-one"))).toBe(true);
+ });
+
+ it("keeps decision summaries and filters lightweight", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0, tags: { name: "Base" } }]);
+ const patch = createOsm("patch", [{ id: 101, lon: 0.000005, lat: 0, tags: { name: "Patch" } }]);
+ const discovery = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+ const decisions = [{ candidateId: "node:101->1", action: "reject" as const }];
+ expect(summarizeConflationCandidates(discovery.candidates, decisions)).toMatchObject({
+ total: 1,
+ automatic: 0,
+ rejected: 1,
+ });
+ expect(
+ filterConflationCandidates(discovery.candidates, { status: "rejected" }, decisions),
+ ).toHaveLength(1);
+
+ const accepted = [{ candidateId: "node:101->1", action: "accept" as const }];
+ expect(summarizeConflationCandidates(discovery.candidates, accepted)).toMatchObject({
+ total: 1,
+ accepted: 1,
+ automatic: 0,
+ });
+ expect(
+ filterConflationCandidates(discovery.candidates, { status: "accepted" }, accepted),
+ ).toHaveLength(1);
+ });
+
+ it("builds filter-wide action-specific decisions and skips ambiguous candidates", () => {
+ const automatic: OsmConflationCandidate = {
+ id: "node:101->1",
+ entityType: "node",
+ sourceId: 101,
+ targetId: 1,
+ status: "automatic",
+ reasons: [],
+ propertyTransfer: { status: "automatic", reasons: [] },
+ networkAttachment: { status: "automatic", reasons: [] },
+ evidence: {
+ distanceMeters: 0.5,
+ sourceRoutingFamilies: ["pedestrian"],
+ targetRoutingFamilies: ["pedestrian"],
+ tagDiff: [{ key: "name", patchValue: "Imported", protected: false, routing: false }],
+ },
+ };
+ const review: OsmConflationCandidate = {
+ ...structuredClone(automatic),
+ id: "node:102->2",
+ sourceId: 102,
+ targetId: 2,
+ status: "review",
+ reasons: ["routing-property"],
+ propertyTransfer: { status: "review", reasons: ["routing-property"] },
+ };
+ const ambiguous: OsmConflationCandidate = {
+ ...structuredClone(review),
+ id: "node:103->3",
+ sourceId: 103,
+ targetId: 3,
+ reasons: ["multiple-targets"],
+ propertyTransfer: { status: "review", reasons: ["multiple-targets"] },
+ networkAttachment: { status: "review", reasons: ["multiple-targets"] },
+ };
+ const blocked: OsmConflationCandidate = {
+ ...structuredClone(automatic),
+ id: "node:104->4",
+ sourceId: 104,
+ targetId: 4,
+ status: "blocked",
+ reasons: ["grade-conflict"],
+ propertyTransfer: { status: "blocked", reasons: ["grade-conflict"] },
+ networkAttachment: { status: "blocked", reasons: ["grade-conflict"] },
+ };
+ const candidates = [automatic, review, ambiguous, blocked];
+ const initialDecisions: OsmConflationDecision[] = [
+ { candidateId: review.id, action: "reject" },
+ ];
+
+ const propertyResult = buildConflationBulkDecisionResult(candidates, initialDecisions, {
+ action: "transfer-properties",
+ filter: { entityType: "node" },
+ });
+ expect(propertyResult.preview).toEqual({
+ action: "transfer-properties",
+ filteredCandidates: 4,
+ eligibleCandidates: 2,
+ changedCandidates: 2,
+ skippedCandidates: 2,
+ automaticCandidates: 1,
+ reviewCandidates: 1,
+ overriddenDecisions: 1,
+ });
+ expect(propertyResult.decisions).toEqual([
+ {
+ candidateId: automatic.id,
+ action: "accept",
+ transferProperties: true,
+ attachNetwork: true,
+ },
+ {
+ candidateId: review.id,
+ action: "accept",
+ transferProperties: true,
+ attachNetwork: false,
+ },
+ ]);
+ expect(propertyResult.summary).toMatchObject({ accepted: 2, blocked: 1, review: 1 });
+
+ const networkResult = buildConflationBulkDecisionResult(candidates, propertyResult.decisions, {
+ action: "attach-network",
+ filter: { status: "accepted" },
+ });
+ expect(networkResult.preview).toMatchObject({
+ filteredCandidates: 2,
+ eligibleCandidates: 2,
+ changedCandidates: 1,
+ skippedCandidates: 0,
+ overriddenDecisions: 1,
+ });
+ expect(networkResult.decisions.find((decision) => decision.candidateId === review.id)).toEqual({
+ candidateId: review.id,
+ action: "accept",
+ transferProperties: true,
+ attachNetwork: true,
+ });
+
+ const rejectResult = buildConflationBulkDecisionResult(candidates, networkResult.decisions, {
+ action: "reject",
+ filter: { status: "accepted" },
+ });
+ expect(rejectResult.preview).toMatchObject({
+ filteredCandidates: 2,
+ eligibleCandidates: 2,
+ changedCandidates: 2,
+ skippedCandidates: 0,
+ overriddenDecisions: 2,
+ });
+ expect(rejectResult.summary).toMatchObject({ rejected: 2, blocked: 1, review: 1 });
+
+ const scopedResult = buildConflationBulkDecisionResult(
+ candidates,
+ [...networkResult.decisions, { candidateId: blocked.id, action: "reject" }],
+ { action: "reject", filter: { sourceId: automatic.sourceId } },
+ );
+ expect(scopedResult.decisions).toContainEqual({
+ candidateId: blocked.id,
+ action: "reject",
+ });
+ });
+
+ it("validates required configuration fields for untyped callers", () => {
+ const base = createOsm("base", []);
+ const patch = createOsm("patch", []);
+ expect(() =>
+ discoverConflationCandidates(base, patch, {
+ attachNetwork: false,
+ } as unknown as OsmConflationOptions),
+ ).toThrow("propertyKeys must be an array");
+ expect(() =>
+ discoverConflationCandidates(base, patch, {
+ propertyKeys: [""],
+ attachNetwork: false,
+ }),
+ ).toThrow("non-empty strings");
+ expect(() =>
+ discoverConflationCandidates(base, patch, {
+ propertyKeys: ["name"],
+ } as unknown as OsmConflationOptions),
+ ).toThrow("attachNetwork must be a boolean");
+ });
+
+ it("rejects stale, duplicate, and malformed decisions at the generation boundary", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0, tags: { name: "Base" } }]);
+ const patch = createOsm("patch", [{ id: 101, lon: 0.000005, lat: 0, tags: { name: "Patch" } }]);
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const discovery = discoverConflationCandidates(base, patch, conflation);
+ const generate = (decisions: readonly OsmConflationDecision[]) =>
+ generateConflationApplicationChangeset(base, patch, discovery, base, decisions);
+ const validDecisions: OsmConflationDecision[] = [
+ { candidateId: "node:101->1", action: "accept", transferProperties: true },
+ ];
+ const beforeValidation = structuredClone(validDecisions);
+ expect(() => validateConflationDecisions(discovery.candidates, validDecisions)).not.toThrow();
+ expect(validDecisions).toEqual(beforeValidation);
+
+ expect(() => generate([{ candidateId: "node:missing->1", action: "accept" }])).toThrow(
+ "Unknown conflation candidate: node:missing->1",
+ );
+ expect(() =>
+ generate([
+ { candidateId: "node:101->1", action: "accept" },
+ { candidateId: "node:101->1", action: "reject" },
+ ]),
+ ).toThrow("Duplicate conflation decision for node:101->1");
+ expect(() =>
+ generate([
+ { candidateId: "node:101->1", action: "approve" },
+ ] as unknown as OsmConflationDecision[]),
+ ).toThrow("Invalid conflation decision action for node:101->1");
+ expect(() =>
+ generate([
+ { candidateId: "node:101->1", action: "accept", transferProperties: "yes" },
+ ] as unknown as OsmConflationDecision[]),
+ ).toThrow("transferProperties must be a boolean for node:101->1");
+ expect(() =>
+ generate([
+ { candidateId: "node:101->1", action: "accept", attachNetwork: null },
+ ] as unknown as OsmConflationDecision[]),
+ ).toThrow("attachNetwork must be a boolean for node:101->1");
+ expect(() => generate({} as unknown as OsmConflationDecision[])).toThrow(
+ "Conflation decisions must be an array",
+ );
+
+ expect(() =>
+ generateConflationChangeset(base, patch, {
+ directMerge: true,
+ conflation: {
+ ...conflation,
+ decisions: [{ candidateId: "node:stale->1", action: "reject" }],
+ },
+ }),
+ ).toThrow("Unknown conflation candidate: node:stale->1");
+ });
+
+ it("rejects a fuzzy-only discovery from another merge session", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const patch = createOsm("patch", [{ id: 101, lon: 0.000005, lat: 0 }]);
+ const otherBase = createOsm("other-base", [{ id: 2, lon: 0, lat: 0 }]);
+ const otherPatch = createOsm("other-patch", [{ id: 102, lon: 0.000005, lat: 0 }]);
+ const discovery = discoverConflationCandidates(otherBase, otherPatch, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+
+ expect(() => generateConflationApplicationChangeset(base, patch, discovery, base)).toThrow(
+ "Conflation discovery patch other-patch does not match patch",
+ );
+ });
+
+ it("rejects unknown decisions through the high-level merge API", async () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0, tags: { name: "Base" } }]);
+ const patch = createOsm("patch", [{ id: 101, lon: 0.000005, lat: 0, tags: { name: "Patch" } }]);
+ await expect(
+ merge(
+ base,
+ patch,
+ {
+ directMerge: true,
+ conflation: {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ decisions: [{ candidateId: "node:stale->1", action: "reject" }],
+ },
+ },
+ silent,
+ ),
+ ).rejects.toThrow("Unknown conflation candidate: node:stale->1");
+ });
+
+ it("recomputes canonical candidates instead of trusting caller-mutated discovery data", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [2, 1],
+ tags: { highway: "footway", layer: "-1", tunnel: "yes" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const discovery = discoverConflationCandidates(base, patch, attachmentOptions);
+ const forged = {
+ ...discovery,
+ candidates: discovery.candidates.map((candidate) =>
+ candidate.sourceId === 101
+ ? {
+ ...candidate,
+ targetId: 2,
+ status: "automatic" as const,
+ reasons: [],
+ networkAttachment: { status: "automatic" as const, reasons: [] },
+ evidence: { ...candidate.evidence, patchWayIds: [20] },
+ }
+ : candidate,
+ ),
+ };
+
+ const cumulative = applyChangesetToOsm(
+ generateConflationChangeset(
+ base,
+ patch,
+ { directMerge: true, conflation: attachmentOptions },
+ [],
+ forged,
+ ),
+ );
+ expect(cumulative.ways.getById(20)?.refs).toEqual([101, 102]);
+
+ const direct = applyChangesetToOsm(generateChangeset(base, patch, { directMerge: true }));
+ const fuzzyOnly = applyChangesetToOsm(
+ generateConflationApplicationChangeset(direct, patch, forged, base),
+ );
+ expect(fuzzyOnly.ways.getById(20)?.refs).toEqual([101, 102]);
+ });
+});
+
+describe("safe fuzzy property transfer", () => {
+ it("overwrites only selected properties and retains the imported point geometry", async () => {
+ const base = createOsm("base", [
+ { id: 1, lon: 0, lat: 0, tags: { amenity: "cafe", name: "Old" } },
+ ]);
+ const patch = createOsm("patch", [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: { amenity: "school", name: "Imported", source: "survey" },
+ },
+ ]);
+
+ const result = await merge(
+ base,
+ patch,
+ {
+ directMerge: true,
+ conflation: { propertyKeys: ["name", "missing"], attachNetwork: false },
+ },
+ silent,
+ );
+ expect(result.nodes.getById(1)?.tags).toEqual({ amenity: "cafe", name: "Imported" });
+ expect(result.nodes.getById(101)?.tags).toEqual({
+ amenity: "school",
+ name: "Imported",
+ source: "survey",
+ });
+ });
+
+ it("blocks structural tags and requires review for routing tags", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const patch = createOsm("patch", [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: { highway: "crossing", layer: "1" },
+ },
+ ]);
+ const protectedMatch = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["layer"],
+ attachNetwork: false,
+ }).candidates[0];
+ expect(protectedMatch?.propertyTransfer).toEqual({
+ status: "blocked",
+ reasons: ["protected-tag"],
+ });
+
+ const routingMatch = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["highway"],
+ attachNetwork: false,
+ }).candidates[0];
+ expect(routingMatch?.propertyTransfer).toEqual({
+ status: "review",
+ reasons: ["routing-property"],
+ });
+ });
+
+ it("requires review for conditional and namespaced modal routing properties", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const patch = createOsm("patch", [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: {
+ "foot:conditional": "no @ (snow)",
+ "kerb:left": "lowered",
+ "motorcycle:conditional": "no @ (wet)",
+ "maxspeed:hgv:conditional": "30 @ (weight>7.5)",
+ },
+ },
+ ]);
+ const candidate = discoverConflationCandidates(base, patch, {
+ propertyKeys: [
+ "foot:conditional",
+ "kerb:left",
+ "motorcycle:conditional",
+ "maxspeed:hgv:conditional",
+ ],
+ attachNetwork: false,
+ }).candidates[0];
+
+ expect(candidate?.propertyTransfer).toEqual({
+ status: "review",
+ reasons: ["routing-property"],
+ });
+ expect(candidate?.evidence.tagDiff.every((diff) => diff.routing)).toBe(true);
+ });
+
+ it("applies an explicitly reviewed routing property but never a protected property", async () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const patch = createOsm("patch", [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: { highway: "crossing", layer: "1" },
+ },
+ ]);
+ const conflation: OsmConflationOptions = {
+ propertyKeys: ["highway", "layer"],
+ attachNetwork: false,
+ decisions: [{ candidateId: "node:101->1", action: "accept" }],
+ };
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.nodes.getById(1)?.tags).toEqual({ highway: "crossing" });
+ });
+
+ it("matches reversed one-to-one ways and removes only redundant imported geometry", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "footway", name: "Old" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.001, lat: 0.000004 },
+ { id: 102, lon: 0, lat: 0.000004 },
+ { id: 999, lon: 0.01, lat: 0.01 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway", name: "Imported" } }],
+ );
+ const options = { propertyKeys: ["name"], attachNetwork: false };
+ const candidate = discoverConflationCandidates(base, patch, options).candidates.find(
+ (item) => item.entityType === "way",
+ );
+ expect(candidate).toMatchObject({ sourceId: 20, targetId: 10, status: "automatic" });
+
+ const result = await merge(base, patch, { directMerge: true, conflation: options }, silent);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2]);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBe("Imported");
+ expect(result.ways.ids.has(20)).toBe(false);
+ expect(result.nodes.ids.has(101)).toBe(false);
+ expect(result.nodes.ids.has(102)).toBe(false);
+ expect(result.nodes.ids.has(999)).toBe(true);
+ });
+
+ it("allows selected patch-wins properties on exact node and way geometry", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0, tags: { ref: "base" } },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "footway", surface: "gravel" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0, tags: { ref: "patch" } },
+ { id: 201, lon: 0, lat: 0 },
+ { id: 202, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [201, 202], tags: { highway: "footway", surface: "paved" } }],
+ );
+ const conflation = { propertyKeys: ["ref", "surface"], attachNetwork: false };
+ const discovery = discoverConflationCandidates(base, patch, conflation);
+ expect(discovery.candidates).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "node:101->1", status: "automatic" }),
+ expect.objectContaining({ id: "way:20->10", status: "automatic" }),
+ ]),
+ );
+ const result = await merge(
+ base,
+ patch,
+ {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ conflation,
+ },
+ silent,
+ );
+ expect(result.nodes.getById(1)?.tags?.["ref"]).toBe("patch");
+ expect(result.ways.getById(10)?.tags?.["surface"]).toBe("paved");
+ expect(result.ways.ids.has(20)).toBe(false);
+ });
+
+ it("keeps same-ID patch updates authoritative over nearby fuzzy sources", async () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0, tags: { name: "Base" } }]);
+ const patch = createOsm("patch", [
+ { id: 1, lon: 0, lat: 0, tags: { name: "Same-ID authoritative" } },
+ { id: 101, lon: 0.000005, lat: 0, tags: { name: "Nearby fuzzy" } },
+ ]);
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const discovery = discoverConflationCandidates(base, patch, conflation);
+ expect(discovery.candidates.find((candidate) => candidate.sourceId === 101)).toMatchObject({
+ status: "unmatched",
+ targetId: null,
+ });
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.nodes.getById(1)?.tags?.["name"]).toBe("Same-ID authoritative");
+ });
+
+ it("does not suppress a geometrically reversed way with incompatible oneway semantics", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "residential", oneway: "yes", name: "Base" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.001, lat: 0.000004 },
+ { id: 102, lon: 0, lat: 0.000004 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102],
+ tags: { highway: "residential", oneway: "yes", name: "Imported" },
+ },
+ ],
+ );
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBe("Base");
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+
+ it("does not suppress an equivalent way with a conditional access conflict", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "footway", name: "Base", "wheelchair:conditional": "yes @ (dry)" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0.000004 },
+ { id: 102, lon: 0.001, lat: 0.000004 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102],
+ tags: { highway: "footway", name: "Imported", "wheelchair:conditional": "no @ (wet)" },
+ },
+ ],
+ );
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const candidate = discoverConflationCandidates(base, patch, conflation).candidates.find(
+ (item) => item.entityType === "way",
+ );
+
+ expect(candidate).toMatchObject({
+ targetId: 10,
+ status: "blocked",
+ reasons: ["routing-family-conflict"],
+ });
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBe("Base");
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+
+ it("does not suppress reversed geometry with directional routing tags", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "footway", "kerb:left": "lowered", name: "Base" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.001, lat: 0.000004 },
+ { id: 102, lon: 0, lat: 0.000004 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102],
+ tags: { highway: "footway", "kerb:left": "lowered", name: "Imported" },
+ },
+ ],
+ );
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const candidate = discoverConflationCandidates(base, patch, conflation).candidates.find(
+ (item) => item.entityType === "way",
+ );
+
+ expect(candidate).toMatchObject({
+ targetId: 10,
+ status: "blocked",
+ reasons: ["routing-family-conflict"],
+ });
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBe("Base");
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+
+ it("blocks a sub-meter way match whose true relative length differs by over five percent", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.000000898, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "footway", name: "Base" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0.000004 },
+ { id: 102, lon: 0.000001257, lat: 0.000004 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway", name: "Imported" } }],
+ );
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const candidate = discoverConflationCandidates(base, patch, conflation).candidates.find(
+ (item) => item.entityType === "way",
+ );
+
+ expect(candidate).toMatchObject({ targetId: 10, status: "blocked" });
+ expect(candidate?.reasons).toContain("length-mismatch");
+ expect(candidate?.evidence.lengthDifferenceRatio).toBeGreaterThan(0.25);
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBe("Base");
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+
+ it("reports a geometrically plausible grade-conflicting way instead of hiding it as unmatched", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "footway", tunnel: "yes", layer: "-1", name: "Tunnel" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0.000004 },
+ { id: 102, lon: 0.001, lat: 0.000004 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway", name: "Surface" } }],
+ );
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const candidate = discoverConflationCandidates(base, patch, conflation).candidates.find(
+ (item) => item.entityType === "way",
+ );
+
+ expect(candidate).toMatchObject({ targetId: 10, status: "blocked" });
+ expect(candidate?.reasons).toContain("grade-conflict");
+ const result = await merge(base, patch, { directMerge: true, conflation }, silent);
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+});
+
+describe("safe fuzzy topology gates", () => {
+ it("requires review before attaching drivable living-street geometry", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "living_street" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "living_street" } }],
+ );
+ const candidate = discoverConflationCandidates(base, patch, attachmentOptions).candidates.find(
+ (item) => item.sourceId === 101,
+ );
+ expect(candidate).toMatchObject({
+ status: "review",
+ networkAttachment: {
+ status: "review",
+ reasons: ["drivable-network"],
+ },
+ evidence: {
+ sourceRoutingFamilies: ["motor-road"],
+ targetRoutingFamilies: ["motor-road"],
+ },
+ });
+
+ const result = await merge(
+ base,
+ patch,
+ { directMerge: true, conflation: attachmentOptions },
+ silent,
+ );
+ expect(result.ways.getById(20)?.refs).toEqual([101, 102]);
+ expect(result.nodes.ids.has(101)).toBe(true);
+ });
+
+ it("blocks conflicting node grade and access context before network attachment", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0, tags: { layer: "-1", access: "private" } },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const candidate = discoverConflationCandidates(base, patch, attachmentOptions).candidates.find(
+ (item) => item.sourceId === 101,
+ );
+ expect(candidate?.networkAttachment?.status).toBe("blocked");
+ expect(candidate?.networkAttachment?.reasons).toEqual(
+ expect.arrayContaining(["grade-conflict", "routing-family-conflict"]),
+ );
+ });
+
+ it("never auto-attaches barrier or floor nodes even when their contexts agree", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0, tags: { barrier: "gate", level: "1" } },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0, tags: { barrier: "gate", level: "1" } },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const candidate = discoverConflationCandidates(base, patch, attachmentOptions).candidates.find(
+ (item) => item.sourceId === 101,
+ );
+ expect(candidate?.networkAttachment).toMatchObject({
+ status: "review",
+ reasons: ["node-context-conflict"],
+ });
+ });
+
+ it("blocks incompatible crossing and kerb node context but permits exact context", () => {
+ const base = createOsm(
+ "base",
+ [
+ {
+ id: 1,
+ lon: 0,
+ lat: 0,
+ tags: { highway: "crossing", crossing: "marked", kerb: "lowered" },
+ },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: { highway: "crossing", crossing: "unmarked", kerb: "raised" },
+ },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const conflict = discoverConflationCandidates(base, patch, attachmentOptions).candidates.find(
+ (item) => item.sourceId === 101,
+ );
+ expect(conflict?.networkAttachment).toMatchObject({
+ status: "blocked",
+ reasons: ["routing-family-conflict"],
+ });
+
+ const exactPatch = createOsm(
+ "exact-patch",
+ [
+ {
+ id: 101,
+ lon: 0.000005,
+ lat: 0,
+ tags: { highway: "crossing", crossing: "marked", kerb: "lowered" },
+ },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const exact = discoverConflationCandidates(base, exactPatch, attachmentOptions).candidates.find(
+ (item) => item.sourceId === 101,
+ );
+ expect(exact?.networkAttachment).toEqual({ status: "automatic", reasons: [] });
+ });
+
+ it("blocks grade conflicts and reviews perpendicular attachments", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0, lat: -0.001 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [2, 1],
+ tags: { highway: "footway", tunnel: "yes", layer: "-1" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ );
+ const gradeConflict = discoverConflationCandidates(
+ base,
+ patch,
+ attachmentOptions,
+ ).candidates.find((candidate) => candidate.sourceId === 101);
+ expect(gradeConflict?.status).toBe("blocked");
+ expect(gradeConflict?.reasons).toContain("grade-conflict");
+
+ const surfaceBase = createOsm(
+ "surface",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0, lat: -0.001 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const perpendicular = discoverConflationCandidates(
+ surfaceBase,
+ patch,
+ attachmentOptions,
+ ).candidates.find((candidate) => candidate.sourceId === 101);
+ expect(perpendicular?.status).toBe("review");
+ expect(perpendicular?.reasons).toContain("bearing-mismatch");
+ });
+
+ it("blocks patch-way collapse and relation-member attachment", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -0.001, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "footway" } }],
+ );
+ const collapsePatch = createOsm(
+ "collapse",
+ [{ id: 101, lon: 0.000005, lat: 0 }],
+ [{ id: 20, refs: [101, 1], tags: { highway: "footway" } }],
+ );
+ const collapse = discoverConflationCandidates(
+ base,
+ collapsePatch,
+ attachmentOptions,
+ ).candidates.find((candidate) => candidate.sourceId === 101);
+ expect(collapse?.status).toBe("blocked");
+ expect(collapse?.reasons).toContain("would-collapse-way");
+
+ const relationPatch = createOsm(
+ "relation",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ [
+ {
+ id: 30,
+ members: [{ type: "node", ref: 101, role: "stop" }],
+ tags: { type: "route" },
+ },
+ ],
+ );
+ const relation = discoverConflationCandidates(
+ base,
+ relationPatch,
+ attachmentOptions,
+ ).candidates.find((candidate) => candidate.sourceId === 101);
+ expect(relation?.status).toBe("review");
+ expect(relation?.reasons).toContain("relation-member");
+
+ const restrictionPatch = createOsm(
+ "restriction",
+ [
+ { id: 101, lon: 0.000005, lat: 0 },
+ { id: 102, lon: 0.001, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "footway" } }],
+ [
+ {
+ id: 31,
+ members: [{ type: "node", ref: 101, role: "via" }],
+ tags: { type: "restriction", restriction: "no_left_turn" },
+ },
+ ],
+ );
+ const restriction = discoverConflationCandidates(
+ base,
+ restrictionPatch,
+ attachmentOptions,
+ ).candidates.find((candidate) => candidate.sourceId === 101);
+ expect(restriction?.status).toBe("blocked");
+ expect(restriction?.reasons).toContain("relation-member");
+ });
+
+ it("reports one-to-many way chains as unsupported and leaves them in the direct merge", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.001, lat: 0 },
+ { id: 3, lon: 0.002, lat: 0 },
+ ],
+ [
+ { id: 10, refs: [1, 2], tags: { highway: "footway" } },
+ { id: 11, refs: [2, 3], tags: { highway: "footway" } },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0.000004 },
+ { id: 102, lon: 0.001, lat: 0.000004 },
+ { id: 103, lon: 0.002, lat: 0.000004 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [101, 102, 103],
+ tags: { highway: "footway", name: "Imported" },
+ },
+ ],
+ );
+ const options = { propertyKeys: ["name"], attachNetwork: false };
+ const unsupported = discoverConflationCandidates(base, patch, options).candidates.find(
+ (candidate) => candidate.entityType === "way",
+ );
+ expect(unsupported).toMatchObject({ status: "unmatched", targetId: null });
+ expect(unsupported?.reasons).toContain("unsupported-way-chain");
+
+ const result = await merge(base, patch, { directMerge: true, conflation: options }, silent);
+ expect(result.ways.getById(20)?.refs).toEqual([101, 102, 103]);
+ });
+
+ it("generates equivalent fuzzy-only and cumulative changesets from canonical discovery", () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0, tags: { name: "Old" } }]);
+ const patch = createOsm("patch", [
+ { id: 101, lon: 0.000005, lat: 0, tags: { name: "Imported" } },
+ ]);
+ const conflation = { propertyKeys: ["name"], attachNetwork: false };
+ const discovery = discoverConflationCandidates(base, patch, conflation);
+
+ const cumulative = applyChangesetToOsm(
+ generateConflationChangeset(base, patch, { directMerge: true, conflation }, [], discovery),
+ );
+ const direct = applyChangesetToOsm(generateChangeset(base, patch, { directMerge: true }));
+ const fuzzyOnly = applyChangesetToOsm(
+ generateConflationApplicationChangeset(direct, patch, discovery, base),
+ );
+ expect(fuzzyOnly.nodes.getById(1)?.tags).toEqual(cumulative.nodes.getById(1)?.tags);
+ expect(fuzzyOnly.nodes.getById(101)).toEqual(cumulative.nodes.getById(101));
+ });
+
+ it("enforces the protected-base assertion inside the fuzzy-only generator", () => {
+ const originalBase = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const malformedBaseline = createOsm("base", []);
+ const patch = createOsm("patch", []);
+ const discovery = discoverConflationCandidates(originalBase, patch, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+
+ expect(() =>
+ generateConflationApplicationChangeset(malformedBaseline, patch, discovery, originalBase),
+ ).toThrow("Conflation changed protected base topology");
+ });
+
+ it("applies and validates the cumulative result before returning its changeset", () => {
+ const base = createOsm("base", []);
+ const patch = createOsm(
+ "patch",
+ [{ id: 101, lon: 0, lat: 0 }],
+ [{ id: 20, refs: [101, 999], tags: { highway: "footway", name: "Imported" } }],
+ );
+
+ expect(() =>
+ generateConflationChangeset(base, patch, {
+ directMerge: true,
+ conflation: { propertyKeys: ["name"], attachNetwork: false },
+ }),
+ ).toThrow("way 20 references missing node 999");
+ });
+});
diff --git a/packages/change/test/intersections.test.ts b/packages/change/test/intersections.test.ts
index 02eae06e..14906ebe 100644
--- a/packages/change/test/intersections.test.ts
+++ b/packages/change/test/intersections.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import { applyChangesetToOsm } from "../src/apply-changeset.ts";
import { OsmChangeset } from "../src/changeset.ts";
+import { merge } from "../src/merge.ts";
+import { waysShouldConnect } from "../src/utils.ts";
function crossingWays() {
const osm = new Osm({ id: "intersections" });
@@ -25,6 +27,27 @@ function crossingWays() {
}
describe("intersection geometry integrity", () => {
+ it("normalizes negative grade tags and compares the full vertical context", () => {
+ expect(
+ waysShouldConnect(
+ { bridge: "no", highway: "primary", tunnel: "false" },
+ { highway: "secondary" },
+ ),
+ ).toBe(true);
+ expect(
+ waysShouldConnect(
+ { highway: "primary", layer: "-1", tunnel: "yes" },
+ { highway: "secondary", layer: "-1", tunnel: "yes" },
+ ),
+ ).toBe(true);
+ expect(
+ waysShouldConnect({ covered: "yes", highway: "primary" }, { highway: "secondary" }),
+ ).toBe(false);
+ expect(
+ waysShouldConnect({ highway: "primary", level: "1" }, { highway: "secondary", level: "2" }),
+ ).toBe(false);
+ });
+
it("resolves pending intersection nodes when a way is spliced more than once", () => {
const osm = crossingWays();
const changeset = new OsmChangeset(osm);
@@ -39,11 +62,262 @@ describe("intersection geometry integrity", () => {
});
const result = applyChangesetToOsm(changeset);
const horizontal = result.ways.getById(10);
- expect(horizontal?.refs).toHaveLength(4);
+ expect(horizontal?.refs).toEqual([1, 7, 8, 2]);
expect(horizontal?.refs.every((ref) => result.nodes.ids.has(ref))).toBe(true);
expect(result.ways.getById(13)?.refs).toEqual([1]);
});
+ it("inserts multiple intersections in way order for reversed ways", () => {
+ const osm = crossingWays();
+ const reversed = osm.ways.getById(10)!;
+ const changeset = new OsmChangeset(osm);
+ changeset.modify("way", reversed.id, (way) => ({ ...way, refs: [2, 1] }));
+
+ changeset.createIntersectionsForWays(osm.ways);
+
+ const result = applyChangesetToOsm(changeset);
+ expect(result.ways.getById(10)?.refs).toEqual([2, 8, 7, 1]);
+ });
+
+ it("rewrites via-node relation members when coincident way nodes are unified", () => {
+ const osm = new Osm({ id: "restriction-intersection" });
+ for (const node of [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 4, lon: 0, lat: -1 },
+ { id: 5, lon: 0, lat: 0 },
+ { id: 6, lon: 0, lat: 1 },
+ ]) {
+ osm.nodes.addNode(node);
+ }
+ osm.ways.addWay({ id: 10, refs: [1, 2, 3], tags: { highway: "primary" } });
+ osm.ways.addWay({ id: 20, refs: [4, 5, 6], tags: { highway: "primary" } });
+ osm.relations.addRelation({
+ id: 100,
+ tags: { type: "restriction", restriction: "no_left_turn" },
+ members: [
+ { type: "way", ref: 10, role: "from" },
+ { type: "node", ref: 5, role: "via" },
+ { type: "way", ref: 20, role: "to" },
+ ],
+ });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ const changeset = new OsmChangeset(osm);
+
+ changeset.createIntersectionsForWays(osm.ways);
+
+ const result = applyChangesetToOsm(changeset);
+ expect(result.ways.getById(20)?.refs).toEqual([4, 2, 6]);
+ expect(result.relations.getById(100)?.members[1]).toEqual({
+ type: "node",
+ ref: 2,
+ role: "via",
+ });
+ });
+
+ it("preserves a routing-critical base endpoint when a patch endpoint is reused", () => {
+ const osm = new Osm({ id: "protected-endpoint" });
+ for (const node of [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0, tags: { barrier: "gate", access: "private" } },
+ { id: 5, lon: 0, lat: 0 },
+ { id: 6, lon: 0, lat: 1 },
+ ]) {
+ osm.nodes.addNode(node);
+ }
+ osm.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "service" } });
+ osm.ways.addWay({ id: 20, refs: [5, 6], tags: { highway: "service" } });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+
+ const patch = new Osm({ id: "patch" });
+ patch.nodes.addNode({ id: 5, lon: 0, lat: 0 });
+ patch.nodes.addNode({ id: 6, lon: 0, lat: 1 });
+ patch.ways.addWay({ id: 20, refs: [5, 6], tags: { highway: "service" } });
+ patch.buildIndexes();
+ const changeset = new OsmChangeset(osm);
+
+ changeset.createIntersectionsForWays(patch.ways);
+
+ const result = applyChangesetToOsm(changeset);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2]);
+ expect(result.ways.getById(20)?.refs).toEqual([2, 6]);
+ expect(result.nodes.getById(2)?.tags).toEqual({
+ access: "private",
+ barrier: "gate",
+ crossing: "yes",
+ });
+ });
+
+ it("preserves a shared base node ID when the patch endpoint adds routing tags", () => {
+ const osm = new Osm({ id: "shared-base-endpoint" });
+ for (const node of [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 5, lon: 0, lat: 0, tags: { barrier: "gate" } },
+ { id: 6, lon: 0, lat: 1 },
+ ]) {
+ osm.nodes.addNode(node);
+ }
+ osm.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "service" } });
+ osm.ways.addWay({ id: 11, refs: [2, 3], tags: { highway: "service" } });
+ osm.ways.addWay({ id: 20, refs: [5, 6], tags: { highway: "service" } });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+
+ const patch = new Osm({ id: "patch" });
+ patch.nodes.addNode({ id: 5, lon: 0, lat: 0, tags: { barrier: "gate" } });
+ patch.nodes.addNode({ id: 6, lon: 0, lat: 1 });
+ patch.ways.addWay({ id: 20, refs: [5, 6], tags: { highway: "service" } });
+ patch.buildIndexes();
+ const changeset = new OsmChangeset(osm);
+
+ changeset.createIntersectionsForWays(patch.ways);
+
+ const result = applyChangesetToOsm(changeset);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2]);
+ expect(result.ways.getById(11)?.refs).toEqual([2, 3]);
+ expect(result.ways.getById(20)?.refs).toEqual([2, 6]);
+ expect(result.nodes.getById(2)?.tags).toEqual({ barrier: "gate", crossing: "yes" });
+ });
+
+ it("creates a dedicated node when endpoint reuse would collapse a short patch way", async () => {
+ const base = new Osm({ id: "short-crossing-base" });
+ for (const node of [
+ { id: 1, lon: -120.5618765, lat: 46.5963651 },
+ { id: 2, lon: -120.5618635, lat: 46.5963706 },
+ { id: 3, lon: -120.5618787, lat: 46.5963832 },
+ ]) {
+ base.nodes.addNode(node);
+ }
+ base.ways.addWay({ id: 10, refs: [1, 2, 3], tags: { highway: "footway" } });
+ base.buildIndexes();
+ base.buildSpatialIndexes();
+
+ const patch = new Osm({ id: "short-crossing-patch" });
+ patch.nodes.addNode({ id: 101, lon: -120.561871, lat: 46.5963653 });
+ patch.nodes.addNode({ id: 102, lon: -120.5618605, lat: 46.5963786 });
+ patch.ways.addWay({ id: 20, refs: [101, 102], tags: { highway: "footway" } });
+ patch.buildIndexes();
+
+ const progress: string[] = [];
+ const result = await merge(
+ base,
+ patch,
+ { createIntersections: true, directMerge: true },
+ (event) => progress.push(event.detail.msg),
+ );
+
+ const baseWay = result.ways.getById(10)!;
+ const patchWay = result.ways.getById(20)!;
+ const generatedRefs = patchWay.refs.filter((ref) => ref > 102);
+
+ expect(patchWay.refs).toContain(2);
+ expect(generatedRefs).toHaveLength(1);
+ expect(baseWay.refs).toContain(generatedRefs[0]!);
+ expect(new Set(patchWay.refs).size).toBe(patchWay.refs.length);
+ expect(progress).toContain("Intersection creation progress: 1 of 1 ways checked");
+ });
+
+ it("reports every patch way after exact reconciliation removes an equivalent way", async () => {
+ const base = new Osm({ id: "progress-base" });
+ base.nodes.addNode({ id: 1, lon: 0, lat: 0 });
+ base.nodes.addNode({ id: 2, lon: 1, lat: 0 });
+ base.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "service" } });
+ base.buildIndexes();
+ base.buildSpatialIndexes();
+
+ const patch = new Osm({ id: "progress-patch" });
+ patch.ways.addWay({ id: 20, refs: [1, 2], tags: { highway: "service" } });
+ patch.buildIndexes();
+
+ const progress: string[] = [];
+ const result = await merge(
+ base,
+ patch,
+ {
+ createIntersections: true,
+ deduplicateWays: true,
+ directMerge: true,
+ },
+ (event) => progress.push(event.detail.msg),
+ );
+
+ expect(result.ways.ids.has(20)).toBe(false);
+ expect(progress).toContain("Intersection creation progress: 1 of 1 ways checked");
+ });
+
+ it("declines endpoint reuse when node tags conflict", () => {
+ const osm = new Osm({ id: "conflicting-endpoint" });
+ for (const node of [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0, tags: { barrier: "gate" } },
+ { id: 5, lon: 0, lat: 0, tags: { barrier: "lift_gate" } },
+ { id: 6, lon: 0, lat: 1 },
+ ]) {
+ osm.nodes.addNode(node);
+ }
+ osm.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "service" } });
+ osm.ways.addWay({ id: 20, refs: [5, 6], tags: { highway: "service" } });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ const changeset = new OsmChangeset(osm);
+
+ changeset.createIntersectionsForWays(osm.ways);
+
+ expect(changeset.stats.intersectionPointsFound).toBe(0);
+ const result = applyChangesetToOsm(changeset);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2]);
+ expect(result.ways.getById(20)?.refs).toEqual([5, 6]);
+ });
+
+ it("reuses one pending node when three ways cross at the same point", () => {
+ const osm = new Osm({ id: "three-way-intersection" });
+ for (const node of [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ { id: 3, lon: 0, lat: -1 },
+ { id: 4, lon: 0, lat: 1 },
+ { id: 5, lon: -1, lat: -1 },
+ { id: 6, lon: 1, lat: 1 },
+ ]) {
+ osm.nodes.addNode(node);
+ }
+ osm.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "primary" } });
+ osm.ways.addWay({ id: 20, refs: [3, 4], tags: { highway: "secondary" } });
+ osm.ways.addWay({ id: 30, refs: [5, 6], tags: { highway: "residential" } });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ const changeset = new OsmChangeset(osm);
+
+ changeset.createIntersectionsForWays(osm.ways);
+
+ expect(changeset.stats.intersectionNodesCreated).toBe(1);
+ const result = applyChangesetToOsm(changeset);
+ const sharedRefs = [10, 20, 30].map(
+ (wayId) => result.ways.getById(wayId)!.refs.find((ref) => ref > 6)!,
+ );
+ expect(new Set(sharedRefs)).toEqual(new Set([7]));
+ for (const wayId of [10, 20, 30]) {
+ const way = result.ways.getById(wayId)!;
+ const coordinates = way.refs.map((ref) => {
+ const node = result.nodes.getById(ref)!;
+ return [node.lon, node.lat];
+ });
+ expect(
+ coordinates.some(
+ (coordinate, index) =>
+ index > 0 &&
+ coordinate[0] === coordinates[index - 1]![0] &&
+ coordinate[1] === coordinates[index - 1]![1],
+ ),
+ ).toBe(false);
+ }
+ });
+
it("skips incomplete current and candidate ways without inventing geometry", () => {
const osm = crossingWays();
const changeset = new OsmChangeset(osm);
diff --git a/packages/change/test/merge.test.ts b/packages/change/test/merge.test.ts
index d9d7507f..38953ddb 100644
--- a/packages/change/test/merge.test.ts
+++ b/packages/change/test/merge.test.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { applyChangesetToOsm } from "../src/apply-changeset";
import { OsmChangeset } from "../src/changeset";
+import { generateChangeset } from "../src/generate-changeset";
const sizes = (osm: Osm) => ({
nodes: osm.nodes.size,
@@ -15,6 +16,7 @@ describe("merge osm", () => {
it("should generate and apply osm changes", () => {
const base = createMockBaseOsm();
const patch = createMockPatchOsm();
+ base.buildSpatialIndexes();
expect(sizes(base)).toEqual({
nodes: 2,
@@ -59,25 +61,28 @@ describe("merge osm", () => {
},
});
- changeset = new OsmChangeset(directResult);
- changeset.deduplicateWays(patch.ways);
- changeset.deduplicateNodes(patch.nodes);
+ changeset = generateChangeset(base, patch, {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ });
const deduplicatedResult = applyChangesetToOsm(changeset, "deduplicated");
- // Node 0 is deleted because node 2 has more tags (version/tags logic)
- expect(deduplicatedResult.nodes.ids.has(0)).toBe(false);
+ // The immutable base node survives and receives non-conflicting patch tags.
+ expect(deduplicatedResult.nodes.ids.has(0)).toBe(true);
+ expect(deduplicatedResult.nodes.ids.has(2)).toBe(false);
expect(deduplicatedResult.ways.getById(1)).toEqual({
id: 1,
- refs: [2, 1], // Node 0 replaced with node 2
+ refs: [0, 1],
tags: {
highway: "primary",
version: "2",
},
});
+ expect(deduplicatedResult.ways.getById(2)?.refs).toEqual([0, 3]);
- // Node 2 is kept because it has tags
- expect(deduplicatedResult.nodes.getById(2)).toEqual({
- id: 2,
+ expect(deduplicatedResult.nodes.getById(0)).toEqual({
+ id: 0,
lat: 46.60207,
lon: -120.505898,
tags: {
diff --git a/packages/change/test/relation-dedup.test.ts b/packages/change/test/relation-dedup.test.ts
index f914316d..7859fc3d 100644
--- a/packages/change/test/relation-dedup.test.ts
+++ b/packages/change/test/relation-dedup.test.ts
@@ -21,7 +21,7 @@ function createOsm(nodes: OsmNode[], ways: OsmWay[], relations: OsmRelation[]) {
}
describe("relation-safe deduplication", () => {
- it("returns flattened node maps and rewrites node members before deletion", () => {
+ it("does not collapse merely nearby nodes or rewrite their relation members", () => {
const nodes: OsmNode[] = [
{ id: 1, lat: 0, lon: 0 },
{ id: 2, lat: 0.000007, lon: 0 },
@@ -41,18 +41,14 @@ describe("relation-safe deduplication", () => {
const replacements = changeset.deduplicateNodes(osm.nodes);
- expect(replacements).toEqual(
- new Map([
- [1, 3],
- [2, 3],
- ]),
- );
+ expect(replacements).toEqual(new Map());
const result = applyChangesetToOsm(changeset);
- expect(result.nodes.ids.has(1)).toBe(false);
- expect(result.nodes.ids.has(2)).toBe(false);
- expect(result.ways.getById(10)?.refs).toEqual([3]);
+ expect(result.nodes.ids.has(1)).toBe(true);
+ expect(result.nodes.ids.has(2)).toBe(true);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 3]);
expect(result.relations.getById(20)?.members).toEqual([
- { type: "node", ref: 3, role: "stop" },
+ { type: "node", ref: 1, role: "stop" },
+ { type: "node", ref: 2, role: "stop" },
{ type: "node", ref: 3, role: "platform" },
]);
});
diff --git a/packages/change/test/routing-integrity.test.ts b/packages/change/test/routing-integrity.test.ts
new file mode 100644
index 00000000..4a724a07
--- /dev/null
+++ b/packages/change/test/routing-integrity.test.ts
@@ -0,0 +1,633 @@
+import { Osm } from "@osmix/core";
+import type { OsmNode, OsmRelation, OsmWay } from "@osmix/types";
+import { describe, expect, it } from "vitest";
+
+import { applyChangesetToOsm } from "../src/apply-changeset.ts";
+import { generateChangeset } from "../src/generate-changeset.ts";
+import { merge } from "../src/merge.ts";
+
+function createOsm(
+ id: string,
+ nodes: OsmNode[],
+ ways: OsmWay[] = [],
+ relations: OsmRelation[] = [],
+) {
+ const osm = new Osm({ id });
+ for (const node of nodes) osm.nodes.addNode(node);
+ for (const way of ways) osm.ways.addWay(way);
+ for (const relation of relations) osm.relations.addRelation(relation);
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ return osm;
+}
+
+const silent = () => {};
+
+describe("routing-safe merge reconciliation", () => {
+ it("keeps an empty-patch merge as an identity operation", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.000005, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm("empty", []);
+
+ const result = await merge(
+ base,
+ patch,
+ { directMerge: true, deduplicateNodes: true, deduplicateWays: true },
+ silent,
+ );
+
+ expect([...result.nodes].map((node) => node.id)).toEqual([1, 2]);
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2]);
+ });
+
+ it("does not reconcile nearby or grade-separated nodes", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 0.01, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "secondary", layer: "-1", tunnel: "yes" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0 },
+ { id: 102, lon: 0.000005, lat: 0 },
+ { id: 103, lon: 0.01, lat: 0.01 },
+ ],
+ [
+ { id: 20, refs: [101, 103], tags: { highway: "secondary" } },
+ { id: 21, refs: [102, 103], tags: { highway: "residential" } },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.nodes.ids.has(101)).toBe(true);
+ expect(result.nodes.ids.has(102)).toBe(true);
+ expect(result.ways.getById(20)?.refs).toEqual([101, 103]);
+ expect(result.ways.getById(21)?.refs).toEqual([102, 103]);
+ });
+
+ it("rejects conflicting node tags and preserves non-conflicting descriptive tags", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0, tags: { amenity: "cafe" } },
+ { id: 2, lon: -1, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [2, 1], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0, tags: { amenity: "school" } },
+ { id: 102, lon: 1, lat: 0, tags: { name: "Patch endpoint" } },
+ { id: 103, lon: 0, lat: 1 },
+ ],
+ [
+ { id: 20, refs: [101, 103], tags: { highway: "residential" } },
+ { id: 21, refs: [102, 103], tags: { highway: "residential" } },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.nodes.ids.has(101)).toBe(true);
+ expect(result.ways.getById(20)?.refs).toEqual([101, 103]);
+ expect(result.nodes.ids.has(102)).toBe(false);
+ expect(result.nodes.getById(3)?.tags).toEqual({ name: "Patch endpoint" });
+ expect(result.ways.getById(21)?.refs).toEqual([3, 103]);
+ });
+
+ it("rejects a candidate when any incident way has incompatible context", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -1, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ ],
+ [
+ { id: 10, refs: [2, 1], tags: { highway: "primary" } },
+ {
+ id: 11,
+ refs: [1, 3],
+ tags: { highway: "primary", layer: "-1", tunnel: "yes" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0 },
+ { id: 102, lon: 0, lat: 1 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "secondary" } }],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.nodes.ids.has(101)).toBe(true);
+ expect(result.ways.getById(20)?.refs).toEqual([101, 102]);
+ });
+
+ it("keeps same-ID patch nodes authoritative instead of deleting a base identity", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ { id: 3, lon: 0, lat: 1 },
+ ],
+ [{ id: 10, refs: [1, 3], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm("patch", [{ id: 1, lon: 1, lat: 0 }]);
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.nodes.ids.has(1)).toBe(true);
+ expect(result.nodes.ids.has(2)).toBe(true);
+ expect(result.nodes.getById(1)).toMatchObject({ lon: 1, lat: 0 });
+ expect(result.ways.getById(10)?.refs).toEqual([1, 3]);
+ });
+
+ it("does not collapse a routable patch way to one distinct base node", async () => {
+ const base = createOsm("base", [{ id: 1, lon: 0, lat: 0 }]);
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0 },
+ { id: 102, lon: 0, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "service" } }],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.nodes.ids.has(101)).toBe(true);
+ expect(result.nodes.ids.has(102)).toBe(true);
+ expect(result.ways.getById(20)?.refs).toEqual([101, 102]);
+ });
+
+ it("does not reconcile ways with conflicting routing tags", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [{ id: 20, refs: [1, 2], tags: { highway: "residential", oneway: "yes" } }],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateWays: true }, silent);
+
+ expect(result.ways.ids.has(10)).toBe(true);
+ expect(result.ways.ids.has(20)).toBe(true);
+ });
+
+ it("does not reconcile ways with conditional access semantics", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [
+ {
+ id: 20,
+ refs: [1, 2],
+ tags: {
+ "access:conditional": "no @ (Mo-Fr 07:00-09:00)",
+ highway: "residential",
+ name: "School Street",
+ },
+ },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateWays: true }, silent);
+
+ expect(result.ways.ids.has(10)).toBe(true);
+ expect(result.ways.ids.has(20)).toBe(true);
+ expect(result.ways.getById(10)?.tags).toEqual({ highway: "residential" });
+ });
+
+ it("copies only non-conflicting descriptive tags when ways reconcile", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [{ id: 20, refs: [1, 2], tags: { highway: "residential", name: "Connector" } }],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateWays: true }, silent);
+
+ expect(result.ways.ids.has(20)).toBe(false);
+ expect(result.ways.getById(10)?.tags).toEqual({
+ highway: "residential",
+ name: "Connector",
+ });
+ });
+
+ it("checks complete way semantics when exact-index hashes collide", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2],
+ tags: { highway: "residential", surface: "1ugdp92ail" },
+ },
+ {
+ id: 11,
+ refs: [1, 2],
+ tags: { highway: "residential", surface: "c9n7431ir0" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [
+ {
+ id: 20,
+ refs: [1, 2],
+ tags: {
+ highway: "residential",
+ name: "Matching target",
+ surface: "c9n7431ir0",
+ },
+ },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateWays: true }, silent);
+
+ expect(result.ways.ids.has(20)).toBe(false);
+ expect(result.ways.getById(10)?.tags?.["name"]).toBeUndefined();
+ expect(result.ways.getById(11)?.tags?.["name"]).toBe("Matching target");
+ });
+
+ it("rejects patch dangling refs even when they already exist in the patch", async () => {
+ const base = createOsm("base", []);
+ const patch = createOsm(
+ "patch",
+ [{ id: 101, lon: 0, lat: 0 }],
+ [{ id: 20, refs: [101, 999], tags: { highway: "service" } }],
+ );
+
+ await expect(merge(base, patch, { directMerge: true }, silent)).rejects.toThrow(
+ "way 20 references missing node 999",
+ );
+ });
+
+ it("rejects a new patch restriction that is detached in the merged network", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ { id: 3, lon: 2, lat: 0 },
+ { id: 4, lon: 3, lat: 0 },
+ ],
+ [
+ { id: 10, refs: [1, 2], tags: { highway: "primary" } },
+ { id: 20, refs: [3, 4], tags: { highway: "primary" } },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [],
+ [
+ {
+ id: 100,
+ tags: { type: "restriction", restriction: "no_left_turn" },
+ members: [
+ { type: "way", ref: 10, role: "from" },
+ { type: "node", ref: 2, role: "via" },
+ { type: "way", ref: 20, role: "to" },
+ ],
+ },
+ ],
+ );
+
+ await expect(merge(base, patch, { directMerge: true }, silent)).rejects.toThrow(
+ "restriction 100 via node 2 is detached",
+ );
+ });
+
+ it("rewrites pending restriction via-node members with reconciled patch nodes", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -1, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ ],
+ [{ id: 20, refs: [1, 3], tags: { highway: "primary" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 0, lat: 0 },
+ { id: 102, lon: -1, lat: 0 },
+ ],
+ [{ id: 30, refs: [102, 101], tags: { highway: "primary" } }],
+ [
+ {
+ id: 100,
+ tags: { type: "restriction", restriction: "no_left_turn" },
+ members: [
+ { type: "way", ref: 30, role: "from" },
+ { type: "node", ref: 101, role: "via" },
+ { type: "way", ref: 20, role: "to" },
+ ],
+ },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true, deduplicateNodes: true }, silent);
+
+ expect(result.ways.getById(30)?.refs).toEqual([2, 1]);
+ expect(result.relations.getById(100)?.members[1]).toEqual({
+ type: "node",
+ ref: 1,
+ role: "via",
+ });
+ });
+
+ it("rejects same-ID changes that detach a valid restriction via node", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ { id: 3, lon: 2, lat: 0 },
+ { id: 4, lon: 1, lat: 1 },
+ ],
+ [
+ { id: 10, refs: [1, 2], tags: { highway: "primary" } },
+ { id: 20, refs: [2, 3], tags: { highway: "primary" } },
+ ],
+ [
+ {
+ id: 100,
+ tags: { type: "restriction", restriction: "no_left_turn" },
+ members: [
+ { type: "way", ref: 10, role: "from" },
+ { type: "node", ref: 2, role: "via" },
+ { type: "way", ref: 20, role: "to" },
+ ],
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 3, lon: 2, lat: 0 },
+ { id: 4, lon: 1, lat: 1 },
+ ],
+ [{ id: 20, refs: [4, 3], tags: { highway: "primary" } }],
+ );
+
+ await expect(merge(base, patch, { directMerge: true }, silent)).rejects.toThrow(
+ "restriction 100 via node 2 is detached",
+ );
+ });
+
+ it("rejects newly connected highways with incompatible grade signatures", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: -1, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 4, lon: 0, lat: 1 },
+ ],
+ [
+ { id: 10, refs: [2, 1, 4], tags: { highway: "primary" } },
+ { id: 20, refs: [1, 3], tags: { highway: "primary" } },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [],
+ [
+ {
+ id: 20,
+ refs: [1, 3],
+ tags: { highway: "primary", layer: "-1", tunnel: "yes" },
+ },
+ ],
+ );
+
+ await expect(merge(base, patch, { directMerge: true }, silent)).rejects.toThrow(
+ "node 1 newly connects grade-separated highways 10 and 20",
+ );
+ });
+
+ it("allows a surface road endpoint to transition into a bridge endpoint", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "primary" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 2, lon: 1, lat: 0 },
+ { id: 3, lon: 2, lat: 0 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [2, 3],
+ tags: { highway: "primary", bridge: "yes", layer: "1" },
+ },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true }, silent);
+
+ expect(result.ways.getById(20)?.refs).toEqual([2, 3]);
+ });
+
+ it("allows an interior way at a bridge portal with a same-grade endpoint continuation", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 4, lon: 0, lat: 1 },
+ ],
+ [
+ { id: 10, refs: [1, 2, 3], tags: { highway: "footway" } },
+ { id: 30, refs: [2, 4], tags: { highway: "primary" } },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 2, lon: 0, lat: 0 },
+ { id: 5, lon: 0, lat: -1 },
+ ],
+ [
+ {
+ id: 20,
+ refs: [2, 5],
+ tags: { highway: "primary", bridge: "yes", layer: "1" },
+ },
+ ],
+ );
+
+ const result = await merge(base, patch, { directMerge: true }, silent);
+
+ expect(result.ways.getById(10)?.refs).toEqual([1, 2, 3]);
+ expect(result.ways.getById(20)?.refs).toEqual([2, 5]);
+ expect(result.ways.getById(30)?.refs).toEqual([2, 4]);
+ });
+
+ it("rejects a surface endpoint spliced into an interior tunnel despite a tunnel continuation", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 4, lon: 0, lat: 1 },
+ ],
+ [
+ {
+ id: 10,
+ refs: [1, 2, 3],
+ tags: { highway: "primary", layer: "-1", tunnel: "yes" },
+ },
+ {
+ id: 30,
+ refs: [2, 4],
+ tags: { highway: "primary", layer: "-1", tunnel: "yes" },
+ },
+ ],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 2, lon: 0, lat: 0 },
+ { id: 5, lon: 0, lat: -1 },
+ ],
+ [{ id: 20, refs: [2, 5], tags: { highway: "primary" } }],
+ );
+
+ await expect(merge(base, patch, { directMerge: true }, silent)).rejects.toThrow(
+ "node 2 newly connects grade-separated highways 10 and 20",
+ );
+ });
+
+ it("tolerates an inherited interior grade issue during an unrelated change", () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: -1, lat: 0 },
+ { id: 2, lon: 0, lat: 0 },
+ { id: 3, lon: 1, lat: 0 },
+ { id: 4, lon: 0, lat: 1 },
+ ],
+ [
+ { id: 10, refs: [1, 2, 3], tags: { highway: "primary" } },
+ {
+ id: 20,
+ refs: [2, 4],
+ tags: { highway: "primary", bridge: "yes", layer: "1" },
+ },
+ ],
+ );
+ const changeset = generateChangeset(
+ base,
+ createOsm("patch", [{ id: 1, lon: -1, lat: 0, tags: { name: "Unrelated" } }]),
+ { directMerge: true },
+ silent,
+ );
+
+ expect(() => applyChangesetToOsm(changeset)).not.toThrow();
+ });
+
+ it("rejects direct merge plus intersections in one generated changeset", () => {
+ const base = createOsm("base", []);
+ const patch = createOsm("patch", []);
+
+ expect(() =>
+ generateChangeset(base, patch, { directMerge: true, createIntersections: true }, silent),
+ ).toThrow("generateChangeset cannot combine directMerge with createIntersections");
+ });
+
+ it("keeps high-level and generated changeset reconciliation in parity", async () => {
+ const base = createOsm(
+ "base",
+ [
+ { id: 1, lon: 0, lat: 0 },
+ { id: 2, lon: 1, lat: 0 },
+ ],
+ [{ id: 10, refs: [1, 2], tags: { highway: "residential" } }],
+ );
+ const patch = createOsm(
+ "patch",
+ [
+ { id: 101, lon: 1, lat: 0 },
+ { id: 102, lon: 2, lat: 0 },
+ ],
+ [{ id: 20, refs: [101, 102], tags: { highway: "residential" } }],
+ );
+ const options = { directMerge: true, deduplicateNodes: true, deduplicateWays: true };
+
+ const highLevel = await merge(base, patch, options, silent);
+ const generated = applyChangesetToOsm(generateChangeset(base, patch, options, silent));
+
+ expect([...highLevel.nodes].map((node) => node.id)).toEqual(
+ [...generated.nodes].map((node) => node.id),
+ );
+ expect([...highLevel.ways].map((way) => way.refs)).toEqual(
+ [...generated.ways].map((way) => way.refs),
+ );
+ });
+});
diff --git a/packages/change/test/ways-intersect.test.ts b/packages/change/test/ways-intersect.test.ts
index 8cd7b7d6..ae84081d 100644
--- a/packages/change/test/ways-intersect.test.ts
+++ b/packages/change/test/ways-intersect.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
+import sweeplineIntersections, {
+ sweeplineLineIntersections,
+} from "../src/sweepline-intersections.ts";
import { waysIntersect } from "../src/utils.ts";
type Point = [number, number];
@@ -116,4 +119,27 @@ describe("waysIntersect", () => {
it.each(cases)("matches pinned behavior for %s", (_name, wayA, wayB, expected) => {
expect(waysIntersect(wayA, wayB)).toEqual(expected);
});
+
+ it.each(cases)("keeps the direct line entry point equivalent for %s", (_name, wayA, wayB) => {
+ const wrapped = sweeplineIntersections(
+ {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "LineString", coordinates: wayA },
+ properties: {},
+ },
+ {
+ type: "Feature",
+ geometry: { type: "LineString", coordinates: wayB },
+ properties: {},
+ },
+ ],
+ },
+ true,
+ );
+
+ expect(sweeplineLineIntersections(wayA, wayB)).toEqual(wrapped);
+ });
});
diff --git a/packages/core/src/entities.ts b/packages/core/src/entities.ts
index 3fe140ad..71dc55ec 100644
--- a/packages/core/src/entities.ts
+++ b/packages/core/src/entities.ts
@@ -241,6 +241,13 @@ export abstract class Entities {
}
}
+ /** @internal Iterate entities in canonical OSM file order. */
+ *osmSorted(): Generator {
+ for (const [id, index] of this.ids.osmSortedEntries()) {
+ yield this.getFullEntity(index, id, this.tags.getTags(index));
+ }
+ }
+
/**
* Search for entities with a specific tag key and optional value.
*/
diff --git a/packages/core/src/ids.ts b/packages/core/src/ids.ts
index 44561e1f..14fa2ba7 100644
--- a/packages/core/src/ids.ts
+++ b/packages/core/src/ids.ts
@@ -242,18 +242,50 @@ export class Ids {
/** @internal Iterate sorted IDs with their original storage positions. */
*sortedEntries(): Generator {
for (let i = 0; i < this.idsSorted.length; i++) {
- const id = this.idsSorted[i];
+ yield this.sortedEntry(i);
+ }
+ }
+
+ /**
+ * Iterate in canonical OSM file order: negative IDs first by increasing
+ * absolute value, followed by non-negative IDs in ascending order.
+ *
+ * @internal
+ */
+ *osmSortedEntries(): Generator {
+ let firstNonNegative = 0;
+ while (firstNonNegative < this.idsSorted.length && this.idsSorted[firstNonNegative]! < 0) {
+ firstNonNegative++;
+ }
+
+ // Numeric sorting places negative IDs in the opposite of canonical OSM
+ // order. Reverse ID groups while preserving duplicate insertion order.
+ let groupEnd = firstNonNegative;
+ while (groupEnd > 0) {
+ const id = this.idsSorted[groupEnd - 1];
assertValue(id, "Sorted ID is undefined");
- if (this.idsAreSorted) {
- yield [id, i];
- } else {
- const index = this.sortedIdPositionToIndex[i];
- assertValue(index, "Sorted position is undefined");
- yield [id, index];
+ let groupStart = groupEnd - 1;
+ while (groupStart > 0 && this.idsSorted[groupStart - 1] === id) groupStart--;
+ for (let position = groupStart; position < groupEnd; position++) {
+ yield this.sortedEntry(position);
}
+ groupEnd = groupStart;
+ }
+
+ for (let position = firstNonNegative; position < this.idsSorted.length; position++) {
+ yield this.sortedEntry(position);
}
}
+ private sortedEntry(position: number): readonly [id: number, index: number] {
+ const id = this.idsSorted[position];
+ assertValue(id, "Sorted ID is undefined");
+ if (this.idsAreSorted) return [id, position];
+ const index = this.sortedIdPositionToIndex[position];
+ assertValue(index, "Sorted position is undefined");
+ return [id, index];
+ }
+
/**
* Get transferable buffers for passing to another thread.
* @returns Serializable representation of this index.
diff --git a/packages/core/test/ids.test.ts b/packages/core/test/ids.test.ts
index aa178ddd..60a1fae4 100644
--- a/packages/core/test/ids.test.ts
+++ b/packages/core/test/ids.test.ts
@@ -66,6 +66,18 @@ describe("Ids sorted entries", () => {
]);
});
+ it("orders negative IDs canonically for OSM serialization", () => {
+ expect(Array.from(buildIds([3, -3, -1, 2, -2, 1, -2]).osmSortedEntries())).toEqual([
+ [-1, 2],
+ [-2, 4],
+ [-2, 6],
+ [-3, 1],
+ [1, 5],
+ [2, 3],
+ [3, 0],
+ ]);
+ });
+
it("omits redundant sorted buffers for ascending IDs", () => {
const transferables = buildIds([1, 2, 3]).transferables();
diff --git a/packages/geoparquet/test/monaco-parquet.test.ts b/packages/geoparquet/test/monaco-parquet.test.ts
index df268efe..b74c06e8 100644
--- a/packages/geoparquet/test/monaco-parquet.test.ts
+++ b/packages/geoparquet/test/monaco-parquet.test.ts
@@ -1,7 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import { getFixturePath } from "@osmix/test-utils/fixtures";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
import { fromGeoParquet, GeoParquetOsmBuilder } from "../src";
@@ -22,11 +22,25 @@ import { fromGeoParquet, GeoParquetOsmBuilder } from "../src";
describe("@osmix/geoparquet: Monaco highways fixture", () => {
const fixturePath = () => getFixturePath("monaco.parquet");
- const readFixture = async (): Promise => {
- const buffer = await readFile(fixturePath());
- return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
+ let fixturePromise: Promise | undefined;
+ let osmPromise: ReturnType | undefined;
+ const readFixture = (): Promise => {
+ fixturePromise ??= readFile(fixturePath()).then((buffer) =>
+ buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
+ );
+ return fixturePromise;
};
- const getOsm = async () => fromGeoParquet(await readFixture());
+ const getOsm = () => {
+ osmPromise ??= readFixture().then((fixture) => fromGeoParquet(fixture));
+ return osmPromise;
+ };
+
+ // Conversion is the expensive integration boundary and the resulting OSM is
+ // read-only in this suite. Build it once instead of repeating the same 5,308-
+ // feature conversion in every behavioral assertion.
+ beforeAll(async () => {
+ await getOsm();
+ });
it("should load the monaco.parquet fixture", async () => {
const { size } = await stat(fixturePath());
diff --git a/packages/load/src/entity-stream.ts b/packages/load/src/entity-stream.ts
index 2e653d64..4eab22af 100644
--- a/packages/load/src/entity-stream.ts
+++ b/packages/load/src/entity-stream.ts
@@ -9,20 +9,20 @@ import type { OsmPbfHeaderBlock } from "@osmix/pbf";
import type { OsmEntity } from "@osmix/types";
function* getAllEntitiesSorted(osm: Osm): Generator {
- for (const node of osm.nodes.sorted()) {
+ for (const node of osm.nodes.osmSorted()) {
yield node;
}
- for (const way of osm.ways.sorted()) {
+ for (const way of osm.ways.osmSorted()) {
yield way;
}
- for (const relation of osm.relations.sorted()) {
+ for (const relation of osm.relations.osmSorted()) {
yield relation;
}
}
/**
* Convert the `Osm` index to a `ReadableStream` of header and entity objects.
- * Header is emitted first, followed by all entities in sorted order.
+ * Header is emitted first, followed by all entities in canonical OSM order.
* Stream can be piped through transform streams for further processing.
*/
export function createReadableEntityStreamFromOsm(
diff --git a/packages/osmix/README.md b/packages/osmix/README.md
index 0e169e3d..e85b2dbd 100644
--- a/packages/osmix/README.md
+++ b/packages/osmix/README.md
@@ -44,6 +44,98 @@ const rasterTile = await merged.getRasterTile([10561, 22891, 16]);
console.log(rasterTile.byteLength);
```
+High-level merges leave the original inputs intact and reconcile only compatible patch entities with unique
+base matches. Regenerate PBFs created by older releases from their original inputs if automatic within-file
+deduplication may already have rewritten routing topology.
+
+### Profile merge performance
+
+The test-only merge profiler runs the reviewed merge stages in their production order and reports per-stage
+wall time, CPU time, RSS, heap use, operation counts, and output fingerprints as JSON. The PBF fingerprint
+normalizes the export header's current timestamp; all entity bytes still use the production serializer. Monaco
+is checked into the repository and is the safe default:
+
+```sh
+pnpm --filter osmix profile:merge -- --scenario monaco --runs 5 --output /tmp/monaco-merge.json
+```
+
+Two larger profiles use ignored local fixtures. They never download data and fail with the required path when
+a fixture is absent:
+
+```sh
+# Recommended Yakima property keys, 1-meter matching, network attachment, and all merge stages.
+pnpm --filter osmix profile:merge -- --scenario yakima --runs 3 --output /tmp/yakima-merge.json
+
+# Direct merge, exact reconciliation, and intersections for the reported full-merge regression.
+pnpm --filter osmix profile:merge -- --scenario eastern-washington --runs 1 \
+ --output /tmp/eastern-washington-merge.json
+```
+
+The Yakima scenario uses `OsmixWorker.generateConflationChangeset`. Its generation stage includes CAR/WALK
+routing diagnostics and the automatic network-attachment CAR safety projection.
+
+Yakima requires `fixtures/yakima-full.osm.pbf` and `fixtures/yakima.osw.pbf`. Eastern Washington requires
+`fixtures/osmix-e_wa_osm.pbf` and `fixtures/east_washington_sidewalk_proviso_1.pbf`. The existing Eastern
+Washington correctness test remains opt-in with `OSMIX_EASTERN_WASHINGTON_INTEGRATION=1`.
+
+`OSMIX_MERGE_PROFILE_SCENARIO`, `OSMIX_MERGE_PROFILE_RUNS`, and `OSMIX_MERGE_PROFILE_OUTPUT` are equivalent
+to the command-line flags. Compare reports produced with the same commit, Node version, hardware, and idle
+system. `processPeakRssBytes` is the process-lifetime high-water mark, so later repetitions can retain an
+earlier run's peak. CI verifies operation counts and semantic fingerprints, but intentionally has no timing
+threshold or compressed-PBF byte golden.
+
+Proximity matching for independently created imports is available as a separate opt-in review session. The
+recommended defaults use a 1-meter radius and automatically apply only high-confidence candidates:
+
+```ts check-docs worker-pbf-inputs
+import { createRemote } from "osmix";
+
+using remote = await createRemote();
+const base = await remote.fromPbf(monacoPbf);
+const patch = await remote.fromPbf(patchPbf, { id: "imported-data" });
+
+const summary = await remote.discoverConflation(base.id, patch.id, {
+ propertyKeys: ["name", "operator", "surface"],
+ attachNetwork: true,
+});
+const page = await remote.getConflationPage(base.id, 0, 100);
+
+// Page previews cover every candidate matching the worker's current filter, not
+// just the rows returned on this page.
+console.log(page.bulkActions["transfer-properties"]);
+await remote.applyConflationBulkDecision(base.id, {
+ action: "transfer-properties",
+ filter: { status: "review" },
+});
+
+for (const candidate of page.candidates) {
+ if (candidate.status !== "review") continue;
+ await remote.setConflationDecision(base.id, {
+ candidateId: candidate.id,
+ action: "reject",
+ });
+}
+
+const generated = await remote.generateConflationChangeset(base.id, {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+});
+console.log(summary, generated.routing.car, generated.routing.walk);
+await remote.applyChangesAndReplace(base.id);
+```
+
+Property transfer changes only explicitly selected tags. Network attachment rewrites only patch-created way
+references. The worker preserves discovery settings, filters, decisions, and generated changes across worker
+restarts, and reports CAR/WALK node, edge, and component deltas before the changeset is applied. Automatic
+pedestrian attachments are rejected if they alter routable CAR topology.
+
+Filter-wide decisions are computed and committed atomically in the worker. Property and network actions
+accept eligible automatic and review candidates while skipping blocked, unmatched, ambiguous, or structurally
+invalid matches. Reject includes every filtered candidate that is not already rejected. Each result returns the
+complete decision snapshot for restart recovery, and accepted candidates can be queried with
+`{ status: "accepted" }`.
+
#### Which mode am I in?
`createRemote()` picks the best mode the current runtime supports and reports
@@ -343,6 +435,17 @@ spec-compliant without staging everything in memory.
- `remote.getRasterTile(osmId, tile, tileSize?)` - Generate raster in worker.
- `remote.merge(baseId, patchId, options?)` - Merge datasets in worker (legacy).
- `dataset.merge(patch, options?)` - Merge datasets via dataset handles.
+- `remote.discoverConflation(baseId, patchId, options)` - Start a non-mutating imported-data match session.
+- `remote.getConflationSummary(baseId)` - Retrieve decision-aware candidate counts.
+- `remote.setConflationFilter(baseId, filter)` / `remote.getConflationPage(...)` - Page through candidate
+ evidence and review state.
+- `remote.setConflationDecision(baseId, decision)` / `remote.setConflationDecisions(...)` - Persist individual
+ or batch review decisions.
+- `remote.applyConflationBulkDecision(baseId, request)` - Atomically apply an action to all candidates matching
+ the request's filter and return preview counts, the updated summary, and the complete decision snapshot.
+- `remote.generateConflationChangeset(baseId, mergeOptions)` - Build one cumulative direct, exact, and fuzzy
+ changeset and return routing diagnostics.
+- `remote.clearConflation(baseId)` - Discard the active review session and any generated changeset.
- `remote.search(osmId, key, val?)` - Search by tag.
- `remote.toPbf(osmId, stream)` - Export to PBF.
diff --git a/packages/osmix/package.json b/packages/osmix/package.json
index 8fdbe7a5..349c6473 100644
--- a/packages/osmix/package.json
+++ b/packages/osmix/package.json
@@ -17,6 +17,7 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
+ "profile:merge": "node --expose-gc --experimental-strip-types test/merge-profile-cli.ts",
"test": "pnpm -w exec vitest run --project \"$npm_package_name\"",
"typecheck": "tsc --noEmit"
},
diff --git a/packages/osmix/src/index.ts b/packages/osmix/src/index.ts
index 9e7b65fc..ab2c5d4a 100644
--- a/packages/osmix/src/index.ts
+++ b/packages/osmix/src/index.ts
@@ -20,7 +20,17 @@ export {
type OsmixRemoteOptions,
type OsmixWorkerLane,
} from "./remote.ts";
-export { OsmixWorker, type RouteResult, type WaySegment } from "./worker.ts";
+export {
+ OsmixWorker,
+ type OsmConflationCandidateView,
+ type OsmConflationGenerationResult,
+ type OsmConflationPage,
+ type OsmConflationRoutingDelta,
+ type OsmConflationRoutingDiagnostics,
+ type OsmConflationRoutingGraphStats,
+ type RouteResult,
+ type WaySegment,
+} from "./worker.ts";
export { drawToRasterTile, type DrawToRasterTileOptions } from "./raster.ts";
export {
canShareArrayBuffers,
@@ -62,7 +72,12 @@ export {
// --- @osmix/change ---
export {
applyChangesetToOsm,
+ conflationEffectiveStatus,
+ discoverConflationCandidates,
+ filterConflationCandidates,
generateChangeset,
+ generateConflationApplicationChangeset,
+ generateConflationChangeset,
generateOscChanges,
merge,
OsmChangeset,
@@ -76,6 +91,8 @@ export {
osmTagsToOscTags,
removeDuplicateAdjacentRelationMembers,
removeDuplicateAdjacentWayRefs,
+ summarizeConflationCandidates,
+ validateConflationDecisions,
waysIntersect,
waysShouldConnect,
} from "@osmix/change";
@@ -85,6 +102,26 @@ export type {
OsmChanges,
OsmChangesetStats,
OsmChangeTypes,
+ OsmConflationActionAssessment,
+ OsmConflationAutomatic,
+ OsmConflationBulkAction,
+ OsmConflationBulkDecisionPreview,
+ OsmConflationBulkDecisionRequest,
+ OsmConflationBulkDecisionResult,
+ OsmConflationCandidate,
+ OsmConflationCandidateFilter,
+ OsmConflationDecision,
+ OsmConflationDiscovery,
+ OsmConflationEffectiveStatus,
+ OsmConflationEntityType,
+ OsmConflationEvidence,
+ OsmConflationOptions,
+ OsmConflationReasonCode,
+ OsmConflationRoutingFamily,
+ OsmConflationStatus,
+ OsmConflationSummary,
+ OsmConflationTagDiff,
+ ResolvedOsmConflationOptions,
OsmEntityRef,
OsmMergeOptions,
} from "@osmix/change";
diff --git a/packages/osmix/src/remote.ts b/packages/osmix/src/remote.ts
index e17ebf91..7a78969a 100644
--- a/packages/osmix/src/remote.ts
+++ b/packages/osmix/src/remote.ts
@@ -8,7 +8,14 @@
* @module
*/
-import type { OsmChangeTypes, OsmMergeOptions } from "@osmix/change";
+import type {
+ OsmChangeTypes,
+ OsmConflationBulkDecisionRequest,
+ OsmConflationCandidateFilter,
+ OsmConflationDecision,
+ OsmConflationOptions,
+ OsmMergeOptions,
+} from "@osmix/change";
import { Osm, type OsmInfo, type OsmOptions, type OsmTransferables } from "@osmix/core";
import type { GeoParquetReadOptions } from "@osmix/geoparquet";
import { type GtfsConversionOptions, isGtfsZip as isGtfsZipBytes } from "@osmix/gtfs";
@@ -100,8 +107,21 @@ type DatasetProxyMethodName =
| "setChangesetFilters"
| "getChangesetPage";
+type ConflationDatasetProxyMethodName =
+ | "applyConflationBulkDecision"
+ | "discoverConflation"
+ | "getConflationSummary"
+ | "setConflationFilter"
+ | "getConflationPage"
+ | "setConflationDecision"
+ | "setConflationDecisions"
+ | "generateConflationChangeset"
+ | "clearConflation";
+
type OsmRemoteDatasetMethods = {
- [K in DatasetProxyMethodName]: BoundDatasetMethod[K]>;
+ [K in DatasetProxyMethodName | ConflationDatasetProxyMethodName]: BoundDatasetMethod<
+ OsmixRemote[K]
+ >;
};
type DatasetMemberMethodName = "size" | "getById" | "search";
@@ -392,6 +412,18 @@ interface ActiveChangesetState {
patchOsmId: string;
}
+interface ActiveConflationState {
+ baseOsmId: string;
+ changeTypes: OsmChangeTypes[];
+ changesetGenerated: boolean;
+ decisions: OsmConflationDecision[];
+ entityTypes: OsmEntityType[];
+ filter: OsmConflationCandidateFilter;
+ mergeOptions: Partial;
+ options: OsmConflationOptions;
+ patchOsmId: string;
+}
+
type DatasetRestorer = (
worker: Comlink.Remote,
datasetId: string,
@@ -399,6 +431,7 @@ type DatasetRestorer = (
export class OsmixRemote {
private activeChangeset: ActiveChangesetState | null = null;
+ private readonly activeConflations = new Map();
private readonly datasetRestorers = new Map | null>();
private readonly retainedDatasets = new Map();
private readonly retainedLoadDecisions = new Map();
@@ -624,6 +657,29 @@ export class OsmixRemote {
this.retainedRoutingGraphs.delete(id);
}
+ private invalidateConflationsForDataset(osmId: OsmId): void {
+ const id = this.getId(osmId);
+ // Dataset IDs are logical keys and loaders may replace the contents under one.
+ // Candidate evidence and decisions are invalid as soon as either input changes.
+ for (const [baseOsmId, state] of this.activeConflations) {
+ if (baseOsmId === id || state.patchOsmId === id) {
+ this.activeConflations.delete(baseOsmId);
+ }
+ }
+ if (
+ this.activeChangeset &&
+ (this.activeChangeset.baseOsmId === id || this.activeChangeset.patchOsmId === id)
+ ) {
+ this.activeChangeset = null;
+ }
+ }
+
+ private getActiveConflation(baseOsmId: string): ActiveConflationState {
+ const state = this.activeConflations.get(baseOsmId);
+ if (!state) throw Error("No active conflation session");
+ return state;
+ }
+
/** Mark changed data as known but not reproducible from its original source. */
private markDatasetUnrecoverable(osmId: OsmId): void {
const id = this.getId(osmId);
@@ -693,6 +749,19 @@ export class OsmixRemote {
await worker.generateChangeset(state.baseOsmId, state.patchOsmId, state.options);
await worker.setChangesetFilters(state.changeTypes, state.entityTypes);
}
+ if (index === 0) {
+ for (const state of this.activeConflations.values()) {
+ // Recovery reproduces review state by rediscovering from restored untouched
+ // inputs, then replaying stable ID-based decisions and filters.
+ await worker.discoverConflation(state.baseOsmId, state.patchOsmId, state.options);
+ await worker.setConflationFilter(state.baseOsmId, state.filter);
+ await worker.setConflationDecisions(state.baseOsmId, state.decisions);
+ if (state.changesetGenerated) {
+ await worker.generateConflationChangeset(state.baseOsmId, state.mergeOptions);
+ await worker.setChangesetFilters(state.changeTypes, state.entityTypes);
+ }
+ }
+ }
}
private async findMissingDatasets(worker: Comlink.Remote): Promise {
@@ -789,6 +858,7 @@ export class OsmixRemote {
(worker) => worker.fromPbf(transfer({ data: transferableData, options })),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(osmInfo.id);
const replayOptions = { ...options };
this.datasetRestorers.set(
osmInfo.id,
@@ -859,6 +929,7 @@ export class OsmixRemote {
),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(osmInfo.id);
const replayOptions = { ...options };
this.datasetRestorers.set(
osmInfo.id,
@@ -894,6 +965,7 @@ export class OsmixRemote {
),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(osmInfo.id);
const replayOptions = { ...options };
this.datasetRestorers.set(
osmInfo.id,
@@ -931,6 +1003,7 @@ export class OsmixRemote {
),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(osmInfo.id);
const replayOptions = { ...options };
const replayGtfsOptions = { ...gtfsOptions };
this.datasetRestorers.set(
@@ -1058,6 +1131,7 @@ export class OsmixRemote {
),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(osmInfo.id);
const replayOptions = { ...options };
const replayReadOptions = { ...readOptions };
const replayableSource =
@@ -1192,6 +1266,7 @@ export class OsmixRemote {
if (this.workerCount > 1 && !isShared) {
throw Error("Multiple workers require a SharedArrayBuffer-backed OSM dataset");
}
+ this.invalidateConflationsForDataset(transferables.id);
this.markDatasetUnrecoverable(transferables.id);
if (isShared) {
this.retainedDatasets.set(transferables.id, transferables);
@@ -1207,6 +1282,7 @@ export class OsmixRemote {
*/
async delete(osmId: OsmId): Promise {
const id = this.getId(osmId);
+ this.invalidateConflationsForDataset(id);
this.unregisterDatasetForRecovery(id);
if (
this.activeChangeset &&
@@ -1233,21 +1309,20 @@ export class OsmixRemote {
}),
{ lane: "control", retry: "once" },
);
+ // Invalidate sessions using either key: rename removes the source and may
+ // overwrite a different dataset already registered at the destination.
+ this.invalidateConflationsForDataset(from);
+ this.invalidateConflationsForDataset(toId);
// Update the id in the transferables
const updatedTransferables = { ...transferables, id: toId };
const restorer = this.datasetRestorers.get(from) ?? null;
this.unregisterDatasetForRecovery(from);
+ this.unregisterDatasetForRecovery(toId);
this.datasetRestorers.set(toId, restorer);
if (hasOnlySharedBackingBuffers(updatedTransferables)) {
this.retainedDatasets.set(toId, updatedTransferables);
this.retainedLoadDecisions.set(toId, loadDecision);
}
- if (
- this.activeChangeset &&
- (this.activeChangeset.baseOsmId === from || this.activeChangeset.patchOsmId === from)
- ) {
- this.activeChangeset = null;
- }
// Delete old entries and transfer in with new ID
await this.broadcastStateChange("dataset rename", async (worker) => {
await worker.delete(from);
@@ -1438,6 +1513,140 @@ export class OsmixRemote {
// Merge & Changesets
// ---------------------------------------------------------------------------
+ /** Discover fuzzy cross-dataset candidates without changing either input dataset. */
+ async discoverConflation(baseOsmId: OsmId, patchOsmId: OsmId, options: OsmConflationOptions) {
+ const baseId = this.getId(baseOsmId);
+ const patchId = this.getId(patchOsmId);
+ // Recovery state must not share mutable decisions or option arrays with callers.
+ const storedOptions = structuredClone(options);
+ const result = await this.runWithWorker(
+ (worker) => worker.discoverConflation(baseId, patchId, storedOptions),
+ { lane: "control", retry: "never" },
+ );
+ this.activeConflations.set(baseId, {
+ baseOsmId: baseId,
+ changeTypes: ["create", "modify", "delete"],
+ changesetGenerated: false,
+ decisions: storedOptions.decisions ?? [],
+ entityTypes: ["node", "way", "relation"],
+ filter: {},
+ mergeOptions: {},
+ options: storedOptions,
+ patchOsmId: patchId,
+ });
+ return result;
+ }
+
+ /** Return the current, decision-aware candidate summary. */
+ getConflationSummary(baseOsmId: OsmId) {
+ return this.runWithWorker((worker) => worker.getConflationSummary(this.getId(baseOsmId)), {
+ lane: "control",
+ retry: "once",
+ });
+ }
+
+ /** Set the filter used by subsequent candidate page requests. */
+ async setConflationFilter(baseOsmId: OsmId, filter: OsmConflationCandidateFilter = {}) {
+ const baseId = this.getId(baseOsmId);
+ const state = this.getActiveConflation(baseId);
+ const storedFilter = structuredClone(filter);
+ await this.runWithWorker((worker) => worker.setConflationFilter(baseId, storedFilter), {
+ lane: "control",
+ retry: "never",
+ });
+ state.filter = storedFilter;
+ }
+
+ /** Retrieve one page of filtered candidates and their current decisions. */
+ getConflationPage(baseOsmId: OsmId, page: number, pageSize: number) {
+ return this.runWithWorker(
+ (worker) => worker.getConflationPage(this.getId(baseOsmId), page, pageSize),
+ { lane: "control", retry: "once" },
+ );
+ }
+
+ /** Record or replace a single candidate decision. */
+ async setConflationDecision(baseOsmId: OsmId, decision: OsmConflationDecision) {
+ const baseId = this.getId(baseOsmId);
+ const state = this.getActiveConflation(baseId);
+ const storedDecision = structuredClone(decision);
+ const result = await this.runWithWorker(
+ (worker) => worker.setConflationDecision(baseId, storedDecision),
+ { lane: "control", retry: "never" },
+ );
+ state.decisions = [
+ ...state.decisions.filter((existing) => existing.candidateId !== storedDecision.candidateId),
+ storedDecision,
+ ];
+ state.changesetGenerated = false;
+ state.mergeOptions = {};
+ return result;
+ }
+
+ /** Replace all candidate decisions for the active session. */
+ async setConflationDecisions(baseOsmId: OsmId, decisions: OsmConflationDecision[]) {
+ const baseId = this.getId(baseOsmId);
+ const state = this.getActiveConflation(baseId);
+ const storedDecisions = structuredClone(decisions);
+ const result = await this.runWithWorker(
+ (worker) => worker.setConflationDecisions(baseId, storedDecisions),
+ { lane: "control", retry: "never" },
+ );
+ state.decisions = storedDecisions;
+ state.changesetGenerated = false;
+ state.mergeOptions = {};
+ return result;
+ }
+
+ /** Apply one action to all eligible candidates matching a filter across every page. */
+ async applyConflationBulkDecision(baseOsmId: OsmId, request: OsmConflationBulkDecisionRequest) {
+ const baseId = this.getId(baseOsmId);
+ const state = this.getActiveConflation(baseId);
+ const storedRequest = structuredClone(request);
+ const result = await this.runWithWorker(
+ (worker) => worker.applyConflationBulkDecision(baseId, storedRequest),
+ { lane: "control", retry: "never" },
+ );
+ state.decisions = result.decisions.map((decision) => ({ ...decision }));
+ if (result.preview.changedCandidates > 0) {
+ state.changesetGenerated = false;
+ state.mergeOptions = {};
+ }
+ return {
+ decisions: result.decisions.map((decision) => ({ ...decision })),
+ preview: { ...result.preview },
+ summary: { ...result.summary },
+ };
+ }
+
+ /**
+ * Generate the cumulative direct, exact, and accepted fuzzy changeset.
+ * Inputs remain untouched until {@link applyChangesAndReplace} is called.
+ */
+ async generateConflationChangeset(baseOsmId: OsmId, mergeOptions: Partial = {}) {
+ const baseId = this.getId(baseOsmId);
+ const state = this.getActiveConflation(baseId);
+ const storedOptions = { ...mergeOptions, conflation: undefined };
+ const result = await this.runWithWorker(
+ (worker) => worker.generateConflationChangeset(baseId, storedOptions),
+ { lane: "control", retry: "never" },
+ );
+ state.changesetGenerated = true;
+ state.mergeOptions = storedOptions;
+ this.activeChangeset = null;
+ return result;
+ }
+
+ /** Cancel a conflation session and discard any generated changeset. */
+ async clearConflation(baseOsmId: OsmId) {
+ const baseId = this.getId(baseOsmId);
+ await this.runWithWorker((worker) => worker.clearConflation(baseId), {
+ lane: "control",
+ retry: "never",
+ });
+ this.activeConflations.delete(baseId);
+ }
+
/**
* Merge two `Osm` instances in a worker.
* Replaces the base instance with the merge result and deletes the patch instance.
@@ -1448,6 +1657,8 @@ export class OsmixRemote {
(worker) => worker.merge(this.getId(baseOsmId), this.getId(patchOsmId), options),
{ lane: "control", retry: "never" },
);
+ this.invalidateConflationsForDataset(baseOsmId);
+ this.invalidateConflationsForDataset(patchOsmId);
this.markDatasetUnrecoverable(osmId);
await this.populateDatasetFromControl(osmId);
await this.delete(patchOsmId);
@@ -1487,6 +1698,7 @@ export class OsmixRemote {
lane: "control",
retry: "never",
});
+ this.invalidateConflationsForDataset(osmId);
this.markDatasetUnrecoverable(osmId);
await this.populateDatasetFromControl(osmId);
this.activeChangeset = null;
@@ -1501,6 +1713,11 @@ export class OsmixRemote {
this.activeChangeset.changeTypes = [...changeTypes];
this.activeChangeset.entityTypes = [...entityTypes];
}
+ for (const state of this.activeConflations.values()) {
+ if (!state.changesetGenerated) continue;
+ state.changeTypes = [...changeTypes];
+ state.entityTypes = [...entityTypes];
+ }
void this.runWithWorker((worker) => worker.setChangesetFilters(changeTypes, entityTypes), {
lane: "control",
retry: "never",
@@ -1525,6 +1742,7 @@ export class OsmixRemote {
const pool = this.workerPool;
this.workerPool = null;
this.activeChangeset = null;
+ this.activeConflations.clear();
this.datasetRestorers.clear();
this.retainedDatasets.clear();
this.retainedLoadDecisions.clear();
diff --git a/packages/osmix/src/worker.ts b/packages/osmix/src/worker.ts
index f0251cf4..aa9dbb28 100644
--- a/packages/osmix/src/worker.ts
+++ b/packages/osmix/src/worker.ts
@@ -20,13 +20,31 @@
import {
applyChangesetToOsm,
+ buildConflationBulkDecisionResult,
generateChangeset,
merge,
+ summarizeConflationCandidates,
type OsmChange,
type OsmChangeset,
type OsmChangeTypes,
+ type OsmConflationBulkAction,
+ type OsmConflationBulkDecisionPreview,
+ type OsmConflationBulkDecisionRequest,
+ type OsmConflationBulkDecisionResult,
+ type OsmConflationCandidate,
+ type OsmConflationCandidateFilter,
+ type OsmConflationDecision,
+ type OsmConflationDiscovery,
+ type OsmConflationOptions,
+ type OsmConflationSummary,
type OsmMergeOptions,
+ validateConflationDecisions,
} from "@osmix/change";
+import {
+ discoverConflationCandidatesForTrustedMerge,
+ generateConflationApplicationArtifactsFromTrustedDiscovery,
+ generateConflationArtifactsFromTrustedDiscovery,
+} from "@osmix/change/src/internal/conflation.ts";
import { Osm, type OsmOptions, type OsmTransferables } from "@osmix/core";
import { fromGeoJSON } from "@osmix/geojson";
import { fromGeoParquet, type GeoParquetReadOptions } from "@osmix/geoparquet";
@@ -40,6 +58,8 @@ import {
RoutingGraph,
type RoutingGraphTransferables,
type WaySegment,
+ defaultHighwayFilter,
+ defaultPedestrianFilter,
} from "@osmix/router";
import { fromShapefile } from "@osmix/shapefile";
import type { Progress, ProgressEvent } from "@osmix/shared/progress";
@@ -49,6 +69,193 @@ import type { LonLat, OsmEntityType, Tile } from "@osmix/types";
// Re-export types from router for backwards compatibility
export type { RouteResult, WaySegment };
+/** A conflation candidate together with the user's current review decision, if any. */
+export interface OsmConflationCandidateView extends OsmConflationCandidate {
+ decision?: OsmConflationDecision;
+}
+
+/** A stable, paginated view of the active conflation candidates. */
+export interface OsmConflationPage {
+ bulkActions: Record;
+ candidates: OsmConflationCandidateView[];
+ page: number;
+ pageSize: number;
+ totalCandidates: number;
+ totalPages: number;
+}
+
+/** Routing graph measurements captured before and after fuzzy conflation. */
+export interface OsmConflationRoutingGraphStats {
+ nodes: number;
+ routableNodes: number;
+ edges: number;
+ components: number;
+}
+
+/** Per-mode routing impact of accepted fuzzy conflation candidates. */
+export interface OsmConflationRoutingDelta {
+ before: OsmConflationRoutingGraphStats;
+ after: OsmConflationRoutingGraphStats;
+ delta: OsmConflationRoutingGraphStats;
+}
+
+/** CAR and WALK topology diagnostics for a generated conflation changeset. */
+export interface OsmConflationRoutingDiagnostics {
+ car: OsmConflationRoutingDelta;
+ walk: OsmConflationRoutingDelta;
+}
+
+/** Result of generating the cumulative direct, exact, and fuzzy changeset. */
+export interface OsmConflationGenerationResult {
+ stats: OsmChangeset["stats"];
+ routing: OsmConflationRoutingDiagnostics;
+}
+
+interface ConflationSession {
+ changesetGenerated: boolean;
+ decisions: Map;
+ discovery: OsmConflationDiscovery;
+ filter: OsmConflationCandidateFilter;
+ generatedChangeset?: OsmChangeset;
+ generatedResult?: Osm;
+ patchOsmId: string;
+ summary: OsmConflationSummary;
+}
+
+// Comlink normally clones return values, but tests and in-process remotes can expose
+// direct references. Clone every nested collection so UI code cannot mutate discovery.
+function cloneConflationCandidateView(
+ candidate: OsmConflationCandidate,
+ decision: OsmConflationDecision | undefined,
+): OsmConflationCandidateView {
+ return {
+ ...candidate,
+ reasons: [...candidate.reasons],
+ propertyTransfer: {
+ ...candidate.propertyTransfer,
+ reasons: [...candidate.propertyTransfer.reasons],
+ },
+ networkAttachment: candidate.networkAttachment
+ ? {
+ ...candidate.networkAttachment,
+ reasons: [...candidate.networkAttachment.reasons],
+ }
+ : null,
+ evidence: {
+ ...candidate.evidence,
+ sourceRoutingFamilies: [...candidate.evidence.sourceRoutingFamilies],
+ targetRoutingFamilies: [...candidate.evidence.targetRoutingFamilies],
+ tagDiff: candidate.evidence.tagDiff.map((diff) => ({ ...diff })),
+ patchWayIds: candidate.evidence.patchWayIds ? [...candidate.evidence.patchWayIds] : undefined,
+ endpointDistancesMeters: candidate.evidence.endpointDistancesMeters
+ ? [...candidate.evidence.endpointDistancesMeters]
+ : undefined,
+ },
+ decision: decision ? { ...decision } : undefined,
+ };
+}
+
+function conflationCandidateMatches(
+ candidate: OsmConflationCandidate,
+ decision: OsmConflationDecision | undefined,
+ filter: OsmConflationCandidateFilter,
+) {
+ const status =
+ decision?.action === "accept"
+ ? "accepted"
+ : decision?.action === "reject"
+ ? "rejected"
+ : candidate.status;
+ if (filter.entityType != null && candidate.entityType !== filter.entityType) return false;
+ if (filter.status != null && status !== filter.status) return false;
+ if (filter.reason != null && !candidate.reasons.includes(filter.reason)) return false;
+ if (filter.sourceId != null && candidate.sourceId !== filter.sourceId) return false;
+ if ("targetId" in filter && candidate.targetId !== filter.targetId) return false;
+ return true;
+}
+
+function routingGraphStats(osm: Osm, filter: HighwayFilter): OsmConflationRoutingGraphStats {
+ const graph = new RoutingGraph(osm, filter);
+ const parent = new Int32Array(graph.size);
+ parent.fill(-1);
+ let routableNodes = 0;
+
+ for (let nodeIndex = 0; nodeIndex < graph.size; nodeIndex++) {
+ if (!graph.isRoutable(nodeIndex)) continue;
+ parent[nodeIndex] = nodeIndex;
+ routableNodes++;
+ }
+
+ const find = (nodeIndex: number): number => {
+ let root = nodeIndex;
+ while (parent[root] !== root) root = parent[root]!;
+ let cursor = nodeIndex;
+ while (parent[cursor] !== cursor) {
+ const next = parent[cursor]!;
+ parent[cursor] = root;
+ cursor = next;
+ }
+ return root;
+ };
+
+ for (let nodeIndex = 0; nodeIndex < graph.size; nodeIndex++) {
+ if (parent[nodeIndex] === -1) continue;
+ for (const edge of graph.getEdges(nodeIndex)) {
+ if (parent[edge.targetNodeIndex] === -1) continue;
+ const left = find(nodeIndex);
+ const right = find(edge.targetNodeIndex);
+ if (left !== right) parent[right] = left;
+ }
+ }
+
+ const roots = new Set();
+ for (let nodeIndex = 0; nodeIndex < graph.size; nodeIndex++) {
+ if (parent[nodeIndex] !== -1) roots.add(find(nodeIndex));
+ }
+
+ return {
+ nodes: graph.size,
+ routableNodes,
+ edges: graph.edges,
+ components: roots.size,
+ };
+}
+
+function routingDelta(
+ before: OsmConflationRoutingGraphStats,
+ after: OsmConflationRoutingGraphStats,
+): OsmConflationRoutingDelta {
+ return {
+ before,
+ after,
+ delta: {
+ nodes: after.nodes - before.nodes,
+ routableNodes: after.routableNodes - before.routableNodes,
+ edges: after.edges - before.edges,
+ components: after.components - before.components,
+ },
+ };
+}
+
+function routingDiagnostics(baseline: Osm, conflated: Osm): OsmConflationRoutingDiagnostics {
+ const walkFilter: HighwayFilter = (tags) =>
+ defaultHighwayFilter(tags) || defaultPedestrianFilter(tags);
+ return {
+ car: routingDelta(
+ routingGraphStats(baseline, defaultHighwayFilter),
+ routingGraphStats(conflated, defaultHighwayFilter),
+ ),
+ walk: routingDelta(
+ routingGraphStats(baseline, walkFilter),
+ routingGraphStats(conflated, walkFilter),
+ ),
+ };
+}
+
+function carTopologyChanged(delta: OsmConflationRoutingDelta) {
+ return delta.delta.routableNodes !== 0 || delta.delta.edges !== 0 || delta.delta.components !== 0;
+}
+
import {
fromPbf,
getOsmLoadDecision as getStoredOsmLoadDecision,
@@ -78,6 +285,7 @@ export class OsmixWorker extends EventTarget {
private vtEncoders = new Map();
private graphs = new Map();
private changesets = new Map();
+ private conflations = new Map();
private changeTypes: OsmChangeTypes[] = ["create", "modify", "delete"];
private entityTypes: OsmEntityType[] = ["node", "way", "relation"];
private filteredChanges = new Map();
@@ -287,6 +495,7 @@ export class OsmixWorker extends EventTarget {
* rebuild it.
*/
protected set(id: string, osm: Osm) {
+ this.invalidateConflationsForDataset(id);
this.osm.set(id, osm);
this.loadDecisions.delete(id);
this.vtEncoders.set(id, new OsmixVtEncoder(osm));
@@ -306,10 +515,24 @@ export class OsmixWorker extends EventTarget {
* Remove an Osm instance from this worker, freeing its memory.
*/
delete(id: string) {
+ this.invalidateConflationsForDataset(id);
this.osm.delete(id);
this.loadDecisions.delete(id);
this.vtEncoders.delete(id);
this.graphs.delete(id);
+ this.changesets.delete(id);
+ this.filteredChanges.delete(id);
+ }
+
+ private invalidateConflationsForDataset(osmId: string) {
+ for (const [baseOsmId, session] of this.conflations) {
+ if (baseOsmId !== osmId && session.patchOsmId !== osmId) continue;
+ this.conflations.delete(baseOsmId);
+ if (session.changesetGenerated) {
+ this.changesets.delete(baseOsmId);
+ this.filteredChanges.delete(baseOsmId);
+ }
+ }
}
// ---------------------------------------------------------------------------
@@ -486,6 +709,248 @@ export class OsmixWorker extends EventTarget {
return this.get(osmId).relations.search(key, val);
}
+ /** Discover non-exact, cross-dataset conflation candidates without mutating either input. */
+ discoverConflation(
+ baseOsmId: string,
+ patchOsmId: string,
+ options: OsmConflationOptions,
+ ): OsmConflationSummary {
+ const discovery = discoverConflationCandidatesForTrustedMerge(
+ this.get(baseOsmId),
+ this.get(patchOsmId),
+ options,
+ );
+ const initialDecisions = options.decisions === undefined ? [] : options.decisions;
+ validateConflationDecisions(discovery.candidates, initialDecisions);
+ const decisions = new Map();
+ for (const decision of initialDecisions) {
+ decisions.set(decision.candidateId, { ...decision });
+ }
+ const previous = this.conflations.get(baseOsmId);
+ if (previous?.changesetGenerated) {
+ this.changesets.delete(baseOsmId);
+ this.filteredChanges.delete(baseOsmId);
+ }
+ const summary =
+ decisions.size === 0
+ ? discovery.summary
+ : summarizeConflationCandidates(discovery.candidates, [...decisions.values()]);
+ this.conflations.set(baseOsmId, {
+ changesetGenerated: false,
+ decisions,
+ discovery,
+ filter: {},
+ patchOsmId,
+ summary,
+ });
+ return { ...summary };
+ }
+
+ /** Return the decision-aware summary for an active conflation session. */
+ getConflationSummary(baseOsmId: string): OsmConflationSummary {
+ return { ...this.getConflationSession(baseOsmId).summary };
+ }
+
+ /** Replace the active candidate filter used by {@link getConflationPage}. */
+ setConflationFilter(baseOsmId: string, filter: OsmConflationCandidateFilter = {}) {
+ this.getConflationSession(baseOsmId).filter = { ...filter };
+ }
+
+ /** Retrieve a stable page of candidates together with their current review decisions. */
+ getConflationPage(baseOsmId: string, page: number, pageSize: number): OsmConflationPage {
+ if (!Number.isInteger(page) || page < 0) throw Error("page must be a non-negative integer");
+ if (!Number.isInteger(pageSize) || pageSize <= 0) {
+ throw Error("pageSize must be a positive integer");
+ }
+ const session = this.getConflationSession(baseOsmId);
+ const candidates = session.discovery.candidates.filter((candidate) =>
+ conflationCandidateMatches(candidate, session.decisions.get(candidate.id), session.filter),
+ );
+ const start = page * pageSize;
+ const decisions = [...session.decisions.values()];
+ const bulkActions = Object.fromEntries(
+ (["transfer-properties", "attach-network", "reject"] as const).map((action) => [
+ action,
+ buildConflationBulkDecisionResult(session.discovery.candidates, decisions, {
+ action,
+ filter: session.filter,
+ }).preview,
+ ]),
+ ) as Record;
+ return {
+ bulkActions,
+ candidates: candidates
+ .slice(start, start + pageSize)
+ .map((candidate) =>
+ cloneConflationCandidateView(candidate, session.decisions.get(candidate.id)),
+ ),
+ page,
+ pageSize,
+ totalCandidates: candidates.length,
+ totalPages: Math.ceil(candidates.length / pageSize),
+ };
+ }
+
+ /** Record or replace one candidate decision and invalidate any generated changeset. */
+ setConflationDecision(baseOsmId: string, decision: OsmConflationDecision) {
+ const session = this.getConflationSession(baseOsmId);
+ // Validate before touching session state so malformed RPC input is atomic.
+ validateConflationDecisions(session.discovery.candidates, [decision]);
+ this.invalidateGeneratedConflationChangeset(baseOsmId, session);
+ session.decisions.set(decision.candidateId, { ...decision });
+ session.summary = summarizeConflationCandidates(session.discovery.candidates, [
+ ...session.decisions.values(),
+ ]);
+ return { ...session.summary };
+ }
+
+ /** Replace every candidate decision and invalidate any generated changeset. */
+ setConflationDecisions(baseOsmId: string, decisions: OsmConflationDecision[]) {
+ const session = this.getConflationSession(baseOsmId);
+ // Build and validate the replacement set before discarding reviewed output.
+ validateConflationDecisions(session.discovery.candidates, decisions);
+ const next = new Map();
+ for (const decision of decisions) {
+ next.set(decision.candidateId, { ...decision });
+ }
+ this.invalidateGeneratedConflationChangeset(baseOsmId, session);
+ session.decisions = next;
+ session.summary =
+ next.size === 0
+ ? session.discovery.summary
+ : summarizeConflationCandidates(session.discovery.candidates, [...next.values()]);
+ return { ...session.summary };
+ }
+
+ /** Apply one action to every eligible candidate matching the supplied filter. */
+ applyConflationBulkDecision(
+ baseOsmId: string,
+ request: OsmConflationBulkDecisionRequest,
+ ): OsmConflationBulkDecisionResult {
+ const session = this.getConflationSession(baseOsmId);
+ const result = buildConflationBulkDecisionResult(
+ session.discovery.candidates,
+ [...session.decisions.values()],
+ request,
+ );
+ if (result.preview.changedCandidates > 0) {
+ this.invalidateGeneratedConflationChangeset(baseOsmId, session);
+ session.decisions = new Map(
+ result.decisions.map((decision) => [decision.candidateId, { ...decision }]),
+ );
+ }
+ session.summary = { ...result.summary };
+ return {
+ decisions: result.decisions.map((decision) => ({ ...decision })),
+ preview: { ...result.preview },
+ summary: { ...result.summary },
+ };
+ }
+
+ /**
+ * Generate one cumulative direct, exact, and fuzzy changeset from the untouched inputs.
+ * Intersections remain a subsequent merge stage so routing diagnostics isolate conflation.
+ */
+ generateConflationChangeset(
+ baseOsmId: string,
+ mergeOptions: Partial = {},
+ ): OsmConflationGenerationResult {
+ if (mergeOptions.createIntersections) {
+ throw Error(
+ "Generate and apply conflation before creating intersections; createIntersections must be false",
+ );
+ }
+ const session = this.getConflationSession(baseOsmId);
+ const base = this.get(baseOsmId);
+ const patch = this.get(session.patchOsmId);
+ const decisions = [...session.decisions.values()];
+ const conflation = {
+ ...session.discovery.options,
+ decisions,
+ };
+ const options: Partial = {
+ ...mergeOptions,
+ createIntersections: false,
+ conflation,
+ };
+ const artifacts = generateConflationArtifactsFromTrustedDiscovery(
+ base,
+ patch,
+ options,
+ decisions,
+ session.discovery,
+ this.onProgress,
+ );
+ const diagnostics = routingDiagnostics(artifacts.ordinaryBaseline, artifacts.result);
+ // The full result may contain manually reviewed motor-network changes. Project
+ // automatic attachments alone so the automatic WALK-only CAR invariant is exact.
+ let hasAutomaticNetworkAttachment = false;
+ const automaticAttachmentDecisions: OsmConflationDecision[] = [];
+ for (const candidate of session.discovery.candidates) {
+ const decision = session.decisions.get(candidate.id);
+ const attachNetwork =
+ candidate.networkAttachment?.status === "automatic" &&
+ decision?.action !== "reject" &&
+ decision?.attachNetwork !== false;
+ hasAutomaticNetworkAttachment ||= attachNetwork;
+ if (attachNetwork) {
+ automaticAttachmentDecisions.push({
+ candidateId: candidate.id,
+ action: "accept",
+ transferProperties: false,
+ attachNetwork: true,
+ });
+ } else if (
+ candidate.propertyTransfer.status === "automatic" ||
+ candidate.networkAttachment?.status === "automatic"
+ ) {
+ // A missing decision enables automatic actions. Explicitly reject only
+ // automatic candidates that must be absent from this attachment-only
+ // projection; review, blocked, and unmatched rows already apply nothing.
+ automaticAttachmentDecisions.push({
+ candidateId: candidate.id,
+ action: "reject",
+ });
+ }
+ }
+ if (hasAutomaticNetworkAttachment) {
+ const automaticAttachment = generateConflationApplicationArtifactsFromTrustedDiscovery(
+ artifacts.ordinaryBaseline,
+ patch,
+ session.discovery,
+ base,
+ automaticAttachmentDecisions,
+ );
+ const automaticCarDelta = routingDelta(
+ diagnostics.car.before,
+ routingGraphStats(automaticAttachment.result, defaultHighwayFilter),
+ );
+ if (carTopologyChanged(automaticCarDelta)) {
+ throw Error(
+ "Automatic walk-only conflation changed the CAR graph; review the candidate instead",
+ );
+ }
+ }
+
+ this.changesets.set(baseOsmId, artifacts.changeset);
+ // Candidate review does not imply changeset review. Defer the large filtered
+ // change list until a caller actually opens a changeset page; automatic runs
+ // apply the already validated materialized result without building it.
+ this.filteredChanges.delete(baseOsmId);
+ session.changesetGenerated = true;
+ session.generatedChangeset = artifacts.changeset;
+ session.generatedResult = artifacts.result;
+ return { stats: artifacts.changeset.stats, routing: diagnostics };
+ }
+
+ /** Clear an active conflation session and its generated changeset, if present. */
+ clearConflation(baseOsmId: string) {
+ const session = this.conflations.get(baseOsmId);
+ if (!session) return;
+ this.invalidateGeneratedConflationChangeset(baseOsmId, session);
+ this.conflations.delete(baseOsmId);
+ }
+
/**
* Perform a full merge of two Osm indexes inside of a worker. Both Osm indexes must be loaded already.
* Replaces the base Osm and deletes the patch Osm.
@@ -516,7 +981,7 @@ export class OsmixWorker extends EventTarget {
this.onProgress,
);
this.changesets.set(baseOsmId, changeset);
- this.sortChangeset(baseOsmId, changeset);
+ this.filteredChanges.delete(baseOsmId);
return changeset.stats;
}
@@ -545,6 +1010,7 @@ export class OsmixWorker extends EventTarget {
getChangesetPage(osmId: string, page: number, pageSize: number) {
const changeset = this.changesets.get(osmId);
if (!changeset) throw Error("No active changeset");
+ if (!this.filteredChanges.has(osmId)) this.sortChangeset(osmId, changeset);
const filteredChanges = this.filteredChanges.get(osmId);
const changes = filteredChanges?.slice(page * pageSize, (page + 1) * pageSize);
return {
@@ -560,13 +1026,35 @@ export class OsmixWorker extends EventTarget {
applyChangesAndReplace(osmId: string) {
const changeset = this.changesets.get(osmId);
if (!changeset) throw Error("No active changeset");
- const newOsm = applyChangesetToOsm(changeset);
+ const session = this.conflations.get(osmId);
+ const newOsm =
+ session?.changesetGenerated && session.generatedChangeset === changeset
+ ? session.generatedResult
+ : applyChangesetToOsm(changeset);
+ if (!newOsm) throw Error("Generated conflation result is missing");
this.set(osmId, newOsm);
this.changesets.delete(osmId);
this.filteredChanges.delete(osmId);
return newOsm.id;
}
+ private getConflationSession(baseOsmId: string) {
+ const session = this.conflations.get(baseOsmId);
+ if (!session) throw Error("No active conflation session");
+ return session;
+ }
+
+ private invalidateGeneratedConflationChangeset(baseOsmId: string, session: ConflationSession) {
+ if (!session.changesetGenerated) return;
+ // A reviewed changeset is a snapshot of its decisions. Never allow a later
+ // decision edit to apply that stale snapshot.
+ this.changesets.delete(baseOsmId);
+ this.filteredChanges.delete(baseOsmId);
+ session.changesetGenerated = false;
+ session.generatedChangeset = undefined;
+ session.generatedResult = undefined;
+ }
+
/**
* Filter and sort changeset entries by the current entity type and change type filters.
* Updates the filteredChanges cache for efficient pagination.
diff --git a/packages/osmix/test/conflation-yakima.test.ts b/packages/osmix/test/conflation-yakima.test.ts
new file mode 100644
index 00000000..1175ac56
--- /dev/null
+++ b/packages/osmix/test/conflation-yakima.test.ts
@@ -0,0 +1,211 @@
+import { access } from "node:fs/promises";
+
+import type { Osm } from "@osmix/core";
+import { getFixtureFileReadStream, getFixturePath } from "@osmix/test-utils/fixtures";
+import { describe, expect, it } from "vitest";
+
+import {
+ discoverConflationCandidates,
+ fromPbf,
+ type OsmConflationCandidate,
+ type OsmConflationDiscovery,
+} from "../src/index.ts";
+
+const BASE_FIXTURE = "yakima-full.osm.pbf";
+const PATCH_FIXTURE = "yakima.osw.pbf";
+const fixturesExist = await Promise.all(
+ [BASE_FIXTURE, PATCH_FIXTURE].map((fixture) =>
+ access(getFixturePath(fixture))
+ .then(() => true)
+ .catch(() => false),
+ ),
+).then((results) => results.every(Boolean));
+
+function getCandidate(discovery: OsmConflationDiscovery, id: string) {
+ const candidate = discovery.candidates.find((item) => item.id === id);
+ if (!candidate) throw new Error(`Missing Yakima conflation witness ${id}`);
+ return candidate;
+}
+
+function getTargetId(candidate: OsmConflationCandidate) {
+ if (candidate.targetId == null) throw new Error(`${candidate.id} does not have a target`);
+ return candidate.targetId;
+}
+
+function getIncidentWays(osm: Osm, nodeId: number) {
+ return [...osm.ways].filter((way) => way.refs.includes(nodeId));
+}
+
+function expectNonExact(candidate: OsmConflationCandidate, base: Osm, patch: Osm) {
+ expect(candidate.targetId).not.toBeNull();
+ const source = patch.nodes.getById(candidate.sourceId);
+ const target = candidate.targetId == null ? null : base.nodes.getById(candidate.targetId);
+ expect(source).not.toBeNull();
+ expect(target).not.toBeNull();
+ expect([source?.lon, source?.lat]).not.toEqual([target?.lon, target?.lat]);
+ expect(candidate.evidence.distanceMeters).toBeGreaterThan(0);
+ expect(candidate.evidence.distanceMeters).toBeLessThanOrEqual(1);
+}
+
+function expectSchoolBoundaryBlocked(
+ discovery: OsmConflationDiscovery,
+ base: Osm,
+ patch: Osm,
+ witness: {
+ candidateId: string;
+ patchWayId: number;
+ schoolName: string;
+ targetWayId: number;
+ },
+) {
+ const candidate = getCandidate(discovery, witness.candidateId);
+ expectNonExact(candidate, base, patch);
+ expect(candidate).toMatchObject({
+ entityType: "node",
+ status: "blocked",
+ networkAttachment: { status: "blocked" },
+ });
+ expect(candidate.reasons).toContain("non-routing-target");
+
+ const sourceWay = getIncidentWays(patch, candidate.sourceId).find(
+ (way) => way.id === witness.patchWayId,
+ );
+ const targetWay = getIncidentWays(base, getTargetId(candidate)).find(
+ (way) => way.id === witness.targetWayId,
+ );
+ expect(sourceWay?.tags).toMatchObject({ highway: "footway" });
+ expect(targetWay?.tags).toMatchObject({ amenity: "school", name: witness.schoolName });
+ expect(targetWay?.tags?.["highway"]).toBeUndefined();
+ expect(targetWay?.refs[0]).toBe(targetWay?.refs.at(-1));
+}
+
+describe("Yakima fuzzy conflation", () => {
+ it.runIf(fixturesExist)(
+ "classifies real non-exact OSW candidates conservatively",
+ async () => {
+ const [base, patch] = await Promise.all([
+ fromPbf(getFixtureFileReadStream(BASE_FIXTURE), { id: BASE_FIXTURE }),
+ fromPbf(getFixtureFileReadStream(PATCH_FIXTURE), { id: PATCH_FIXTURE }),
+ ]);
+ const discovery = discoverConflationCandidates(base, patch, {
+ propertyKeys: ["barrier", "crossing", "kerb", "tactile_paving"],
+ attachNetwork: true,
+ });
+
+ expect(discovery.options).toEqual({
+ propertyKeys: ["barrier", "crossing", "kerb", "tactile_paving"],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence",
+ });
+ expect(discovery.summary).toEqual({
+ total: 11_689,
+ accepted: 0,
+ automatic: 145,
+ review: 212,
+ blocked: 88,
+ unmatched: 11_244,
+ rejected: 0,
+ });
+
+ const matched = discovery.candidates.filter((candidate) => candidate.targetId != null);
+ const targetCountBySource = new Map();
+ for (const candidate of matched) {
+ targetCountBySource.set(
+ candidate.sourceId,
+ (targetCountBySource.get(candidate.sourceId) ?? 0) + 1,
+ );
+ }
+ expect(matched).toHaveLength(445);
+ expect(targetCountBySource.size).toBe(399);
+ expect([...targetCountBySource.values()].filter((count) => count === 1)).toHaveLength(356);
+ expect([...targetCountBySource.values()].filter((count) => count > 1)).toHaveLength(43);
+ expect(matched.every((candidate) => candidate.evidence.distanceMeters > 0)).toBe(true);
+
+ const accessibleCrossing = getCandidate(discovery, "node:2220318->11643002707");
+ expectNonExact(accessibleCrossing, base, patch);
+ expect(accessibleCrossing).toMatchObject({
+ status: "review",
+ reasons: ["node-context-conflict"],
+ propertyTransfer: { status: "automatic", reasons: [] },
+ networkAttachment: { status: "review", reasons: ["node-context-conflict"] },
+ evidence: {
+ distanceMeters: 0.40797,
+ sourceRoutingFamilies: ["pedestrian"],
+ targetRoutingFamilies: ["pedestrian"],
+ tagDiff: [
+ {
+ key: "tactile_paving",
+ patchValue: "yes",
+ protected: false,
+ routing: false,
+ },
+ ],
+ },
+ });
+ expect(
+ getIncidentWays(patch, accessibleCrossing.sourceId).find((way) => way.id === 850268)?.tags,
+ ).toMatchObject({ footway: "crossing", highway: "footway" });
+ expect(
+ getIncidentWays(base, getTargetId(accessibleCrossing)).find(
+ (way) => way.id === 1_252_605_649,
+ )?.tags,
+ ).toMatchObject({ footway: "crossing", highway: "footway" });
+
+ const kerbConflict = getCandidate(discovery, "node:2475012->11643237283");
+ expectNonExact(kerbConflict, base, patch);
+ expect(kerbConflict).toMatchObject({
+ status: "blocked",
+ networkAttachment: {
+ status: "blocked",
+ reasons: expect.arrayContaining(["routing-family-conflict"]),
+ },
+ });
+ expect(patch.nodes.getById(kerbConflict.sourceId)?.tags).toMatchObject({
+ barrier: "kerb",
+ });
+ expect(patch.nodes.getById(kerbConflict.sourceId)?.tags?.["kerb"]).toBeUndefined();
+ expect(base.nodes.getById(getTargetId(kerbConflict))?.tags).toMatchObject({
+ barrier: "kerb",
+ kerb: "raised",
+ });
+
+ const sidewalk = getCandidate(discovery, "node:2213758->8075647920");
+ expectNonExact(sidewalk, base, patch);
+ expect(sidewalk).toMatchObject({
+ status: "automatic",
+ propertyTransfer: {
+ status: "blocked",
+ reasons: ["no-transferable-properties"],
+ },
+ networkAttachment: { status: "automatic", reasons: [] },
+ });
+ expect(
+ getIncidentWays(patch, sidewalk.sourceId).find((way) => way.id === 848575)?.tags,
+ ).toMatchObject({ footway: "sidewalk", highway: "footway" });
+ expect(
+ getIncidentWays(base, getTargetId(sidewalk)).find((way) => way.id === 866_417_077)?.tags,
+ ).toMatchObject({ footway: "sidewalk", highway: "footway" });
+
+ expectSchoolBoundaryBlocked(discovery, base, patch, {
+ candidateId: "node:2193697->7201121727",
+ patchWayId: 840053,
+ targetWayId: 771_378_493,
+ schoolName: "West Valley High School",
+ });
+ expectSchoolBoundaryBlocked(discovery, base, patch, {
+ candidateId: "node:9412890->9508231896",
+ patchWayId: 4_256_164,
+ targetWayId: 1_031_701_052,
+ schoolName: "White Swan High School",
+ });
+ expectSchoolBoundaryBlocked(discovery, base, patch, {
+ candidateId: "node:9885001->2172323056",
+ patchWayId: 4_490_365,
+ targetWayId: 207_104_786,
+ schoolName: "Terrace Heights Elementary School",
+ });
+ },
+ 60_000,
+ );
+});
diff --git a/packages/osmix/test/eastern-washington-merge.test.ts b/packages/osmix/test/eastern-washington-merge.test.ts
new file mode 100644
index 00000000..828b12a9
--- /dev/null
+++ b/packages/osmix/test/eastern-washington-merge.test.ts
@@ -0,0 +1,109 @@
+import { execFile } from "node:child_process";
+import { createReadStream, createWriteStream } from "node:fs";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Readable, Writable } from "node:stream";
+import { promisify } from "node:util";
+
+import type { Osm } from "@osmix/core";
+import { getFixtureFileReadStream } from "@osmix/test-utils/fixtures";
+import { describe, expect, it } from "vitest";
+
+import { fromPbf, merge, toPbfStream } from "../src/index.ts";
+
+const execFileAsync = promisify(execFile);
+const RUN_INTEGRATION = process.env["OSMIX_EASTERN_WASHINGTON_INTEGRATION"] === "1";
+const BASE_FIXTURE = "osmix-e_wa_osm.pbf";
+const PATCH_FIXTURE = "east_washington_sidewalk_proviso_1.pbf";
+const COLLAPSE_WITNESSES = [
+ 853_782, 855_126, 898_741, 1_030_054, 1_869_808, 1_870_520, 1_870_540, 1_870_637, 4_036_007,
+ 4_079_228, 4_471_764,
+] as const;
+
+function sizes(osm: Osm) {
+ return {
+ nodes: osm.nodes.size,
+ relations: osm.relations.size,
+ ways: osm.ways.size,
+ };
+}
+
+function expectValidWayTopology(osm: Osm) {
+ const danglingRefs: string[] = [];
+ const degenerateHighways: number[] = [];
+ for (const way of osm.ways) {
+ if (way.tags?.["highway"] != null && new Set(way.refs).size < 2) {
+ degenerateHighways.push(way.id);
+ }
+ for (const ref of way.refs) {
+ if (!osm.nodes.ids.has(ref)) danglingRefs.push(`${way.id}->${ref}`);
+ }
+ }
+ expect(degenerateHighways).toEqual([]);
+ expect(danglingRefs).toEqual([]);
+}
+
+describe("Eastern Washington full merge", () => {
+ it.runIf(RUN_INTEGRATION)(
+ "preserves short imported footways through export and reload",
+ async () => {
+ let base: Osm | null = await fromPbf(
+ getFixtureFileReadStream(BASE_FIXTURE),
+ { id: BASE_FIXTURE },
+ () => {},
+ );
+ let patch: Osm | null = await fromPbf(
+ getFixtureFileReadStream(PATCH_FIXTURE),
+ { id: PATCH_FIXTURE },
+ () => {},
+ );
+ expect(sizes(base)).toEqual({ nodes: 2_819_575, relations: 0, ways: 244_822 });
+ expect(sizes(patch)).toEqual({ nodes: 1_107_476, relations: 0, ways: 368_648 });
+
+ const progress: string[] = [];
+ const merged = await merge(
+ base,
+ patch,
+ {
+ createIntersections: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ directMerge: true,
+ },
+ (event) => progress.push(event.detail.msg),
+ );
+ base = null;
+ patch = null;
+
+ expectValidWayTopology(merged);
+ for (const wayId of COLLAPSE_WITNESSES) {
+ expect(new Set(merged.ways.getById(wayId)?.refs).size, `way ${wayId}`).toBeGreaterThan(1);
+ }
+ expect(progress).toContain("Intersection creation progress: 368,648 of 368,648 ways checked");
+
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), "osmix-eastern-washington-"));
+ const outputPath = join(temporaryDirectory, "merged.osm.pbf");
+ try {
+ await toPbfStream(merged).pipeTo(
+ Writable.toWeb(createWriteStream(outputPath)) as WritableStream,
+ );
+ await execFileAsync("osmium", ["check-refs", outputPath]);
+
+ const reloaded = await fromPbf(
+ Readable.toWeb(createReadStream(outputPath)) as ReadableStream,
+ { id: "eastern-washington-round-trip" },
+ () => {},
+ );
+ expect(sizes(reloaded)).toEqual(sizes(merged));
+ expectValidWayTopology(reloaded);
+ for (const wayId of COLLAPSE_WITNESSES) {
+ expect(reloaded.ways.getById(wayId)?.refs).toEqual(merged.ways.getById(wayId)?.refs);
+ }
+ } finally {
+ await rm(temporaryDirectory, { force: true, recursive: true });
+ }
+ },
+ 20 * 60_000,
+ );
+});
diff --git a/packages/osmix/test/fixtures/routing-cases.ts b/packages/osmix/test/fixtures/routing-cases.ts
new file mode 100644
index 00000000..f85d42d4
--- /dev/null
+++ b/packages/osmix/test/fixtures/routing-cases.ts
@@ -0,0 +1,433 @@
+import type { LonLat, RouteOptions } from "../../src/index.ts";
+
+export type RoutingTestMode = "car" | "walk";
+
+export type RoutingTestEndpoint =
+ | { nodeId: number }
+ | {
+ coordinates: LonLat;
+ maxSnapDistanceMeters: number;
+ };
+
+export interface RoutingPolicyLimitation {
+ kind: "access" | "turn-restriction";
+ reason: string;
+ r5Expectation: string;
+ witness: {
+ type: "relation" | "way";
+ id: number;
+ tags: Readonly>;
+ };
+}
+
+export interface RoutingTestExpectation {
+ reachable?: boolean;
+ distanceMeters?: { min: number; max: number };
+ timeSeconds?: { min: number; max: number };
+ requiredWayIds?: readonly number[];
+ forbiddenWayIds?: readonly number[];
+}
+
+export interface RoutingTestCase {
+ id: string;
+ description: string;
+ mode: RoutingTestMode;
+ metric: RouteOptions["metric"];
+ from: RoutingTestEndpoint;
+ to: RoutingTestEndpoint;
+ expect: RoutingTestExpectation;
+ /** Optional test-only policy refinement; this is not a public Osmix routing profile. */
+ graphPolicy?: "access-aware";
+ policyLimitation?: RoutingPolicyLimitation;
+}
+
+/**
+ * Routes whose OSM IDs and broad measurements are stable in the checked-in Monaco fixture.
+ * Exact coordinate arrays are deliberately not golden data: they are too sensitive to harmless
+ * encoding and graph-storage changes.
+ */
+export const MONACO_ROUTING_CASES = [
+ {
+ id: "monaco-short-drive",
+ description: "Short drive through central Monaco",
+ mode: "car",
+ metric: "distance",
+ from: { coordinates: [7.4229093, 43.7371175], maxSnapDistanceMeters: 100 },
+ to: { coordinates: [7.4259193, 43.7377731], maxSnapDistanceMeters: 100 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 240, max: 270 },
+ timeSeconds: { min: 15, max: 25 },
+ requiredWayIds: [157719644, 254596486, 166624050],
+ },
+ },
+ {
+ id: "monaco-short-walk",
+ description: "Walk between the same central Monaco points",
+ mode: "walk",
+ metric: "distance",
+ from: { coordinates: [7.4229093, 43.7371175], maxSnapDistanceMeters: 100 },
+ to: { coordinates: [7.4259193, 43.7377731], maxSnapDistanceMeters: 100 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 200, max: 500 },
+ },
+ },
+ {
+ id: "monaco-cross-town-drive",
+ description: "West-to-east drive across Monaco's largest connected road component",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 4329343083 },
+ to: { nodeId: 7779445520 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 5_650, max: 5_900 },
+ timeSeconds: { min: 360, max: 390 },
+ requiredWayIds: [239592573, 952419570, 161627743],
+ },
+ },
+ {
+ id: "monaco-streets-and-steps-walk",
+ description: "Walk through ordinary streets, pedestrian ways, footways, and steps",
+ mode: "walk",
+ metric: "distance",
+ from: { nodeId: 25182927 },
+ to: { nodeId: 25181969 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 440, max: 480 },
+ requiredWayIds: [4227157, 1082312632, 4227155, 167014909, 165636031],
+ },
+ },
+ {
+ id: "monaco-oneway-forward",
+ description: "Drive with the tagged direction of Avenue des Papalins",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 25177418 },
+ to: { nodeId: 25177397 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 30, max: 40 },
+ timeSeconds: { min: 3, max: 5 },
+ requiredWayIds: [4224972],
+ },
+ },
+ {
+ id: "monaco-oneway-reverse",
+ description: "Reverse drive detours around Avenue des Papalins",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 25177397 },
+ to: { nodeId: 25177418 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 120, max: 130 },
+ timeSeconds: { min: 10, max: 13 },
+ requiredWayIds: [4229273, 503462459, 804900035, 4229900, 503462460, 4229274],
+ forbiddenWayIds: [4224972],
+ },
+ },
+ {
+ id: "monaco-reverse-oneway-legal",
+ description: "Drive against stored node order where oneway=-1 permits that direction",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 4437836938 },
+ to: { nodeId: 254470730 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 10, max: 12 },
+ requiredWayIds: [158215200],
+ },
+ },
+ {
+ id: "monaco-reverse-oneway-detour",
+ description: "Drive around the loop rather than forward against a oneway=-1 tag",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 254470730 },
+ to: { nodeId: 4437836938 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 40, max: 43 },
+ requiredWayIds: [158215200],
+ },
+ },
+ {
+ id: "monaco-implicit-roundabout-oneway",
+ description: "Drive in the legal direction around the Avenue Albert II roundabout",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 25204713 },
+ to: { nodeId: 25238111 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 60, max: 75 },
+ timeSeconds: { min: 2, max: 6 },
+ requiredWayIds: [4229900, 503462460, 503462476, 503462459, 804900035],
+ },
+ },
+ {
+ id: "monaco-motor-vehicle-access",
+ description: "Car access witness on motor_vehicle=no Impasse du Stade",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 254470916 },
+ to: { nodeId: 1704462513 },
+ expect: {},
+ policyLimitation: {
+ kind: "access",
+ reason: "Osmix's default vehicle filter currently checks highway class but not access tags.",
+ r5Expectation: "A normal car route must not traverse way 158215187 because motor_vehicle=no.",
+ witness: {
+ type: "way",
+ id: 158215187,
+ tags: { highway: "service", motor_vehicle: "no" },
+ },
+ },
+ },
+ {
+ id: "monaco-no-left-turn-restriction",
+ description: "Prohibited turn witness for restriction relation 4261963",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 1704462546 },
+ to: { nodeId: 1778433989 },
+ expect: {},
+ policyLimitation: {
+ kind: "turn-restriction",
+ reason: "Osmix's routing graph does not currently interpret restriction relations.",
+ r5Expectation:
+ "Do not transition directly from way 176527122 to way 166399477 through node 25177185.",
+ witness: {
+ type: "relation",
+ id: 4261963,
+ tags: { restriction: "no_left_turn", type: "restriction" },
+ },
+ },
+ },
+ {
+ id: "monaco-tunnel-layer-regression",
+ description: "Driving route that must not shortcut between nearby road levels",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 1866510534 },
+ to: { nodeId: 937988247 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 1_000, max: 1_100 },
+ timeSeconds: { min: 55, max: 70 },
+ },
+ },
+ {
+ id: "monaco-reachability-regression",
+ description: "Driving route that became disconnected after unsafe node deduplication",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 1875118274 },
+ to: { nodeId: 12281555152 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 150, max: 180 },
+ timeSeconds: { min: 8, max: 15 },
+ },
+ },
+ {
+ id: "outside-monaco",
+ description: "A point outside the extract cannot snap to its routing graph",
+ mode: "car",
+ metric: "distance",
+ from: { coordinates: [0, 0], maxSnapDistanceMeters: 50 },
+ to: { coordinates: [0.001, 0.001], maxSnapDistanceMeters: 50 },
+ expect: { reachable: false },
+ },
+] as const satisfies readonly RoutingTestCase[];
+
+export const SYNTHETIC_ROUTING_CASES = [
+ {
+ id: "synthetic-car-extension",
+ description: "Cars use the residential base road and its merged extension",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 1 },
+ to: { nodeId: 4 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 540, max: 570 },
+ requiredWayIds: [100, 101],
+ forbiddenWayIds: [102],
+ },
+ },
+ {
+ id: "synthetic-walk-shortcut",
+ description: "Walkers can use the foot-only direct connection",
+ mode: "walk",
+ metric: "distance",
+ from: { nodeId: 1 },
+ to: { nodeId: 4 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 330, max: 340 },
+ requiredWayIds: [102],
+ forbiddenWayIds: [100, 101],
+ },
+ },
+ {
+ id: "synthetic-oneway-forward",
+ description: "Driving follows a one-way road in its tagged direction",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 10 },
+ to: { nodeId: 12 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 210, max: 230 },
+ requiredWayIds: [110],
+ },
+ },
+ {
+ id: "synthetic-oneway-reverse",
+ description: "Driving cannot reverse along a one-way road",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 12 },
+ to: { nodeId: 10 },
+ expect: { reachable: false },
+ },
+ {
+ id: "synthetic-reverse-oneway-forward",
+ description: "Driving cannot follow the stored order of a reverse one-way road",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 13 },
+ to: { nodeId: 15 },
+ expect: { reachable: false },
+ },
+ {
+ id: "synthetic-reverse-oneway-reverse",
+ description: "Driving follows a reverse one-way road against its stored node order",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 15 },
+ to: { nodeId: 13 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 210, max: 230 },
+ requiredWayIds: [111],
+ },
+ },
+ {
+ id: "synthetic-grade-separation",
+ description: "A tunnel remains disconnected from the nearby surface road",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 21 },
+ to: { nodeId: 23 },
+ expect: { reachable: false },
+ },
+ {
+ id: "synthetic-same-grade-crossing",
+ description: "A same-grade crossing creates a routable connection",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 40 },
+ to: { nodeId: 51 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 325, max: 345 },
+ requiredWayIds: [130, 131],
+ },
+ },
+ {
+ id: "synthetic-reverse-multiple-intersections",
+ description: "Routing follows a reverse-ordered way after two intersections are inserted",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 61 },
+ to: { nodeId: 63 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 435, max: 455 },
+ requiredWayIds: [140, 141],
+ },
+ },
+ {
+ id: "synthetic-access-car",
+ description: "Cars detour around a residential way tagged motor_vehicle=no",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 70 },
+ to: { nodeId: 72 },
+ graphPolicy: "access-aware",
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 305, max: 325 },
+ requiredWayIds: [151],
+ forbiddenWayIds: [150],
+ },
+ },
+ {
+ id: "synthetic-access-walk",
+ description: "Walking may use a residential way explicitly designated for foot access",
+ mode: "walk",
+ metric: "distance",
+ from: { nodeId: 70 },
+ to: { nodeId: 72 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 215, max: 230 },
+ requiredWayIds: [150],
+ forbiddenWayIds: [151],
+ },
+ },
+] as const satisfies readonly RoutingTestCase[];
+
+/** The offset sidewalk inputs before fuzzy attachment remain separate WALK components. */
+export const SYNTHETIC_CONFLATION_DISCONNECTED_CASES = [
+ {
+ id: "synthetic-conflation-walk",
+ description: "The imported sidewalk is disconnected before fuzzy network attachment",
+ mode: "walk",
+ metric: "distance",
+ from: { nodeId: 801 },
+ to: { nodeId: 902 },
+ expect: { reachable: false },
+ },
+ {
+ id: "synthetic-conflation-car",
+ description: "Pedestrian-only source and target geometry is unavailable to cars",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 801 },
+ to: { nodeId: 902 },
+ expect: { reachable: false },
+ },
+] as const satisfies readonly RoutingTestCase[];
+
+/** The same sidewalk pair after accepting its high-confidence pedestrian attachment. */
+export const SYNTHETIC_CONFLATION_ATTACHED_CASES = [
+ {
+ id: "synthetic-conflation-walk",
+ description: "Fuzzy attachment joins the aligned imported and base sidewalks",
+ mode: "walk",
+ metric: "distance",
+ from: { nodeId: 801 },
+ to: { nodeId: 902 },
+ expect: {
+ reachable: true,
+ distanceMeters: { min: 215, max: 230 },
+ requiredWayIds: [810, 910],
+ },
+ },
+ {
+ id: "synthetic-conflation-car",
+ description: "A pedestrian attachment does not introduce a car route",
+ mode: "car",
+ metric: "distance",
+ from: { nodeId: 801 },
+ to: { nodeId: 902 },
+ expect: { reachable: false },
+ },
+] as const satisfies readonly RoutingTestCase[];
diff --git a/packages/osmix/test/merge-profile-cli.ts b/packages/osmix/test/merge-profile-cli.ts
new file mode 100644
index 00000000..f730ea50
--- /dev/null
+++ b/packages/osmix/test/merge-profile-cli.ts
@@ -0,0 +1,297 @@
+import { access, writeFile } from "node:fs/promises";
+import { arch, cpus, platform, totalmem } from "node:os";
+
+import { getFixtureFileReadStream, getFixturePath, PBFs } from "@osmix/test-utils/fixtures";
+
+import { fromPbf, toPbfBuffer, type Osm, type OsmMergeOptions } from "../src/index.ts";
+import {
+ measureMergeProfileTask,
+ osmEntityCounts,
+ profileMerge,
+ profileWorkerConflation,
+ type MergeProfileOperationCounts,
+ type MergeProfileRun,
+ type MergeProfileStage,
+} from "./merge-profile-harness.ts";
+import { createMonacoRoutingPatch } from "./synthetic-routing-fixture.ts";
+
+type MergeProfileScenario = "monaco" | "yakima" | "eastern-washington";
+
+interface ScenarioDefinition {
+ baseFixture: string;
+ patchFixture: string;
+ defaultRuns: number;
+ options: Partial;
+ workerConflation?: boolean;
+}
+
+interface MergeProfileReport {
+ schemaVersion: 1;
+ scenario: MergeProfileScenario;
+ fixtures: { base: string; patch: string };
+ mergeOptions: Partial;
+ startedAt: string;
+ runtime: {
+ node: string;
+ platform: string;
+ architecture: string;
+ cpu: string;
+ logicalCpus: number;
+ totalMemoryBytes: number;
+ commit?: string;
+ };
+ runs: MergeProfileRun[];
+ medianStageDurationMs: Record;
+ equivalence: {
+ identicalFingerprints: boolean;
+ identicalOperationCounts: boolean;
+ };
+}
+
+const ALL_MERGE_STEPS = {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ createIntersections: true,
+} as const;
+
+const SCENARIOS: Record = {
+ monaco: {
+ baseFixture: PBFs["monaco"]!.url,
+ patchFixture: "generated Monaco routing patch",
+ defaultRuns: 5,
+ options: ALL_MERGE_STEPS,
+ },
+ yakima: {
+ baseFixture: "yakima-full.osm.pbf",
+ patchFixture: "yakima.osw.pbf",
+ defaultRuns: 3,
+ options: {
+ ...ALL_MERGE_STEPS,
+ conflation: {
+ propertyKeys: ["barrier", "crossing", "kerb", "tactile_paving"],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence",
+ },
+ },
+ workerConflation: true,
+ },
+ "eastern-washington": {
+ baseFixture: "osmix-e_wa_osm.pbf",
+ patchFixture: "east_washington_sidewalk_proviso_1.pbf",
+ defaultRuns: 1,
+ options: ALL_MERGE_STEPS,
+ },
+};
+
+function usage(): string {
+ return `Usage: pnpm --filter osmix profile:merge -- [options]
+
+Options:
+ --scenario Fixture pair (default: monaco)
+ --runs Repetitions (defaults: 5/3/1)
+ --output Also write the JSON report to a file
+ --help Show this help
+
+The same values can be set with OSMIX_MERGE_PROFILE_SCENARIO,
+OSMIX_MERGE_PROFILE_RUNS, and OSMIX_MERGE_PROFILE_OUTPUT.`;
+}
+
+function optionValue(arguments_: string[], name: string): string | undefined {
+ const index = arguments_.indexOf(name);
+ if (index === -1) return undefined;
+ const value = arguments_[index + 1];
+ if (!value || value.startsWith("--")) throw Error(`${name} requires a value`);
+ return value;
+}
+
+function parseScenario(value: string | undefined): MergeProfileScenario {
+ if (value === undefined) return "monaco";
+ if (value === "monaco" || value === "yakima" || value === "eastern-washington") return value;
+ throw Error(`Unknown merge profile scenario: ${value}`);
+}
+
+function parseRuns(value: string | undefined, fallback: number): number {
+ if (value === undefined) return fallback;
+ const runs = Number(value);
+ if (!Number.isSafeInteger(runs) || runs < 1) throw Error(`Invalid run count: ${value}`);
+ return runs;
+}
+
+async function requireFixture(name: string): Promise {
+ const path = getFixturePath(name);
+ try {
+ await access(path);
+ } catch {
+ throw Error(`Required local fixture is missing: ${path}`);
+ }
+}
+
+async function loadInputs(
+ scenario: MergeProfileScenario,
+ definition: ScenarioDefinition,
+): Promise<{ base: Osm; patch: Osm; stages: MergeProfileStage[] }> {
+ await requireFixture(definition.baseFixture);
+ const baseProfile = await measureMergeProfileTask("load-base-pbf", async () => {
+ const base = await fromPbf(
+ getFixtureFileReadStream(definition.baseFixture),
+ {
+ id: definition.baseFixture,
+ },
+ () => undefined,
+ );
+ return { value: base, operations: { ...osmEntityCounts(base) } };
+ });
+ const base = baseProfile.value;
+ if (scenario === "monaco") {
+ const patchProfile = await measureMergeProfileTask("load-patch-pbf", async () => {
+ const patch = await fromPbf(
+ await toPbfBuffer(createMonacoRoutingPatch(base)),
+ {
+ id: "monaco-profile-patch",
+ },
+ () => undefined,
+ );
+ return { value: patch, operations: { ...osmEntityCounts(patch) } };
+ });
+ return {
+ base,
+ patch: patchProfile.value,
+ stages: [baseProfile.stage, patchProfile.stage],
+ };
+ }
+ await requireFixture(definition.patchFixture);
+ const patchProfile = await measureMergeProfileTask("load-patch-pbf", async () => {
+ const patch = await fromPbf(
+ getFixtureFileReadStream(definition.patchFixture),
+ {
+ id: definition.patchFixture,
+ },
+ () => undefined,
+ );
+ return { value: patch, operations: { ...osmEntityCounts(patch) } };
+ });
+ return { base, patch: patchProfile.value, stages: [baseProfile.stage, patchProfile.stage] };
+}
+
+function median(values: number[]): number {
+ const sorted = [...values].sort((left, right) => left - right);
+ const middle = Math.floor(sorted.length / 2);
+ const value =
+ sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!;
+ return Math.round(value * 1_000) / 1_000;
+}
+
+function medianStageDurations(runs: MergeProfileRun[]): Record {
+ const durations = new Map();
+ for (const run of runs) {
+ for (const stage of run.stages) {
+ const values = durations.get(stage.name) ?? [];
+ values.push(stage.durationMs);
+ durations.set(stage.name, values);
+ }
+ }
+ return Object.fromEntries([...durations].map(([name, values]) => [name, median(values)]));
+}
+
+function stableOperations(run: MergeProfileRun): Record {
+ return Object.fromEntries(run.stages.map((stage) => [stage.name, stage.operations]));
+}
+
+function assertEquivalentRuns(runs: MergeProfileRun[]) {
+ const expectedFingerprint = JSON.stringify(runs[0]!.fingerprints);
+ const expectedOperations = JSON.stringify(stableOperations(runs[0]!));
+ const identicalFingerprints = runs.every(
+ (run) => JSON.stringify(run.fingerprints) === expectedFingerprint,
+ );
+ const identicalOperationCounts = runs.every(
+ (run) => JSON.stringify(stableOperations(run)) === expectedOperations,
+ );
+ if (!identicalFingerprints || !identicalOperationCounts) {
+ throw Error(
+ `Profile runs were not deterministic (fingerprints: ${identicalFingerprints}, operations: ${identicalOperationCounts})`,
+ );
+ }
+ return { identicalFingerprints, identicalOperationCounts };
+}
+
+function silenceLibraryTimings(): () => void {
+ const time = console.time;
+ const timeEnd = console.timeEnd;
+ console.time = () => undefined;
+ console.timeEnd = () => undefined;
+ return () => {
+ console.time = time;
+ console.timeEnd = timeEnd;
+ };
+}
+
+async function main(): Promise {
+ const arguments_ = process.argv.slice(2);
+ if (arguments_.includes("--help")) {
+ process.stdout.write(`${usage()}\n`);
+ return;
+ }
+ const scenario = parseScenario(
+ optionValue(arguments_, "--scenario") ?? process.env["OSMIX_MERGE_PROFILE_SCENARIO"],
+ );
+ const definition = SCENARIOS[scenario];
+ const runCount = parseRuns(
+ optionValue(arguments_, "--runs") ?? process.env["OSMIX_MERGE_PROFILE_RUNS"],
+ definition.defaultRuns,
+ );
+ const output = optionValue(arguments_, "--output") ?? process.env["OSMIX_MERGE_PROFILE_OUTPUT"];
+ const startedAt = new Date().toISOString();
+ const runs: MergeProfileRun[] = [];
+
+ const restoreLibraryTimings = silenceLibraryTimings();
+ try {
+ for (let run = 1; run <= runCount; run++) {
+ globalThis.gc?.();
+ const { base, patch, stages } = await loadInputs(scenario, definition);
+ const profile = definition.workerConflation
+ ? await profileWorkerConflation(base, patch, definition.options, { run })
+ : await profileMerge(base, patch, definition.options, { run });
+ profile.stages.unshift(...stages);
+ profile.wallDurationMs =
+ Math.round(
+ (profile.wallDurationMs + stages.reduce((sum, stage) => sum + stage.durationMs, 0)) *
+ 1_000,
+ ) / 1_000;
+ profile.processPeakRssBytes = Math.max(
+ profile.processPeakRssBytes,
+ ...stages.map((stage) => stage.processPeakRssBytes),
+ );
+ runs.push(profile);
+ }
+ } finally {
+ restoreLibraryTimings();
+ }
+
+ const cpuList = cpus();
+ const report: MergeProfileReport = {
+ schemaVersion: 1,
+ scenario,
+ fixtures: { base: definition.baseFixture, patch: definition.patchFixture },
+ mergeOptions: definition.options,
+ startedAt,
+ runtime: {
+ node: process.version,
+ platform: platform(),
+ architecture: arch(),
+ cpu: cpuList[0]?.model ?? "unknown",
+ logicalCpus: cpuList.length,
+ totalMemoryBytes: totalmem(),
+ ...(process.env["GITHUB_SHA"] ? { commit: process.env["GITHUB_SHA"] } : {}),
+ },
+ runs,
+ medianStageDurationMs: medianStageDurations(runs),
+ equivalence: assertEquivalentRuns(runs),
+ };
+ const json = `${JSON.stringify(report, null, 2)}\n`;
+ if (output) await writeFile(output, json);
+ process.stdout.write(json);
+}
+
+await main();
diff --git a/packages/osmix/test/merge-profile-harness.ts b/packages/osmix/test/merge-profile-harness.ts
new file mode 100644
index 00000000..308cc8d0
--- /dev/null
+++ b/packages/osmix/test/merge-profile-harness.ts
@@ -0,0 +1,525 @@
+import { createHash } from "node:crypto";
+import { performance } from "node:perf_hooks";
+
+import {
+ discoverConflationCandidatesForTrustedMerge,
+ generateConflationApplicationArtifactsFromTrustedDiscovery,
+} from "@osmix/change/src/internal/conflation.ts";
+import type { Osm } from "@osmix/core";
+import type { OsmEntity } from "@osmix/types";
+
+import {
+ applyChangesetToOsm,
+ createOsmJsonReadableStream,
+ OsmBlocksToPbfBytesTransformStream,
+ OsmJsonToBlocksTransformStream,
+ OsmChangeset,
+ OsmixWorker,
+ type OsmConflationGenerationResult,
+ type OsmConflationSummary,
+ type OsmMergeOptions,
+ type OsmChangesetStats,
+} from "../src/index.ts";
+
+export type MergeProfileOperationCounts = Record;
+
+export interface MergeProfileStage {
+ name: string;
+ durationMs: number;
+ cpuUserMs: number;
+ cpuSystemMs: number;
+ rssBytesBefore: number;
+ rssBytesAfter: number;
+ heapUsedBytesBefore: number;
+ heapUsedBytesAfter: number;
+ /** Process-lifetime RSS high-water at stage completion, not a stage-local maximum. */
+ processPeakRssBytes: number;
+ operations: MergeProfileOperationCounts;
+}
+
+export interface MergeProfileFingerprints {
+ /** The built-in storage-level fingerprint, including typed-buffer ordering. */
+ contentHash: string;
+ /** A semantic fingerprint with sorted entities and object keys but ordered refs/members. */
+ canonicalSha256: string;
+ /** A PBF byte fingerprint after normalizing the serializer's current-time header. */
+ normalizedPbfSha256: string;
+ pbfBytes: number;
+}
+
+export interface MergeProfileRun {
+ run: number;
+ stages: MergeProfileStage[];
+ inputs: {
+ base: MergeProfileEntityCounts;
+ patch: MergeProfileEntityCounts;
+ };
+ output: MergeProfileEntityCounts;
+ fingerprints: MergeProfileFingerprints;
+ wallDurationMs: number;
+ /** Process-lifetime RSS high-water at run completion. */
+ processPeakRssBytes: number;
+}
+
+export interface MergeProfileEntityCounts {
+ nodes: number;
+ ways: number;
+ relations: number;
+}
+
+export interface ProfileMergeOptions {
+ /** A stable one-based run number included in reports. */
+ run?: number;
+ /** Include semantic and serialized fingerprints. Enabled by default. */
+ fingerprint?: boolean;
+}
+
+interface StageResult {
+ value: T;
+ operations?: MergeProfileOperationCounts;
+}
+
+interface MemorySnapshot {
+ rss: number;
+ heapUsed: number;
+ peakRss: number;
+}
+
+class ProfileOsmixWorker extends OsmixWorker {
+ register(osm: Osm): void {
+ this.set(osm.id, osm);
+ }
+
+ read(osmId: string): Osm {
+ return this.get(osmId);
+ }
+}
+
+function roundMilliseconds(value: number): number {
+ return Math.round(value * 1_000) / 1_000;
+}
+
+function memorySnapshot(): MemorySnapshot {
+ const memory = process.memoryUsage();
+ return {
+ rss: memory.rss,
+ heapUsed: memory.heapUsed,
+ // Node reports maxRSS in KiB on every supported platform.
+ peakRss: process.resourceUsage().maxRSS * 1_024,
+ };
+}
+
+class MergeProfileRecorder {
+ readonly stages: MergeProfileStage[] = [];
+
+ async measure(name: string, task: () => StageResult | Promise>): Promise {
+ const memoryBefore = memorySnapshot();
+ const cpuBefore = process.cpuUsage();
+ const started = performance.now();
+ const result = await task();
+ const durationMs = performance.now() - started;
+ const cpu = process.cpuUsage(cpuBefore);
+ const memoryAfter = memorySnapshot();
+
+ this.stages.push({
+ name,
+ durationMs: roundMilliseconds(durationMs),
+ cpuUserMs: roundMilliseconds(cpu.user / 1_000),
+ cpuSystemMs: roundMilliseconds(cpu.system / 1_000),
+ rssBytesBefore: memoryBefore.rss,
+ rssBytesAfter: memoryAfter.rss,
+ heapUsedBytesBefore: memoryBefore.heapUsed,
+ heapUsedBytesAfter: memoryAfter.heapUsed,
+ processPeakRssBytes: Math.max(memoryBefore.peakRss, memoryAfter.peakRss),
+ operations: result.operations ?? {},
+ });
+ return result.value;
+ }
+}
+
+/** Measure setup work, such as fixture loading, with the same stage schema. */
+export async function measureMergeProfileTask(
+ name: string,
+ task: () => StageResult | Promise>,
+): Promise<{ value: T; stage: MergeProfileStage }> {
+ const recorder = new MergeProfileRecorder();
+ const value = await recorder.measure(name, task);
+ return { value, stage: recorder.stages[0]! };
+}
+
+export function osmEntityCounts(osm: Osm): MergeProfileEntityCounts {
+ return {
+ nodes: osm.nodes.size,
+ ways: osm.ways.size,
+ relations: osm.relations.size,
+ };
+}
+
+function changesetCounts(stats: OsmChangesetStats): MergeProfileOperationCounts {
+ return {
+ totalChanges: stats.totalChanges,
+ nodeChanges: stats.nodeChanges,
+ wayChanges: stats.wayChanges,
+ relationChanges: stats.relationChanges,
+ deduplicatedNodes: stats.deduplicatedNodes,
+ deduplicatedNodesReplaced: stats.deduplicatedNodesReplaced,
+ deduplicatedWays: stats.deduplicatedWays,
+ intersectionPointsFound: stats.intersectionPointsFound,
+ intersectionNodesCreated: stats.intersectionNodesCreated,
+ };
+}
+
+function prefixedCounts(
+ prefix: string,
+ counts: MergeProfileEntityCounts | OsmConflationSummary,
+): MergeProfileOperationCounts {
+ return Object.fromEntries(
+ Object.entries(counts).map(([key, value]) => [
+ `${prefix}${key[0]!.toUpperCase()}${key.slice(1)}`,
+ value,
+ ]),
+ );
+}
+
+/**
+ * Serialize a JSON-compatible value with stable object-key order. Array order is
+ * intentionally retained because way refs and relation members are structural.
+ */
+function stableJson(value: unknown): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
+ const entries = Object.entries(value as Record)
+ .filter(([, entry]) => entry !== undefined)
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
+}
+
+/** Create a semantic digest independent of entity and tag insertion order. */
+export function canonicalOsmSha256(osm: Osm): string {
+ const hash = createHash("sha256");
+ const update = (type: string, entities: Iterable) => {
+ hash.update(`${type}\n`);
+ for (const entity of entities) hash.update(`${stableJson(entity)}\n`);
+ };
+ update("nodes", osm.nodes.sorted());
+ update("ways", osm.ways.sorted());
+ update("relations", osm.relations.sorted());
+ return hash.digest("hex");
+}
+
+async function* sortedEntities(osm: Osm): AsyncGenerator {
+ for (const node of osm.nodes.osmSorted()) yield node;
+ for (const way of osm.ways.osmSorted()) yield way;
+ for (const relation of osm.relations.osmSorted()) yield relation;
+}
+
+async function pbfFingerprint(osm: Osm): Promise<{ sha256: string; bytes: number }> {
+ const hash = createHash("sha256");
+ let bytes = 0;
+ // Production export records Date.now() in this header. Normalizing that one
+ // volatile field makes byte comparisons useful without changing serialization.
+ const stream = createOsmJsonReadableStream(
+ {
+ ...osm.header,
+ writingprogram: "@osmix/core",
+ osmosis_replication_timestamp: 1_700_000_000_000,
+ },
+ sortedEntities(osm),
+ )
+ .pipeThrough(new OsmJsonToBlocksTransformStream())
+ .pipeThrough(new OsmBlocksToPbfBytesTransformStream());
+ await stream.pipeTo(
+ new WritableStream({
+ write(chunk) {
+ hash.update(chunk);
+ bytes += chunk.byteLength;
+ },
+ }),
+ );
+ return { sha256: hash.digest("hex"), bytes };
+}
+
+async function collectFingerprints(
+ recorder: MergeProfileRecorder,
+ osm: Osm,
+ enabled: boolean,
+): Promise {
+ if (!enabled) {
+ return {
+ contentHash: osm.contentHash(),
+ canonicalSha256: "not-collected",
+ normalizedPbfSha256: "not-collected",
+ pbfBytes: 0,
+ };
+ }
+ const canonicalSha256 = await recorder.measure("fingerprint-canonical-entities", () => ({
+ value: canonicalOsmSha256(osm),
+ operations: prefixedCounts("entity", osmEntityCounts(osm)),
+ }));
+ const pbf = await recorder.measure("fingerprint-pbf-output", async () => {
+ const result = await pbfFingerprint(osm);
+ return { value: result, operations: { pbfBytes: result.bytes } };
+ });
+ return {
+ contentHash: osm.contentHash(),
+ canonicalSha256,
+ normalizedPbfSha256: pbf.sha256,
+ pbfBytes: pbf.bytes,
+ };
+}
+
+function routingDiagnosticCounts(
+ diagnostics: OsmConflationGenerationResult["routing"],
+): MergeProfileOperationCounts {
+ const counts: MergeProfileOperationCounts = {};
+ for (const mode of ["car", "walk"] as const) {
+ for (const view of ["before", "after", "delta"] as const) {
+ for (const [key, value] of Object.entries(diagnostics[mode][view])) {
+ counts[
+ `${mode}${view[0]!.toUpperCase()}${view.slice(1)}${key[0]!.toUpperCase()}${key.slice(1)}`
+ ] = value;
+ }
+ }
+ }
+ return counts;
+}
+
+/**
+ * Run the merge pipeline through its public changeset operations while recording
+ * each expensive boundary separately. This intentionally follows the same order
+ * as `merge`: ordinary direct/exact changes, optional conflation, then intersections.
+ */
+export async function profileMerge(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ profileOptions: ProfileMergeOptions = {},
+): Promise {
+ const recorder = new MergeProfileRecorder();
+ const wallStarted = performance.now();
+ let modifiedBase = base;
+
+ if (options.directMerge || options.deduplicateNodes || options.deduplicateWays) {
+ const changeset = await recorder.measure("prepare-direct-exact-changeset", () => ({
+ value: new OsmChangeset(base),
+ operations: prefixedCounts("base", osmEntityCounts(base)),
+ }));
+
+ if (options.directMerge) {
+ await recorder.measure("generate-direct-changes", () => {
+ changeset.generateDirectChanges(patch);
+ return { value: undefined, operations: changesetCounts(changeset.stats) };
+ });
+ }
+
+ if (options.deduplicateNodes) {
+ await recorder.measure("reconcile-exact-nodes", () => {
+ changeset.deduplicateNodes(patch.nodes);
+ return { value: undefined, operations: changesetCounts(changeset.stats) };
+ });
+ }
+
+ if (options.deduplicateWays) {
+ await recorder.measure("reconcile-exact-ways", () => {
+ let waysChecked = 0;
+ let waysReconciled = 0;
+ for (const reconciled of changeset.deduplicateWaysGenerator(patch.ways)) {
+ waysChecked++;
+ waysReconciled += reconciled;
+ }
+ return {
+ value: undefined,
+ operations: {
+ ...changesetCounts(changeset.stats),
+ waysChecked,
+ waysReconciled,
+ },
+ };
+ });
+ }
+
+ modifiedBase = await recorder.measure("apply-direct-exact-changes", () => {
+ const result = applyChangesetToOsm(changeset);
+ return {
+ value: result,
+ operations: {
+ ...changesetCounts(changeset.stats),
+ ...prefixedCounts("output", osmEntityCounts(result)),
+ },
+ };
+ });
+ }
+
+ if (options.conflation) {
+ if (!options.directMerge) {
+ throw Error("Fuzzy conflation requires directMerge to preserve unmatched patch entities");
+ }
+ const discovery = await recorder.measure("discover-conflation-candidates", () => {
+ const result = discoverConflationCandidatesForTrustedMerge(base, patch, options.conflation!);
+ return {
+ value: result,
+ operations: prefixedCounts("candidate", result.summary),
+ };
+ });
+ const conflation = await recorder.measure("generate-conflation-changes", () => {
+ const result = generateConflationApplicationArtifactsFromTrustedDiscovery(
+ modifiedBase,
+ patch,
+ discovery,
+ base,
+ options.conflation?.decisions ?? [],
+ );
+ return { value: result, operations: changesetCounts(result.changeset.stats) };
+ });
+ modifiedBase = await recorder.measure("apply-conflation-changes", () => {
+ // Production installs the exact result already materialized and validated
+ // during generation. Keep this boundary visible without doing the work twice.
+ const result = conflation.result;
+ return {
+ value: result,
+ operations: {
+ ...changesetCounts(conflation.changeset.stats),
+ ...prefixedCounts("output", osmEntityCounts(result)),
+ },
+ };
+ });
+ }
+
+ if (options.createIntersections) {
+ const changeset = await recorder.measure("prepare-intersection-changeset", () => ({
+ value: new OsmChangeset(modifiedBase),
+ operations: prefixedCounts("base", osmEntityCounts(modifiedBase)),
+ }));
+ await recorder.measure("create-safe-intersections", () => {
+ let waysChecked = 0;
+ for (const _result of changeset.createIntersectionsForWaysGenerator(patch.ways)) {
+ waysChecked++;
+ }
+ return {
+ value: undefined,
+ operations: { ...changesetCounts(changeset.stats), waysChecked },
+ };
+ });
+ modifiedBase = await recorder.measure("apply-intersection-changes", () => {
+ const result = applyChangesetToOsm(changeset);
+ return {
+ value: result,
+ operations: {
+ ...changesetCounts(changeset.stats),
+ ...prefixedCounts("output", osmEntityCounts(result)),
+ },
+ };
+ });
+ }
+
+ const fingerprints = await collectFingerprints(
+ recorder,
+ modifiedBase,
+ profileOptions.fingerprint ?? true,
+ );
+
+ return {
+ run: profileOptions.run ?? 1,
+ stages: recorder.stages,
+ inputs: { base: osmEntityCounts(base), patch: osmEntityCounts(patch) },
+ output: osmEntityCounts(modifiedBase),
+ fingerprints,
+ wallDurationMs: roundMilliseconds(performance.now() - wallStarted),
+ processPeakRssBytes: memorySnapshot().peakRss,
+ };
+}
+
+/**
+ * Profile the production worker conflation path, including routing diagnostics,
+ * the automatic-attachment CAR projection, and installation of the materialized result.
+ */
+export async function profileWorkerConflation(
+ base: Osm,
+ patch: Osm,
+ options: Partial,
+ profileOptions: ProfileMergeOptions = {},
+): Promise {
+ if (!options.conflation) throw Error("Worker conflation profiling requires conflation options");
+ if (!options.directMerge) throw Error("Worker conflation profiling requires directMerge");
+ const recorder = new MergeProfileRecorder();
+ const wallStarted = performance.now();
+ const worker = new ProfileOsmixWorker();
+ await recorder.measure("register-worker-inputs", () => {
+ worker.register(base);
+ worker.register(patch);
+ return {
+ value: undefined,
+ operations: {
+ ...prefixedCounts("base", osmEntityCounts(base)),
+ ...prefixedCounts("patch", osmEntityCounts(patch)),
+ },
+ };
+ });
+
+ await recorder.measure("worker-discover-conflation-candidates", () => {
+ const summary = worker.discoverConflation(base.id, patch.id, options.conflation!);
+ return { value: undefined, operations: prefixedCounts("candidate", summary) };
+ });
+ const generation = await recorder.measure("worker-generate-conflation-changeset", () => {
+ const result = worker.generateConflationChangeset(base.id, {
+ directMerge: true,
+ deduplicateNodes: options.deduplicateNodes ?? false,
+ deduplicateWays: options.deduplicateWays ?? false,
+ createIntersections: false,
+ });
+ return {
+ value: result,
+ operations: {
+ ...changesetCounts(result.stats),
+ ...routingDiagnosticCounts(result.routing),
+ },
+ };
+ });
+ await recorder.measure("worker-apply-conflation-result", () => {
+ worker.applyChangesAndReplace(base.id);
+ const result = worker.read(base.id);
+ return {
+ value: undefined,
+ operations: {
+ ...changesetCounts(generation.stats),
+ ...prefixedCounts("output", osmEntityCounts(result)),
+ },
+ };
+ });
+
+ if (options.createIntersections) {
+ const stats = await recorder.measure("worker-create-safe-intersections", async () => {
+ const result = await worker.generateChangeset(base.id, patch.id, {
+ createIntersections: true,
+ });
+ return { value: result, operations: changesetCounts(result) };
+ });
+ await recorder.measure("worker-apply-intersection-changes", () => {
+ worker.applyChangesAndReplace(base.id);
+ const result = worker.read(base.id);
+ return {
+ value: undefined,
+ operations: {
+ ...changesetCounts(stats),
+ ...prefixedCounts("output", osmEntityCounts(result)),
+ },
+ };
+ });
+ }
+
+ const output = worker.read(base.id);
+ const fingerprints = await collectFingerprints(
+ recorder,
+ output,
+ profileOptions.fingerprint ?? true,
+ );
+ return {
+ run: profileOptions.run ?? 1,
+ stages: recorder.stages,
+ inputs: { base: osmEntityCounts(base), patch: osmEntityCounts(patch) },
+ output: osmEntityCounts(output),
+ fingerprints,
+ wallDurationMs: roundMilliseconds(performance.now() - wallStarted),
+ processPeakRssBytes: memorySnapshot().peakRss,
+ };
+}
diff --git a/packages/osmix/test/merge-profile.test.ts b/packages/osmix/test/merge-profile.test.ts
new file mode 100644
index 00000000..7420a7ca
--- /dev/null
+++ b/packages/osmix/test/merge-profile.test.ts
@@ -0,0 +1,202 @@
+import { getFixtureFileReadStream, PBFs } from "@osmix/test-utils/fixtures";
+import { describe, expect, it } from "vitest";
+
+import { fromPbf, merge, Osm, toPbfBuffer } from "../src/index.ts";
+import {
+ canonicalOsmSha256,
+ profileMerge,
+ profileWorkerConflation,
+} from "./merge-profile-harness.ts";
+import {
+ createMonacoRoutingPatch,
+ createSyntheticConflationRoutingInputs,
+ createSyntheticRoutingBase,
+ createSyntheticRoutingPatch,
+ roundTripRoutingOsm,
+} from "./synthetic-routing-fixture.ts";
+
+const ALL_MERGE_STEPS = {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ createIntersections: true,
+} as const;
+
+function complete(osm: Osm): Osm {
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ return osm;
+}
+
+describe("merge performance harness", () => {
+ it("uses a semantic fingerprint that ignores insertion and object-key order", () => {
+ const first = new Osm({ id: "first" });
+ first.nodes.addNode({ id: 2, lon: 1, lat: 1, tags: { name: "Two", source: "survey" } });
+ first.nodes.addNode({ id: 1, lon: 0, lat: 0 });
+ first.ways.addWay({ id: 10, refs: [1, 2], tags: { name: "Way", highway: "footway" } });
+
+ const second = new Osm({ id: "second" });
+ second.nodes.addNode({ id: 1, lat: 0, lon: 0 });
+ second.nodes.addNode({ id: 2, lat: 1, lon: 1, tags: { source: "survey", name: "Two" } });
+ second.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "footway", name: "Way" } });
+
+ expect(canonicalOsmSha256(complete(first))).toBe(canonicalOsmSha256(complete(second)));
+ const reversed = new Osm({ id: "reversed" });
+ reversed.nodes.addNode({ id: 1, lon: 0, lat: 0 });
+ reversed.nodes.addNode({ id: 2, lon: 1, lat: 1, tags: { name: "Two", source: "survey" } });
+ reversed.ways.addWay({ id: 10, refs: [2, 1], tags: { highway: "footway", name: "Way" } });
+ expect(canonicalOsmSha256(complete(reversed))).not.toBe(canonicalOsmSha256(first));
+ });
+
+ it("profiles the same ordered full merge as the public pipeline", async () => {
+ const [base, patch] = await Promise.all([
+ roundTripRoutingOsm(createSyntheticRoutingBase(), "profile-synthetic-base"),
+ roundTripRoutingOsm(createSyntheticRoutingPatch(), "profile-synthetic-patch"),
+ ]);
+ const report = await profileMerge(base, patch, ALL_MERGE_STEPS);
+ const publicResult = await merge(base, patch, ALL_MERGE_STEPS, () => undefined);
+
+ expect(report.stages.map(({ name }) => name)).toEqual([
+ "prepare-direct-exact-changeset",
+ "generate-direct-changes",
+ "reconcile-exact-nodes",
+ "reconcile-exact-ways",
+ "apply-direct-exact-changes",
+ "prepare-intersection-changeset",
+ "create-safe-intersections",
+ "apply-intersection-changes",
+ "fingerprint-canonical-entities",
+ "fingerprint-pbf-output",
+ ]);
+ expect(report.output).toEqual({ nodes: 33, ways: 14, relations: 1 });
+ expect(report.fingerprints.contentHash).toBe(publicResult.contentHash());
+ expect(report.fingerprints.canonicalSha256).toBe(canonicalOsmSha256(publicResult));
+ expect(
+ report.stages.find(({ name }) => name === "reconcile-exact-nodes")?.operations,
+ ).toMatchObject({ deduplicatedNodes: 1, deduplicatedNodesReplaced: 2 });
+ expect(
+ report.stages.find(({ name }) => name === "reconcile-exact-ways")?.operations,
+ ).toMatchObject({ waysChecked: 7, waysReconciled: 0 });
+ expect(
+ report.stages.find(({ name }) => name === "create-safe-intersections")?.operations,
+ ).toMatchObject({
+ waysChecked: 7,
+ intersectionPointsFound: 3,
+ intersectionNodesCreated: 3,
+ });
+ });
+
+ it("profiles worker conflation generation with routing safety diagnostics", async () => {
+ const { base, patch } = createSyntheticConflationRoutingInputs();
+ const report = await profileWorkerConflation(base, patch, {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ createIntersections: false,
+ conflation: {
+ propertyKeys: ["name"],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence",
+ },
+ });
+
+ expect(report.stages.map(({ name }) => name)).toEqual([
+ "register-worker-inputs",
+ "worker-discover-conflation-candidates",
+ "worker-generate-conflation-changeset",
+ "worker-apply-conflation-result",
+ "fingerprint-canonical-entities",
+ "fingerprint-pbf-output",
+ ]);
+ expect(report.output).toEqual({ nodes: 82, ways: 80, relations: 0 });
+ expect(
+ report.stages.find(({ name }) => name === "worker-discover-conflation-candidates")
+ ?.operations,
+ ).toMatchObject({
+ candidateTotal: 81,
+ candidateAutomatic: 1,
+ candidateReview: 0,
+ candidateBlocked: 0,
+ candidateUnmatched: 80,
+ });
+ const generation = report.stages.find(
+ ({ name }) => name === "worker-generate-conflation-changeset",
+ )?.operations;
+ expect(generation).toMatchObject({
+ totalChanges: 82,
+ nodeChanges: 42,
+ wayChanges: 40,
+ carDeltaRoutableNodes: 0,
+ carDeltaEdges: 0,
+ carDeltaComponents: 0,
+ walkDeltaRoutableNodes: -1,
+ walkDeltaComponents: -1,
+ });
+ });
+
+ it("profiles high-level conflation with the production discovery reuse path", async () => {
+ const { base, patch } = createSyntheticConflationRoutingInputs();
+ const options = {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ createIntersections: false,
+ conflation: {
+ propertyKeys: ["name"],
+ attachNetwork: true,
+ maxDistanceMeters: 1,
+ automatic: "high-confidence" as const,
+ },
+ };
+ const report = await profileMerge(base, patch, options);
+ const publicResult = await merge(base, patch, options, () => undefined);
+
+ expect(report.stages.map(({ name }) => name)).toEqual([
+ "prepare-direct-exact-changeset",
+ "generate-direct-changes",
+ "reconcile-exact-nodes",
+ "reconcile-exact-ways",
+ "apply-direct-exact-changes",
+ "discover-conflation-candidates",
+ "generate-conflation-changes",
+ "apply-conflation-changes",
+ "fingerprint-canonical-entities",
+ "fingerprint-pbf-output",
+ ]);
+ expect(report.fingerprints.contentHash).toBe(publicResult.contentHash());
+ expect(report.fingerprints.canonicalSha256).toBe(canonicalOsmSha256(publicResult));
+ });
+
+ it("locks Monaco full-merge operations and output fingerprints", async () => {
+ const fixture = PBFs["monaco"]!;
+ const base = await fromPbf(getFixtureFileReadStream(fixture.url), { id: "profile-monaco" });
+ const patch = await fromPbf(await toPbfBuffer(createMonacoRoutingPatch(base)), {
+ id: "profile-monaco-patch",
+ });
+ const report = await profileMerge(base, patch, ALL_MERGE_STEPS);
+
+ expect(report.inputs).toEqual({
+ base: { nodes: 14_286, ways: 3_346, relations: 46 },
+ patch: { nodes: 2, ways: 1, relations: 0 },
+ });
+ expect(report.output).toEqual({ nodes: 14_287, ways: 3_347, relations: 46 });
+ expect(
+ report.stages.find(({ name }) => name === "reconcile-exact-nodes")?.operations,
+ ).toMatchObject({ deduplicatedNodes: 1, deduplicatedNodesReplaced: 1 });
+ expect(
+ report.stages.find(({ name }) => name === "reconcile-exact-ways")?.operations,
+ ).toMatchObject({ waysChecked: 1, waysReconciled: 0 });
+ expect(
+ report.stages.find(({ name }) => name === "create-safe-intersections")?.operations,
+ ).toMatchObject({ waysChecked: 1 });
+ expect(report.fingerprints).toMatchObject({
+ contentHash: "c941a5b8",
+ canonicalSha256: "4f47037cf117c361dfc36113a7734eebd37b0f4a9e4d84861dc0ca5e3527ea5d",
+ });
+ // The compressed byte stream can vary with Node's zlib version. Reports keep
+ // that useful same-runtime fingerprint, while CI locks semantic output above.
+ expect(report.fingerprints.normalizedPbfSha256).toMatch(/^[a-f\d]{64}$/);
+ expect(report.fingerprints.pbfBytes).toBeGreaterThan(0);
+ }, 30_000);
+});
diff --git a/packages/osmix/test/merge.test.ts b/packages/osmix/test/merge.test.ts
index bcc3eb31..20c01cc6 100644
--- a/packages/osmix/test/merge.test.ts
+++ b/packages/osmix/test/merge.test.ts
@@ -85,20 +85,20 @@ describe("merge osm", () => {
changeset = new OsmChangeset(baseOsm);
changeset.createIntersectionsForWays(osm2.ways);
- // Pending intersection nodes are resolved from the changeset when a way is spliced
- // again. This keeps every ref aligned with real geometry instead of aliasing a node
- // from the base index, and can expose additional legitimate intersections.
+ // Intersections are grouped by their containing segment and inserted in geometric
+ // order. Endpoint reuse that would create duplicate or degenerate refs falls back
+ // to a dedicated exact intersection node.
expect(changeset.stats).toEqual({
osmId: baseOsm.id,
- totalChanges: 9_461,
- nodeChanges: 5_824,
- wayChanges: 3_637,
+ totalChanges: 9_508,
+ nodeChanges: 5_869,
+ wayChanges: 3_639,
relationChanges: 0,
deduplicatedNodes: 0,
deduplicatedNodesReplaced: 0,
deduplicatedWays: 0,
- intersectionPointsFound: 3_187,
- intersectionNodesCreated: 2_623,
+ intersectionPointsFound: 3_105,
+ intersectionNodesCreated: 2_609,
});
baseOsm = applyChangesetToOsm(changeset);
@@ -122,7 +122,10 @@ describe("merge osm", () => {
},
});
},
- 30_000,
+ // This optional integration fixture loads and indexes nearly one million
+ // entities before creating intersections. Keep enough headroom for a full
+ // workspace run where other Vitest projects compete for CPU and memory.
+ 120_000,
);
it.skip("should merge seattle with deduplication", async () => {
diff --git a/packages/osmix/test/r5/R5RoutingOracle.java b/packages/osmix/test/r5/R5RoutingOracle.java
new file mode 100644
index 00000000..f5f03eef
--- /dev/null
+++ b/packages/osmix/test/r5/R5RoutingOracle.java
@@ -0,0 +1,221 @@
+import com.conveyal.r5.profile.StreetMode;
+import com.conveyal.r5.streets.StreetRouter;
+import com.conveyal.r5.streets.VertexStore;
+import com.conveyal.r5.transit.TransportNetwork;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Local-only point-to-point R5 oracle for Osmix's Monaco routing manifest.
+ *
+ * This source is compiled against an adjacent R5 checkout by r5-oracle.init.gradle. It is not
+ * part of either product's runtime or CI dependency graph.
+ */
+public final class R5RoutingOracle {
+
+ private record RouteCase(
+ String id,
+ StreetMode mode,
+ double fromLon,
+ double fromLat,
+ double toLon,
+ double toLat,
+ boolean exactOrigin,
+ boolean exactDestination
+ ) {}
+
+ private record VertexResolution(int index, String method) {}
+
+ private record RouteResult(
+ boolean originLinked,
+ boolean destinationLinked,
+ boolean reachable,
+ double distanceMeters,
+ int durationSeconds
+ ) {}
+
+ private R5RoutingOracle() {}
+
+ public static void main(String[] args) throws Exception {
+ if (args.length < 4 || args.length % 2 != 0) {
+ throw new IllegalArgumentException(
+ "Usage: R5RoutingOracle "
+ + " [ ...]"
+ );
+ }
+
+ Path manifest = Path.of(args[0]);
+ Path outputDirectory = Path.of(args[1]);
+ Files.createDirectories(outputDirectory);
+ List routeCases = readCases(manifest);
+
+ for (int argument = 2; argument < args.length; argument += 2) {
+ String datasetId = args[argument];
+ Path pbf = Path.of(args[argument + 1]).toAbsolutePath();
+ runDataset(datasetId, pbf, routeCases, outputDirectory);
+ }
+ }
+
+ private static List readCases(Path manifest) throws IOException {
+ List cases = new ArrayList<>();
+ List lines = Files.readAllLines(manifest);
+ for (int lineNumber = 1; lineNumber < lines.size(); lineNumber += 1) {
+ String line = lines.get(lineNumber);
+ if (line.isBlank()) continue;
+ String[] cells = line.split("\t", -1);
+ if (cells.length < 10) {
+ throw new IllegalArgumentException(
+ "Malformed routing manifest line " + (lineNumber + 1) + ": " + line
+ );
+ }
+ cases.add(new RouteCase(
+ cells[0],
+ StreetMode.valueOf(cells[1]),
+ Double.parseDouble(cells[2]),
+ Double.parseDouble(cells[3]),
+ Double.parseDouble(cells[4]),
+ Double.parseDouble(cells[5]),
+ cells[6].equals("osm-node"),
+ cells[8].equals("osm-node")
+ ));
+ }
+ return cases;
+ }
+
+ private static void runDataset(
+ String datasetId,
+ Path pbf,
+ List routeCases,
+ Path outputDirectory
+ ) throws Exception {
+ TransportNetwork network = TransportNetwork.fromFiles(pbf.toString(), List.of());
+ Path output = outputDirectory.resolve("r5-" + datasetId + ".tsv");
+ try (BufferedWriter writer = Files.newBufferedWriter(output)) {
+ writer.write("case_id\tmode\torigin_vertex_resolution\tdestination_vertex_resolution"
+ + "\tcoordinate_origin_linked\tcoordinate_destination_linked"
+ + "\tcoordinate_reachable\tcoordinate_distance_m\tcoordinate_duration_s"
+ + "\texact_vertex_reachable\texact_vertex_distance_m"
+ + "\texact_vertex_duration_s\n");
+ for (RouteCase routeCase : routeCases) {
+ VertexResolution origin = resolveVertex(
+ network,
+ routeCase.fromLat,
+ routeCase.fromLon,
+ routeCase.exactOrigin
+ );
+ VertexResolution destination = resolveVertex(
+ network,
+ routeCase.toLat,
+ routeCase.toLon,
+ routeCase.exactDestination
+ );
+ RouteResult coordinateResult = routeCoordinates(network, routeCase);
+ RouteResult exactResult = origin.index >= 0 && destination.index >= 0
+ ? routeVertices(network, routeCase.mode, origin.index, destination.index)
+ : null;
+
+ writer.write(String.join("\t",
+ routeCase.id,
+ routeCase.mode.name(),
+ origin.method,
+ destination.method,
+ Boolean.toString(coordinateResult.originLinked),
+ Boolean.toString(coordinateResult.destinationLinked),
+ Boolean.toString(coordinateResult.reachable),
+ coordinateResult.reachable
+ ? Double.toString(coordinateResult.distanceMeters)
+ : "",
+ coordinateResult.reachable
+ ? Integer.toString(coordinateResult.durationSeconds)
+ : "",
+ exactResult == null ? "" : Boolean.toString(exactResult.reachable),
+ exactResult == null || !exactResult.reachable
+ ? ""
+ : Double.toString(exactResult.distanceMeters),
+ exactResult == null || !exactResult.reachable
+ ? ""
+ : Integer.toString(exactResult.durationSeconds)
+ ));
+ writer.newLine();
+ }
+ }
+ System.out.println("Wrote " + output);
+ }
+
+ private static RouteResult routeCoordinates(TransportNetwork network, RouteCase routeCase) {
+ StreetRouter router = new StreetRouter(network.streetLayer);
+ router.streetMode = routeCase.mode;
+ boolean originLinked = router.setOrigin(routeCase.fromLat, routeCase.fromLon);
+ boolean destinationLinked = router.setDestination(routeCase.toLat, routeCase.toLon);
+ StreetRouter.State state = null;
+ if (originLinked && destinationLinked) {
+ router.route();
+ state = router.getState(router.getDestinationSplit());
+ }
+ return result(originLinked, destinationLinked, state);
+ }
+
+ private static RouteResult routeVertices(
+ TransportNetwork network,
+ StreetMode mode,
+ int originVertex,
+ int destinationVertex
+ ) {
+ StreetRouter router = new StreetRouter(network.streetLayer);
+ router.streetMode = mode;
+ router.setOrigin(originVertex);
+ router.toVertex = destinationVertex;
+ router.route();
+ return result(true, true, router.getStateAtVertex(destinationVertex));
+ }
+
+ private static RouteResult result(
+ boolean originLinked,
+ boolean destinationLinked,
+ StreetRouter.State state
+ ) {
+ return new RouteResult(
+ originLinked,
+ destinationLinked,
+ state != null,
+ state == null ? Double.NaN : state.distance / 1_000d,
+ state == null ? -1 : state.getDurationSeconds()
+ );
+ }
+
+ /**
+ * Resolve OSM-node cases to an exact R5 street vertex whenever R5 retained that node as a
+ * topological vertex. Intermediate shape nodes fall back to normal coordinate-to-edge linking.
+ */
+ private static VertexResolution resolveVertex(
+ TransportNetwork network,
+ double lat,
+ double lon,
+ boolean exact
+ ) {
+ if (!exact) return new VertexResolution(-1, "coordinate");
+
+ VertexStore vertices = network.streetLayer.vertexStore;
+ int fixedLat = VertexStore.floatingDegreesToFixed(lat);
+ int fixedLon = VertexStore.floatingDegreesToFixed(lon);
+ int match = -1;
+ int matches = 0;
+ for (int index = 0; index < vertices.getVertexCount(); index += 1) {
+ boolean sameCoordinate =
+ vertices.fixedLats.get(index) == fixedLat
+ && vertices.fixedLons.get(index) == fixedLon;
+ if (sameCoordinate) {
+ match = index;
+ matches += 1;
+ }
+ }
+ if (matches == 1) return new VertexResolution(match, "exact-osm-node");
+ if (matches == 0) return new VertexResolution(-1, "coordinate-fallback-no-vertex");
+ return new VertexResolution(-1, "coordinate-fallback-ambiguous-vertex");
+ }
+}
diff --git a/packages/osmix/test/r5/README.md b/packages/osmix/test/r5/README.md
new file mode 100644
index 00000000..e34d98c8
--- /dev/null
+++ b/packages/osmix/test/r5/README.md
@@ -0,0 +1,157 @@
+# Local R5 routing oracle
+
+R5 is the authority for Conveyal street-mode legality. This local-only runner sends the exact
+Monaco endpoints used by the Osmix regression suite through `TransportNetwork.fromFiles`,
+`StreetRouter`, and `StreetMode.CAR` or `StreetMode.WALK`. It does not add R5 to Osmix's CI or
+package dependency graph.
+
+The checked-in Osmix expectations cover topology, route shape, broad measurements, and
+Dijkstra/A* agreement. Two cases are intentionally policy diagnostics rather than absolute
+Osmix goldens:
+
+- `monaco-motor-vehicle-access`: R5 must not drive on `motor_vehicle=no` way `158215187`.
+- `monaco-no-left-turn-restriction`: R5 must honor `no_left_turn` relation `4261963`.
+
+The implicit-roundabout and reverse-oneway cases are absolute goldens: Osmix and R5 both take the
+legal direction implied by `junction=roundabout` and `oneway=-1`.
+
+The test-only Osmix WALK graph changes highway eligibility and speeds, but the generic
+`RoutingGraph` still applies way-level `oneway` and roundabout direction. No accepted Monaco walk
+case depends on that limitation; R5 WALK results remain authoritative for modal legality.
+
+## 1. Export the inputs and matrix from Osmix
+
+Run from the Osmix repository. Use a fresh temporary directory because R5 creates MapDB sidecar
+files beside each input PBF.
+
+```sh
+OSMIX_DIR="$PWD"
+ORACLE_DIR="$(mktemp -d /tmp/osmix-r5-oracle.XXXXXX)"
+OSMIX_ROUTING_ORACLE_DIR="$ORACLE_DIR" \
+ pnpm -w exec vitest run --project osmix \
+ packages/osmix/test/routing-after-merge.test.ts
+```
+
+This opt-in command writes:
+
+- `routing-cases.tsv`: modes, OSM node IDs, and exact snapped coordinates for R5.
+- Raw, empty-merged, synthetic-patched, and reloaded Monaco PBFs: exact oracle inputs.
+- `oracle-matrix.json`: current Osmix raw, merged, and PBF-reloaded reports.
+- Per-dataset JSON and GeoJSON diagnostics for visual review.
+- `synthetic/`: generated merged and PBF-reloaded synthetic networks and reports.
+- `conflation-property/`: ordinary, property-only, and property-only-reloaded PBFs. Their
+ disconnected WALK topology must remain identical.
+- `conflation-attachment/`: attached and attached-reloaded pedestrian PBFs. Both must expose the
+ newly connected WALK route.
+
+Normal tests never write these files and no command auto-updates checked-in expectations.
+
+## 2. Run the same matrix through a local R5 checkout
+
+Run from the R5 repository. The init script adds only a temporary source set and task. Supplying a
+temporary build directory keeps generated R5 build files out of the adjacent checkout.
+
+```sh
+R5_ORACLE_BUILD="$(mktemp -d /tmp/osmix-r5-build.XXXXXX)"
+gradle --no-daemon --init-script \
+ "$OSMIX_DIR/packages/osmix/test/r5/r5-oracle.init.gradle" \
+ -PosmixOracleBuildDir="$R5_ORACLE_BUILD" \
+ -PosmixOracleSourceDir="$OSMIX_DIR/packages/osmix/test/r5" \
+ -PosmixOracleManifest="$ORACLE_DIR/routing-cases.tsv" \
+ -PosmixOracleOutputDir="$ORACLE_DIR" \
+ runOsmixRoutingOracle
+```
+
+Use `--offline` when the R5 Gradle dependencies are already cached. The runner produces
+one TSV per Monaco dataset in `ORACLE_DIR`.
+
+The init script accepts a comma-separated `osmixOracleDatasets` override for the generated
+conflation variants. Run the property-only matrix and attachment matrix separately so each uses its
+matching endpoint expectations:
+
+```sh
+gradle --offline --no-daemon --init-script \
+ "$OSMIX_DIR/packages/osmix/test/r5/r5-oracle.init.gradle" \
+ -PosmixOracleBuildDir="$R5_ORACLE_BUILD" \
+ -PosmixOracleSourceDir="$OSMIX_DIR/packages/osmix/test/r5" \
+ -PosmixOracleManifest="$ORACLE_DIR/conflation-property/routing-cases.tsv" \
+ -PosmixOracleOutputDir="$ORACLE_DIR/conflation-property" \
+ -PosmixOracleDatasets=synthetic-conflation-ordinary,synthetic-conflation-property,synthetic-conflation-property-roundtrip \
+ runOsmixRoutingOracle
+
+gradle --offline --no-daemon --init-script \
+ "$OSMIX_DIR/packages/osmix/test/r5/r5-oracle.init.gradle" \
+ -PosmixOracleBuildDir="$R5_ORACLE_BUILD" \
+ -PosmixOracleSourceDir="$OSMIX_DIR/packages/osmix/test/r5" \
+ -PosmixOracleManifest="$ORACLE_DIR/conflation-attachment/routing-cases.tsv" \
+ -PosmixOracleOutputDir="$ORACLE_DIR/conflation-attachment" \
+ -PosmixOracleDatasets=synthetic-conflation-attachment,synthetic-conflation-attachment-roundtrip \
+ runOsmixRoutingOracle
+```
+
+The primary result columns use normal R5 coordinate-to-edge linking. For node-ID cases, the runner
+also reports an exact-vertex result when both OSM nodes survive as unambiguous R5 topological
+vertices. R5 collapses intermediate shape nodes, so the endpoint-resolution columns record
+`coordinate-fallback-no-vertex` when no exact vertex exists. Exact-vertex results are left blank
+rather than guessed when either endpoint is missing or more than one R5 vertex has that coordinate.
+
+## 3. Review the oracle matrix
+
+For absolute-golden cases, raw, merged, patched, and reloaded R5 reachability and measurements
+should agree; any difference indicates a merge or serialization defect. R5 and Osmix measurements
+can differ because their snapping, speed, and policy models are different, so compare reachability,
+direction, and plausible bounded metrics rather than exact equality.
+
+For the two policy diagnostics, inspect the expectation in the last column of
+`routing-cases.tsv`. A reachable result alone is insufficient: compare its distance with the
+Osmix route in `oracle-matrix.json` and inspect the matching GeoJSON when necessary to confirm R5
+used the legal detour. Do not promote current Osmix behavior for these cases into a golden unless
+the missing policy is implemented.
+
+## Observed Monaco matrix
+
+The runner was verified on 2026-07-21 with a local R5 checkout at commit
+`ac95649c7094bf394b3be43fa523d0fb4447633e` (with unrelated existing local changes). All five raw,
+empty-merged, synthetic-patched, and reloaded TSV outputs were byte-for-byte identical. These
+values document that run; they are not a CI golden because R5 remains a local oracle.
+
+| Case | Mode | Coordinate distance | Duration | Exact-vertex result |
+| ----------------------------------- | ---- | ------------------: | -------: | ------------------- |
+| `monaco-short-drive` | CAR | 254.162 m | 19 s | n/a |
+| `monaco-short-walk` | WALK | 254.162 m | 196 s | n/a |
+| `monaco-cross-town-drive` | CAR | 5,690.595 m | 1,166 s | n/a |
+| `monaco-streets-and-steps-walk` | WALK | 459.287 m | 359 s | n/a |
+| `monaco-oneway-forward` | CAR | 48.672 m | 46 s | 34.200 m / 4 s |
+| `monaco-oneway-reverse` | CAR | 737.016 m | 204 s | 124.167 m / 103 s |
+| `monaco-reverse-oneway-legal` | CAR | 12.456 m | 99 s | n/a |
+| `monaco-reverse-oneway-detour` | CAR | 23.473 m | 108 s | n/a |
+| `monaco-implicit-roundabout-oneway` | CAR | 73.007 m | 123 s | n/a |
+| `monaco-motor-vehicle-access` | CAR | 215.600 m | 50 s | unreachable |
+| `monaco-no-left-turn-restriction` | CAR | 490.496 m | 277 s | 288.450 m / 134 s |
+| `monaco-tunnel-layer-regression` | CAR | 21.771 m | 106 s | n/a |
+| `monaco-reachability-regression` | CAR | 7.148 m | 95 s | n/a |
+
+The last two cases deliberately assert Osmix node-to-node topology. R5 collapses one endpoint in
+each case into an intermediate shape point, then normal coordinate linking can snap to a different
+nearby level. Their R5 distance is therefore not compared with the Osmix node-ID golden; raw versus
+merged equality remains the valid R5 check for those witnesses.
+
+## Observed synthetic conflation matrix
+
+The same 2026-07-21 R5 checkout was also used for the explicit 1-meter conflation variants. The
+ordinary fixture contains two aligned footway components whose endpoints are about 0.56 meters
+apart. Property transfer changes only a selected `name`; network attachment rewrites the first
+imported way reference to the preserved base endpoint.
+
+| Variant | WALK reachable | Coordinate result | Exact-vertex result |
+| ----------------------------------- | -------------- | ----------------: | ------------------: |
+| ordinary direct merge | no | n/a | n/a |
+| property transfer | no | n/a | n/a |
+| property transfer after PBF reload | no | n/a | n/a |
+| network attachment | yes | 222.244 m / 237 s | 222.245 m / 240 s |
+| network attachment after PBF reload | yes | 222.244 m / 237 s | 222.245 m / 240 s |
+
+The three property-side TSVs were byte-identical, as were the two attachment-side TSVs. Osmix
+separately asserts that the attachment changes WALK from two components to one while its CAR graph
+is unchanged. `osmium check-refs` reported zero missing way-node references for the generated and
+reloaded conflation PBFs.
diff --git a/packages/osmix/test/r5/r5-oracle.init.gradle b/packages/osmix/test/r5/r5-oracle.init.gradle
new file mode 100644
index 00000000..ec397c4d
--- /dev/null
+++ b/packages/osmix/test/r5/r5-oracle.init.gradle
@@ -0,0 +1,58 @@
+gradle.beforeProject { project ->
+ def oracleBuildDirectory = gradle.startParameter.projectProperties['osmixOracleBuildDir']
+ if (oracleBuildDirectory != null) {
+ project.layout.buildDirectory.set(new File(oracleBuildDirectory, project.name))
+ }
+}
+
+gradle.afterProject { project, state ->
+ if (project != project.rootProject || state.failure != null) return
+
+ def requiredProperty = { String name ->
+ def value = project.findProperty(name)
+ if (value == null || value.toString().isBlank()) {
+ throw new GradleException("Missing required -P${name}=... property")
+ }
+ return value.toString()
+ }
+
+ def oracleSourceDirectory = requiredProperty('osmixOracleSourceDir')
+ def oracleManifest = requiredProperty('osmixOracleManifest')
+ def oracleOutputDirectory = requiredProperty('osmixOracleOutputDir')
+ def defaultOracleDatasets = [
+ 'monaco-raw',
+ 'monaco-empty-merge',
+ 'monaco-empty-merge-roundtrip',
+ 'monaco-synthetic-patch',
+ 'monaco-synthetic-patch-roundtrip'
+ ]
+ def configuredDatasets = project.findProperty('osmixOracleDatasets')
+ def oracleDatasets = configuredDatasets == null
+ ? defaultOracleDatasets
+ : configuredDatasets.toString().split(',')
+ .collect { it.trim() }
+ .findAll { !it.isBlank() }
+ if (oracleDatasets.isEmpty()) {
+ throw new GradleException('The -PosmixOracleDatasets list must not be empty')
+ }
+
+ def oracleSourceSet = project.sourceSets.create('osmixRoutingOracle') {
+ java.srcDir(oracleSourceDirectory)
+ compileClasspath += project.sourceSets.main.output + project.configurations.runtimeClasspath
+ runtimeClasspath += output + compileClasspath
+ }
+
+ project.tasks.named(oracleSourceSet.compileJavaTaskName) {
+ dependsOn(project.tasks.named('classes'))
+ }
+
+ project.tasks.register('runOsmixRoutingOracle', JavaExec) {
+ dependsOn(project.tasks.named(oracleSourceSet.classesTaskName))
+ classpath = oracleSourceSet.runtimeClasspath
+ mainClass = 'R5RoutingOracle'
+ args(oracleManifest, oracleOutputDirectory)
+ oracleDatasets.each { datasetId ->
+ args(datasetId, new File(oracleOutputDirectory, "${datasetId}.osm.pbf").absolutePath)
+ }
+ }
+}
diff --git a/packages/osmix/test/remote.test.ts b/packages/osmix/test/remote.test.ts
index 0368b6df..d0c73f2e 100644
--- a/packages/osmix/test/remote.test.ts
+++ b/packages/osmix/test/remote.test.ts
@@ -10,6 +10,27 @@ const occupiedMonacoTile: [number, number, number] = [17059, 11948, 15];
// Increase timeout for worker tests
const workerTestTimeout = 30_000;
+function createParallelFootway(
+ id: string,
+ nodeId: number,
+ wayId: number,
+ lat: number,
+ name: string,
+) {
+ const osm = new Osm({ id });
+ osm.nodes.addNode({ id: nodeId, lon: 0, lat });
+ osm.nodes.addNode({ id: nodeId + 1, lon: 0.001, lat });
+ osm.nodes.buildIndex();
+ osm.ways.addWay({
+ id: wayId,
+ refs: [nodeId, nodeId + 1],
+ tags: { highway: "footway", name },
+ });
+ osm.buildIndexes();
+ osm.buildSpatialIndexes();
+ return osm;
+}
+
class RecoveryTestRemote extends OsmixRemote {
private readonly customSources = new Map();
@@ -312,6 +333,125 @@ describe("OsmixRemote", () => {
expect(addProgressListener).toHaveBeenCalledOnce();
});
+
+ it("restores conflation discovery, review decisions, filters, and generated changes", async () => {
+ using remote = new RecoveryTestRemote();
+ await remote.initializeWorkerPool(1, undefined, undefined, true);
+ const base = createParallelFootway("recovery-base", 1, 10, 0, "Base path");
+ const patch = createParallelFootway("recovery-patch", 11, 20, 0.000004, "Imported path");
+ await remote.transferIn(base);
+ await remote.transferIn(patch);
+ await remote.discoverConflation(base.id, patch.id, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+ const wayCandidate = (await remote.getConflationPage(base.id, 0, 100)).candidates.find(
+ (candidate) => candidate.entityType === "way",
+ );
+ if (!wayCandidate) throw Error("Expected a way conflation candidate");
+ const bulkResult = await remote.applyConflationBulkDecision(base.id, {
+ action: "reject",
+ filter: { entityType: "way" },
+ });
+ expect(bulkResult.decisions).toContainEqual({
+ candidateId: wayCandidate.id,
+ action: "reject",
+ });
+ await expect(
+ remote.setConflationDecision(base.id, {
+ candidateId: wayCandidate.id,
+ action: "invalid",
+ } as never),
+ ).rejects.toThrow(`Invalid conflation decision action for ${wayCandidate.id}`);
+ await remote.setConflationFilter(base.id, { status: "rejected" });
+ await remote.generateConflationChangeset(base.id, {
+ directMerge: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ });
+ const unchanged = await remote.applyConflationBulkDecision(base.id, {
+ action: "reject",
+ filter: { entityType: "way" },
+ });
+ expect(unchanged.preview.changedCandidates).toBe(0);
+
+ await remote.getWorker().clearConflation(base.id);
+ await remote.restoreForTest();
+
+ const restoredPage = await remote.getConflationPage(base.id, 0, 100);
+ expect(restoredPage.totalCandidates).toBe(1);
+ expect(restoredPage.candidates[0]?.decision).toEqual({
+ candidateId: wayCandidate.id,
+ action: "reject",
+ });
+ expect((await remote.getChangesetPage(base.id, 0, 100)).changes?.length).toBeGreaterThan(0);
+ });
+
+ it("does not replay conflation state after a loader replaces an input ID", async () => {
+ using remote = new RecoveryTestRemote();
+ await remote.initializeWorkerPool(1, undefined, undefined, true);
+ const base = createParallelFootway("loader-base", 1, 10, 0, "Base path");
+ const patch = createParallelFootway("loader-patch", 11, 20, 0.000004, "Imported path");
+ await remote.transferIn(base);
+ await remote.transferIn(patch);
+ await remote.discoverConflation(base.id, patch.id, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+ const candidate = (await remote.getConflationPage(base.id, 0, 100)).candidates.find(
+ (row) => row.entityType === "way",
+ )!;
+ await remote.setConflationDecision(base.id, {
+ candidateId: candidate.id,
+ action: "reject",
+ });
+
+ const replacement: FeatureCollection = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [1, 1] },
+ properties: { name: "Replacement" },
+ },
+ ],
+ };
+ await remote.fromGeoJSON(new TextEncoder().encode(JSON.stringify(replacement)), {
+ id: patch.id,
+ });
+
+ await expect(remote.restoreForTest()).resolves.toBeUndefined();
+ await expect(remote.getConflationSummary(base.id)).rejects.toThrow(
+ "No active conflation session",
+ );
+ });
+
+ it("invalidates sessions for both sides of an overwriting rename", async () => {
+ using remote = new RecoveryTestRemote();
+ await remote.initializeWorkerPool(1, undefined, undefined, true);
+ const from = createParallelFootway("rename-from", 1, 10, 0, "From");
+ const fromPatch = createParallelFootway("rename-from-patch", 11, 20, 0.000004, "Patch");
+ const otherBase = createParallelFootway("rename-other-base", 21, 30, 0, "Other");
+ const to = createParallelFootway("rename-to", 31, 40, 0.000004, "Destination");
+ for (const osm of [from, fromPatch, otherBase, to]) await remote.transferIn(osm);
+ await remote.discoverConflation(from.id, fromPatch.id, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+ await remote.discoverConflation(otherBase.id, to.id, {
+ propertyKeys: ["name"],
+ attachNetwork: false,
+ });
+
+ await remote.rename(from.id, to.id);
+ await expect(remote.restoreForTest()).resolves.toBeUndefined();
+ await expect(remote.getConflationSummary(from.id)).rejects.toThrow(
+ "No active conflation session",
+ );
+ await expect(remote.getConflationSummary(otherBase.id)).rejects.toThrow(
+ "No active conflation session",
+ );
+ });
});
describe("partial state broadcasts", () => {
diff --git a/packages/osmix/test/routing-after-merge.test.ts b/packages/osmix/test/routing-after-merge.test.ts
new file mode 100644
index 00000000..7527a0b5
--- /dev/null
+++ b/packages/osmix/test/routing-after-merge.test.ts
@@ -0,0 +1,473 @@
+import { createHash } from "node:crypto";
+import { join } from "node:path";
+
+import { getFixtureFile, PBFs } from "@osmix/test-utils/fixtures";
+import { beforeAll, describe, expect, it } from "vitest";
+
+import { fromPbf, merge, Osm, type OsmEntity, toPbfBuffer } from "../src/index.ts";
+import {
+ MONACO_ROUTING_CASES,
+ SYNTHETIC_CONFLATION_ATTACHED_CASES,
+ SYNTHETIC_CONFLATION_DISCONNECTED_CASES,
+ SYNTHETIC_ROUTING_CASES,
+ type RoutingTestCase,
+} from "./fixtures/routing-cases.ts";
+import {
+ type RoutingCaseReport,
+ RoutingTestHarness,
+ stableRoutingReport,
+ writeR5OracleArtifacts,
+ writeRoutingDiagnostics,
+} from "./routing-harness.ts";
+import {
+ createMonacoRoutingPatch,
+ createMergedSyntheticRoutingOsm,
+ createSyntheticConflationRoutingVariants,
+ roundTripRoutingOsm,
+} from "./synthetic-routing-fixture.ts";
+
+const ALL_MERGE_STEPS = {
+ createIntersections: true,
+ deduplicateNodes: true,
+ deduplicateWays: true,
+ directMerge: true,
+} as const;
+
+function canonicalOsmDigest(osm: Osm): string {
+ const hash = createHash("sha256");
+ const updateEntities = (type: string, entities: Iterable): void => {
+ hash.update(type);
+ for (const entity of entities) hash.update(JSON.stringify(entity));
+ };
+ updateEntities("nodes", osm.nodes.sorted());
+ updateEntities("ways", osm.ways.sorted());
+ updateEntities("relations", osm.relations.sorted());
+ return hash.digest("hex");
+}
+
+function topologyFingerprint(osm: Osm): string {
+ return JSON.stringify({
+ nodes: [...osm.nodes.sorted()].map(({ id, lat, lon }) => ({ id, lat, lon })),
+ ways: [...osm.ways.sorted()].map(({ id, refs }) => ({ id, refs })),
+ relations: [...osm.relations.sorted()].map(({ id, members }) => ({ id, members })),
+ });
+}
+
+function expectReportToMatchCase(report: RoutingCaseReport, testCase: RoutingTestCase): void {
+ expect(report.caseId).toBe(testCase.id);
+ expect(report.graphPolicy).toBe(testCase.graphPolicy ?? "osmix-default");
+ expect
+ .soft(
+ report.algorithmAgreement,
+ `${testCase.id}: Dijkstra and A* disagree (${JSON.stringify(report.algorithmCosts)})`,
+ )
+ .toBe(true);
+
+ if (testCase.policyLimitation) {
+ expect(report.from, `${testCase.id}: policy-witness origin did not resolve`).not.toBeNull();
+ expect(report.to, `${testCase.id}: policy-witness destination did not resolve`).not.toBeNull();
+ return;
+ }
+
+ if (testCase.expect.reachable === undefined) {
+ throw new Error(`${testCase.id}: non-policy cases must declare reachability`);
+ }
+ expect(report.reachable).toBe(testCase.expect.reachable);
+
+ if (!testCase.expect.reachable) {
+ expect(report.path).toBeNull();
+ return;
+ }
+
+ expect(report.from, `${testCase.id}: origin did not resolve`).not.toBeNull();
+ expect(report.to, `${testCase.id}: destination did not resolve`).not.toBeNull();
+ expect(report.path, `${testCase.id}: expected a route`).not.toBeNull();
+ if (!report.path) return;
+
+ const { distanceMeters, timeSeconds, wayIds } = report.path;
+ const distance = testCase.expect.distanceMeters;
+ if (distance) {
+ expect(distanceMeters).toBeGreaterThanOrEqual(distance.min);
+ expect(distanceMeters).toBeLessThanOrEqual(distance.max);
+ }
+ const time = testCase.expect.timeSeconds;
+ if (time) {
+ expect(timeSeconds).toBeGreaterThanOrEqual(time.min);
+ expect(timeSeconds).toBeLessThanOrEqual(time.max);
+ }
+ for (const wayId of testCase.expect.requiredWayIds ?? []) expect(wayIds).toContain(wayId);
+ for (const wayId of testCase.expect.forbiddenWayIds ?? []) {
+ expect(wayIds).not.toContain(wayId);
+ }
+}
+
+function expectReportsToMatchCases(
+ reports: readonly RoutingCaseReport[],
+ testCases: readonly RoutingTestCase[],
+): void {
+ expect(reports).toHaveLength(testCases.length);
+ for (const [index, testCase] of testCases.entries()) {
+ expectReportToMatchCase(reports[index]!, testCase);
+ }
+}
+
+function stableReports(
+ reports: readonly RoutingCaseReport[],
+ testCases: readonly RoutingTestCase[],
+) {
+ return reports.map((report, index) => {
+ const testCase = testCases[index]!;
+ if (!testCase.policyLimitation) return stableRoutingReport(report);
+ return {
+ caseId: report.caseId,
+ mode: report.mode,
+ graphPolicy: report.graphPolicy,
+ metric: report.metric,
+ graph: report.graph,
+ fromNodeId: report.from?.nodeId ?? null,
+ toNodeId: report.to?.nodeId ?? null,
+ algorithmAgreement: report.algorithmAgreement,
+ policyLimitation: report.policyLimitation,
+ };
+ });
+}
+
+function stableRouteBehavior(
+ reports: readonly RoutingCaseReport[],
+ testCases: readonly RoutingTestCase[],
+) {
+ return stableReports(reports, testCases).map(({ graph: _graph, ...report }) => report);
+}
+
+function expectPolicyWitnesses(osm: Osm, testCases: readonly RoutingTestCase[]): void {
+ for (const testCase of testCases) {
+ const witness = testCase.policyLimitation?.witness;
+ if (!witness) continue;
+ const entity =
+ witness.type === "way" ? osm.ways.getById(witness.id) : osm.relations.getById(witness.id);
+ expect(
+ entity,
+ `${testCase.id}: policy witness ${witness.type} ${witness.id} is missing`,
+ ).not.toBeNull();
+ expect(entity?.tags).toMatchObject(witness.tags);
+ }
+}
+
+describe("routing after a Monaco merge", () => {
+ let raw: Osm;
+ let merged: Osm;
+ let roundTripped: Osm;
+ let patched: Osm;
+ let patchedRoundTripped: Osm;
+ let rawReports: RoutingCaseReport[];
+ let mergedReports: RoutingCaseReport[];
+ let roundTripReports: RoutingCaseReport[];
+ let patchedReports: RoutingCaseReport[];
+ let patchedRoundTripReports: RoutingCaseReport[];
+
+ beforeAll(async () => {
+ raw = await fromPbf(await getFixtureFile(PBFs["monaco"]!.url), { id: "monaco-raw" });
+ const emptyPatch = new Osm({ id: "empty-patch" });
+ emptyPatch.buildIndexes();
+ emptyPatch.buildSpatialIndexes();
+ merged = await merge(raw, emptyPatch, ALL_MERGE_STEPS, () => undefined);
+ roundTripped = await roundTripRoutingOsm(merged, "monaco-merged-roundtrip");
+ const syntheticPatch = await roundTripRoutingOsm(createMonacoRoutingPatch(raw));
+ patched = await merge(raw, syntheticPatch, ALL_MERGE_STEPS, () => undefined);
+ patchedRoundTripped = await roundTripRoutingOsm(patched, "monaco-synthetic-patch-roundtrip");
+
+ rawReports = new RoutingTestHarness(raw).runAll(MONACO_ROUTING_CASES);
+ mergedReports = new RoutingTestHarness(merged).runAll(MONACO_ROUTING_CASES);
+ roundTripReports = new RoutingTestHarness(roundTripped).runAll(MONACO_ROUTING_CASES);
+ patchedReports = new RoutingTestHarness(patched).runAll(MONACO_ROUTING_CASES);
+ patchedRoundTripReports = new RoutingTestHarness(patchedRoundTripped).runAll(
+ MONACO_ROUTING_CASES,
+ );
+
+ const diagnosticsDirectory = process.env["OSMIX_ROUTING_DIAGNOSTICS_DIR"];
+ if (diagnosticsDirectory) await writeRoutingDiagnostics(rawReports, diagnosticsDirectory);
+ const r5OracleDirectory = process.env["OSMIX_ROUTING_ORACLE_DIR"];
+ if (r5OracleDirectory) {
+ await writeR5OracleArtifacts(
+ [
+ { id: "monaco-raw", osm: raw, reports: rawReports },
+ { id: "monaco-empty-merge", osm: merged, reports: mergedReports },
+ {
+ id: "monaco-empty-merge-roundtrip",
+ osm: roundTripped,
+ reports: roundTripReports,
+ },
+ { id: "monaco-synthetic-patch", osm: patched, reports: patchedReports },
+ {
+ id: "monaco-synthetic-patch-roundtrip",
+ osm: patchedRoundTripped,
+ reports: patchedRoundTripReports,
+ },
+ ],
+ MONACO_ROUTING_CASES,
+ r5OracleDirectory,
+ );
+ }
+ }, 30_000);
+
+ it("keeps an empty all-steps merge as a canonical identity operation", () => {
+ expect({
+ nodes: merged.nodes.size,
+ ways: merged.ways.size,
+ relations: merged.relations.size,
+ }).toEqual({ nodes: 14_286, ways: 3_346, relations: 46 });
+ expect(canonicalOsmDigest(merged)).toBe(canonicalOsmDigest(raw));
+ });
+
+ it("preserves driving and walking routes after the empty merge", () => {
+ expect(rawReports.find((report) => report.caseId === "monaco-short-drive")?.graph).toEqual({
+ nodes: 14_286,
+ edges: 10_831,
+ weakComponents: 6,
+ });
+ expect(rawReports.find((report) => report.caseId === "monaco-short-walk")?.graph).toEqual({
+ nodes: 14_286,
+ edges: 25_750,
+ weakComponents: 27,
+ });
+ expectReportsToMatchCases(rawReports, MONACO_ROUTING_CASES);
+ expectReportsToMatchCases(mergedReports, MONACO_ROUTING_CASES);
+ expectPolicyWitnesses(raw, MONACO_ROUTING_CASES);
+ expectPolicyWitnesses(merged, MONACO_ROUTING_CASES);
+ expect(stableReports(mergedReports, MONACO_ROUTING_CASES)).toEqual(
+ stableReports(rawReports, MONACO_ROUTING_CASES),
+ );
+ });
+
+ it("preserves stable routing topology through PBF serialization", () => {
+ expect(canonicalOsmDigest(roundTripped)).toBe(canonicalOsmDigest(merged));
+ expectReportsToMatchCases(roundTripReports, MONACO_ROUTING_CASES);
+ expectPolicyWitnesses(roundTripped, MONACO_ROUTING_CASES);
+ expect(stableReports(roundTripReports, MONACO_ROUTING_CASES)).toEqual(
+ stableReports(mergedReports, MONACO_ROUTING_CASES),
+ );
+ });
+
+ it("preserves Monaco routes after a real, PBF-decoded synthetic patch", () => {
+ expect({
+ nodes: patched.nodes.size,
+ ways: patched.ways.size,
+ relations: patched.relations.size,
+ }).toEqual({ nodes: 14_287, ways: 3_347, relations: 46 });
+ expect(patched.nodes.getById(13_000_000_001)).toBeNull();
+ expect(patched.ways.getById(3_000_000_001)?.refs).toEqual([7779445520, 13_000_000_002]);
+ expectReportsToMatchCases(patchedReports, MONACO_ROUTING_CASES);
+ expect(stableRouteBehavior(patchedReports, MONACO_ROUTING_CASES)).toEqual(
+ stableRouteBehavior(rawReports, MONACO_ROUTING_CASES),
+ );
+
+ expect(canonicalOsmDigest(patchedRoundTripped)).toBe(canonicalOsmDigest(patched));
+ expectReportsToMatchCases(patchedRoundTripReports, MONACO_ROUTING_CASES);
+ expect(stableReports(patchedRoundTripReports, MONACO_ROUTING_CASES)).toEqual(
+ stableReports(patchedReports, MONACO_ROUTING_CASES),
+ );
+ });
+});
+
+describe("routing on a synthetic merged network", () => {
+ let merged: Osm;
+ let roundTripped: Osm;
+ let mergedReports: RoutingCaseReport[];
+ let roundTripReports: RoutingCaseReport[];
+
+ beforeAll(async () => {
+ merged = await createMergedSyntheticRoutingOsm();
+ roundTripped = await fromPbf(await toPbfBuffer(merged), {
+ id: "synthetic-merged-roundtrip",
+ });
+ mergedReports = new RoutingTestHarness(merged).runAll(SYNTHETIC_ROUTING_CASES);
+ roundTripReports = new RoutingTestHarness(roundTripped).runAll(SYNTHETIC_ROUTING_CASES);
+ const r5OracleDirectory = process.env["OSMIX_ROUTING_ORACLE_DIR"];
+ if (r5OracleDirectory) {
+ await writeR5OracleArtifacts(
+ [
+ { id: "synthetic-merged", osm: merged, reports: mergedReports },
+ {
+ id: "synthetic-merged-roundtrip",
+ osm: roundTripped,
+ reports: roundTripReports,
+ },
+ ],
+ SYNTHETIC_ROUTING_CASES,
+ join(r5OracleDirectory, "synthetic"),
+ );
+ }
+ });
+
+ it("keeps mode-specific paths, one-way direction, and grade separation correct", () => {
+ expect(merged.nodes.getById(30)).toBeNull();
+ expect(merged.ways.getById(101)?.refs).toEqual([3, 4]);
+ expect(merged.relations.getById(200)?.members).toEqual([
+ { type: "way", ref: 100, role: "from" },
+ { type: "node", ref: 3, role: "via" },
+ { type: "way", ref: 101, role: "to" },
+ ]);
+ expect(merged.nodes.getById(21)).not.toBeNull();
+ expect(merged.nodes.getById(23)).not.toBeNull();
+ expect(merged.ways.getById(150)?.tags).toMatchObject({
+ foot: "designated",
+ highway: "residential",
+ motor_vehicle: "no",
+ });
+ const way130 = merged.ways.getById(130)!;
+ const way131 = merged.ways.getById(131)!;
+ expect(way130.refs.map((ref) => merged.nodes.getNodeLonLat({ id: ref })?.[0])).toEqual([
+ 0, 0.002, 0.004,
+ ]);
+ expect(way130.refs.filter((ref) => way131.refs.includes(ref))).toHaveLength(1);
+
+ const reverseWay = merged.ways.getById(140)!;
+ expect(reverseWay.refs.map((ref) => merged.nodes.getNodeLonLat({ id: ref })?.[0])).toEqual([
+ 0.004, 0.003, 0.001, 0,
+ ]);
+ expectReportsToMatchCases(mergedReports, SYNTHETIC_ROUTING_CASES);
+ });
+
+ it("keeps synthetic route behavior stable through a PBF round trip", () => {
+ expectReportsToMatchCases(roundTripReports, SYNTHETIC_ROUTING_CASES);
+ expect(stableReports(roundTripReports, SYNTHETIC_ROUTING_CASES)).toEqual(
+ stableReports(mergedReports, SYNTHETIC_ROUTING_CASES),
+ );
+ });
+});
+
+describe("routing after explicit fuzzy conflation", () => {
+ let ordinary: Osm;
+ let propertyTransfer: Osm;
+ let networkAttachment: Osm;
+ let propertyRoundTrip: Osm;
+ let attachmentRoundTrip: Osm;
+ let ordinaryReports: RoutingCaseReport[];
+ let propertyReports: RoutingCaseReport[];
+ let attachmentReports: RoutingCaseReport[];
+ let propertyRoundTripReports: RoutingCaseReport[];
+ let attachmentRoundTripReports: RoutingCaseReport[];
+
+ beforeAll(async () => {
+ ({ ordinary, propertyTransfer, networkAttachment } =
+ await createSyntheticConflationRoutingVariants());
+ [propertyRoundTrip, attachmentRoundTrip] = await Promise.all([
+ roundTripRoutingOsm(propertyTransfer, "synthetic-conflation-property-roundtrip"),
+ roundTripRoutingOsm(networkAttachment, "synthetic-conflation-attachment-roundtrip"),
+ ]);
+
+ ordinaryReports = new RoutingTestHarness(ordinary).runAll(
+ SYNTHETIC_CONFLATION_DISCONNECTED_CASES,
+ );
+ propertyReports = new RoutingTestHarness(propertyTransfer).runAll(
+ SYNTHETIC_CONFLATION_DISCONNECTED_CASES,
+ );
+ attachmentReports = new RoutingTestHarness(networkAttachment).runAll(
+ SYNTHETIC_CONFLATION_ATTACHED_CASES,
+ );
+ propertyRoundTripReports = new RoutingTestHarness(propertyRoundTrip).runAll(
+ SYNTHETIC_CONFLATION_DISCONNECTED_CASES,
+ );
+ attachmentRoundTripReports = new RoutingTestHarness(attachmentRoundTrip).runAll(
+ SYNTHETIC_CONFLATION_ATTACHED_CASES,
+ );
+
+ const r5OracleDirectory = process.env["OSMIX_ROUTING_ORACLE_DIR"];
+ if (r5OracleDirectory) {
+ await Promise.all([
+ writeR5OracleArtifacts(
+ [
+ { id: "synthetic-conflation-ordinary", osm: ordinary, reports: ordinaryReports },
+ {
+ id: "synthetic-conflation-property",
+ osm: propertyTransfer,
+ reports: propertyReports,
+ },
+ {
+ id: "synthetic-conflation-property-roundtrip",
+ osm: propertyRoundTrip,
+ reports: propertyRoundTripReports,
+ },
+ ],
+ SYNTHETIC_CONFLATION_DISCONNECTED_CASES,
+ join(r5OracleDirectory, "conflation-property"),
+ ),
+ writeR5OracleArtifacts(
+ [
+ {
+ id: "synthetic-conflation-attachment",
+ osm: networkAttachment,
+ reports: attachmentReports,
+ },
+ {
+ id: "synthetic-conflation-attachment-roundtrip",
+ osm: attachmentRoundTrip,
+ reports: attachmentRoundTripReports,
+ },
+ ],
+ SYNTHETIC_CONFLATION_ATTACHED_CASES,
+ join(r5OracleDirectory, "conflation-attachment"),
+ ),
+ ]);
+ }
+ });
+
+ it("leaves property-only topology and routing unchanged", () => {
+ expect(propertyTransfer.nodes.getById(802)?.tags?.["name"]).toBe("Imported endpoint");
+ expect(propertyTransfer.nodes.getById(901)?.tags).toMatchObject({
+ name: "Imported endpoint",
+ source: "synthetic survey",
+ });
+ expect(propertyTransfer.ways.getById(810)?.refs.at(0)).toBe(801);
+ expect(propertyTransfer.ways.getById(849)?.refs.at(-1)).toBe(802);
+ expect(propertyTransfer.ways.getById(910)?.refs.at(0)).toBe(901);
+ expect(propertyTransfer.ways.getById(949)?.refs.at(-1)).toBe(902);
+ expect(topologyFingerprint(propertyTransfer)).toBe(topologyFingerprint(ordinary));
+ expectReportsToMatchCases(ordinaryReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES);
+ expectReportsToMatchCases(propertyReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES);
+ expect(stableReports(propertyReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES)).toEqual(
+ stableReports(ordinaryReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES),
+ );
+ });
+
+ it("attaches the WALK network while preserving the CAR graph", () => {
+ expect(networkAttachment.nodes.getById(802)).toMatchObject({ lon: 0, lat: 0 });
+ expect(networkAttachment.ways.getById(810)?.refs.at(0)).toBe(801);
+ expect(networkAttachment.ways.getById(849)?.refs.at(-1)).toBe(802);
+ expect(networkAttachment.ways.getById(910)?.refs.at(0)).toBe(802);
+ expect(networkAttachment.ways.getById(949)?.refs.at(-1)).toBe(902);
+ expect(networkAttachment.nodes.getById(901)).not.toBeNull();
+ expectReportsToMatchCases(attachmentReports, SYNTHETIC_CONFLATION_ATTACHED_CASES);
+
+ const ordinaryCar = ordinaryReports.find(
+ (report) => report.caseId === "synthetic-conflation-car",
+ );
+ const attachedCar = attachmentReports.find(
+ (report) => report.caseId === "synthetic-conflation-car",
+ );
+ expect(attachedCar?.graph).toEqual(ordinaryCar?.graph);
+
+ const ordinaryWalk = ordinaryReports.find(
+ (report) => report.caseId === "synthetic-conflation-walk",
+ );
+ const attachedWalk = attachmentReports.find(
+ (report) => report.caseId === "synthetic-conflation-walk",
+ );
+ expect(attachedWalk?.graph.edges).toBe(ordinaryWalk?.graph.edges);
+ expect(attachedWalk?.graph.weakComponents).toBe(1);
+ expect(ordinaryWalk?.graph.weakComponents).toBe(2);
+ });
+
+ it("preserves both conflation variants through PBF serialization", () => {
+ expect(topologyFingerprint(propertyRoundTrip)).toBe(topologyFingerprint(propertyTransfer));
+ expect(topologyFingerprint(attachmentRoundTrip)).toBe(topologyFingerprint(networkAttachment));
+ expectReportsToMatchCases(propertyRoundTripReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES);
+ expectReportsToMatchCases(attachmentRoundTripReports, SYNTHETIC_CONFLATION_ATTACHED_CASES);
+ expect(
+ stableReports(propertyRoundTripReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES),
+ ).toEqual(stableReports(propertyReports, SYNTHETIC_CONFLATION_DISCONNECTED_CASES));
+ expect(stableReports(attachmentRoundTripReports, SYNTHETIC_CONFLATION_ATTACHED_CASES)).toEqual(
+ stableReports(attachmentReports, SYNTHETIC_CONFLATION_ATTACHED_CASES),
+ );
+ });
+});
diff --git a/packages/osmix/test/routing-harness.ts b/packages/osmix/test/routing-harness.ts
new file mode 100644
index 00000000..9e65e313
--- /dev/null
+++ b/packages/osmix/test/routing-harness.ts
@@ -0,0 +1,494 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import type { FeatureCollection, LineString } from "geojson";
+
+import {
+ defaultHighwayFilter,
+ type HighwayFilter,
+ type LonLat,
+ type Osm,
+ type OsmTags,
+ Router,
+ RoutingGraph,
+ toPbfBuffer,
+} from "../src/index.ts";
+import type {
+ RoutingTestCase,
+ RoutingTestEndpoint,
+ RoutingTestMode,
+ RoutingPolicyLimitation,
+} from "./fixtures/routing-cases.ts";
+
+const WALK_SPEEDS = {
+ bridleway: 5,
+ cycleway: 5,
+ footway: 5,
+ living_street: 5,
+ path: 5,
+ pedestrian: 5,
+ primary: 5,
+ residential: 5,
+ secondary: 5,
+ service: 5,
+ steps: 2,
+ tertiary: 5,
+ track: 5,
+ unclassified: 5,
+};
+
+const WALKABLE_HIGHWAYS = new Set(Object.keys(WALK_SPEEDS));
+const POSITIVE_ACCESS_VALUES = new Set(["designated", "destination", "permissive", "yes"]);
+const NEGATIVE_ACCESS_VALUES = new Set(["no", "private"]);
+
+/**
+ * Test-only subset of R5's motor-vehicle access policy. This deliberately does not model
+ * conditional access, barriers, or every OSM vehicle class and is not a public routing profile.
+ */
+export const routingTestCarAccessFilter: HighwayFilter = (tags?: OsmTags): boolean => {
+ if (!defaultHighwayFilter(tags)) return false;
+ const access =
+ tags?.["motorcar"] ?? tags?.["motor_vehicle"] ?? tags?.["vehicle"] ?? tags?.["access"];
+ return !NEGATIVE_ACCESS_VALUES.has(String(access));
+};
+
+/** Test-only pedestrian policy. R5 remains the authority for production access semantics. */
+export const routingTestWalkFilter: HighwayFilter = (tags?: OsmTags): boolean => {
+ const highway = tags?.["highway"];
+ if (!highway || !WALKABLE_HIGHWAYS.has(String(highway))) return false;
+ if (tags["foot"] === "no" || tags["foot"] === "private") return false;
+
+ const access = tags["access"];
+ if (access === "no" || access === "private") {
+ return POSITIVE_ACCESS_VALUES.has(String(tags["foot"]));
+ }
+
+ return true;
+};
+
+export interface RoutingGraphReport {
+ nodes: number;
+ edges: number;
+ weakComponents: number;
+}
+
+export interface RoutingEndpointReport {
+ nodeId: number;
+ coordinates: LonLat;
+ snapDistanceMeters: number;
+}
+
+export interface RoutingPathReport {
+ nodeIds: number[];
+ wayIds: number[];
+ highways: string[];
+ coordinates: LonLat[];
+ distanceMeters: number;
+ timeSeconds: number;
+ optimizedCost: number;
+}
+
+export interface RoutingCaseReport {
+ caseId: string;
+ mode: RoutingTestMode;
+ graphPolicy: "access-aware" | "osmix-default";
+ metric: RoutingTestCase["metric"];
+ graph: RoutingGraphReport;
+ from: RoutingEndpointReport | null;
+ to: RoutingEndpointReport | null;
+ reachable: boolean;
+ algorithmAgreement: boolean;
+ algorithmCosts: { astar: number | null; dijkstra: number | null };
+ policyLimitation?: RoutingPolicyLimitation;
+ path: RoutingPathReport | null;
+}
+
+interface RoutingContext {
+ graph: RoutingGraph;
+ report: RoutingGraphReport;
+ router: Router;
+}
+
+type RoutingContextKey = RoutingTestMode | "car-access-aware";
+
+function countWeakComponents(graph: RoutingGraph): number {
+ const parents = Uint32Array.from({ length: graph.size }, (_, index) => index);
+ const routable = new Uint8Array(graph.size);
+
+ const find = (value: number): number => {
+ let root = value;
+ while (parents[root] !== root) root = parents[root]!;
+ let cursor = value;
+ while (parents[cursor] !== cursor) {
+ const next = parents[cursor]!;
+ parents[cursor] = root;
+ cursor = next;
+ }
+ return root;
+ };
+
+ const union = (left: number, right: number): void => {
+ const leftRoot = find(left);
+ const rightRoot = find(right);
+ if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
+ };
+
+ for (let nodeIndex = 0; nodeIndex < graph.size; nodeIndex++) {
+ if (!graph.isRoutable(nodeIndex)) continue;
+ routable[nodeIndex] = 1;
+ for (const edge of graph.getEdges(nodeIndex)) union(nodeIndex, edge.targetNodeIndex);
+ }
+
+ const roots = new Set();
+ for (let nodeIndex = 0; nodeIndex < graph.size; nodeIndex++) {
+ if (routable[nodeIndex]) roots.add(find(nodeIndex));
+ }
+ return roots.size;
+}
+
+function buildContext(osm: Osm, mode: RoutingContextKey): RoutingContext {
+ let graph: RoutingGraph;
+ if (mode === "walk") graph = new RoutingGraph(osm, routingTestWalkFilter, WALK_SPEEDS);
+ else if (mode === "car-access-aware") graph = new RoutingGraph(osm, routingTestCarAccessFilter);
+ else graph = new RoutingGraph(osm, defaultHighwayFilter);
+ return {
+ graph,
+ router: new Router(osm, graph),
+ report: {
+ nodes: graph.size,
+ edges: graph.edges,
+ weakComponents: countWeakComponents(graph),
+ },
+ };
+}
+
+function resolveEndpoint(
+ osm: Osm,
+ graph: RoutingGraph,
+ endpoint: RoutingTestEndpoint,
+): RoutingEndpointReport | null {
+ if ("nodeId" in endpoint) {
+ const nodeIndex = osm.nodes.ids.getIndexFromId(endpoint.nodeId);
+ if (nodeIndex === -1 || !graph.isRoutable(nodeIndex)) return null;
+ return {
+ nodeId: endpoint.nodeId,
+ coordinates: osm.nodes.getNodeLonLat({ index: nodeIndex }),
+ snapDistanceMeters: 0,
+ };
+ }
+
+ const nearest = graph.findNearestRoutableNode(
+ osm,
+ endpoint.coordinates,
+ endpoint.maxSnapDistanceMeters,
+ );
+ if (!nearest) return null;
+ return {
+ nodeId: osm.nodes.ids.at(nearest.nodeIndex),
+ coordinates: nearest.coordinates,
+ snapDistanceMeters: nearest.distance,
+ };
+}
+
+function uniqueConsecutive(values: readonly T[]): T[] {
+ return values.filter((value, index) => index === 0 || value !== values[index - 1]);
+}
+
+function routeCase(
+ osm: Osm,
+ context: RoutingContext,
+ testCase: RoutingTestCase,
+): RoutingCaseReport {
+ const from = resolveEndpoint(osm, context.graph, testCase.from);
+ const to = resolveEndpoint(osm, context.graph, testCase.to);
+ if (!from || !to) {
+ return {
+ caseId: testCase.id,
+ mode: testCase.mode,
+ graphPolicy: testCase.graphPolicy ?? "osmix-default",
+ metric: testCase.metric,
+ graph: context.report,
+ from,
+ to,
+ reachable: false,
+ algorithmAgreement: true,
+ algorithmCosts: { astar: null, dijkstra: null },
+ policyLimitation: testCase.policyLimitation,
+ path: null,
+ };
+ }
+
+ const fromIndex = osm.nodes.ids.getIndexFromId(from.nodeId);
+ const toIndex = osm.nodes.ids.getIndexFromId(to.nodeId);
+ const routeOptions = { metric: testCase.metric } as const;
+ const dijkstra = context.router.route(fromIndex, toIndex, {
+ ...routeOptions,
+ algorithm: "dijkstra",
+ });
+ const astar = context.router.route(fromIndex, toIndex, {
+ ...routeOptions,
+ algorithm: "astar",
+ });
+ const bothReachable = dijkstra !== null && astar !== null;
+ const bothUnreachable = dijkstra === null && astar === null;
+ const algorithmAgreement =
+ bothUnreachable ||
+ (bothReachable &&
+ Math.abs(dijkstra.at(-1)!.cost - astar.at(-1)!.cost) <=
+ Math.max(0.001, Math.abs(dijkstra.at(-1)!.cost) * 1e-6));
+
+ if (!dijkstra) {
+ return {
+ caseId: testCase.id,
+ mode: testCase.mode,
+ graphPolicy: testCase.graphPolicy ?? "osmix-default",
+ metric: testCase.metric,
+ graph: context.report,
+ from,
+ to,
+ reachable: false,
+ algorithmAgreement,
+ algorithmCosts: { astar: astar?.at(-1)?.cost ?? null, dijkstra: null },
+ policyLimitation: testCase.policyLimitation,
+ path: null,
+ };
+ }
+
+ const stats = context.router.getRouteStatistics(dijkstra);
+ const wayIndexes = uniqueConsecutive(
+ dijkstra.flatMap((segment) => (segment.wayIndex === undefined ? [] : [segment.wayIndex])),
+ );
+ return {
+ caseId: testCase.id,
+ mode: testCase.mode,
+ graphPolicy: testCase.graphPolicy ?? "osmix-default",
+ metric: testCase.metric,
+ graph: context.report,
+ from,
+ to,
+ reachable: true,
+ algorithmAgreement,
+ algorithmCosts: {
+ astar: astar?.at(-1)?.cost ?? null,
+ dijkstra: dijkstra.at(-1)!.cost,
+ },
+ policyLimitation: testCase.policyLimitation,
+ path: {
+ nodeIds: dijkstra.map((segment) => osm.nodes.ids.at(segment.nodeIndex)),
+ wayIds: wayIndexes.map((wayIndex) => osm.ways.ids.at(wayIndex)),
+ highways: wayIndexes.map((wayIndex) =>
+ String(osm.ways.tags.getTags(wayIndex)?.["highway"] ?? ""),
+ ),
+ coordinates: dijkstra.map((segment) => osm.nodes.getNodeLonLat({ index: segment.nodeIndex })),
+ distanceMeters: stats.distance,
+ timeSeconds: stats.time,
+ optimizedCost: dijkstra.at(-1)!.cost,
+ },
+ };
+}
+
+export class RoutingTestHarness {
+ readonly osm: Osm;
+ readonly contexts: Record;
+
+ constructor(osm: Osm) {
+ this.osm = osm;
+ this.contexts = {
+ car: buildContext(osm, "car"),
+ "car-access-aware": buildContext(osm, "car-access-aware"),
+ walk: buildContext(osm, "walk"),
+ };
+ }
+
+ run(testCase: RoutingTestCase): RoutingCaseReport {
+ const contextKey =
+ testCase.mode === "car" && testCase.graphPolicy === "access-aware"
+ ? "car-access-aware"
+ : testCase.mode;
+ return routeCase(this.osm, this.contexts[contextKey], testCase);
+ }
+
+ runAll(testCases: readonly RoutingTestCase[]): RoutingCaseReport[] {
+ return testCases.map((testCase) => this.run(testCase));
+ }
+}
+
+export function stableRoutingReport(report: RoutingCaseReport) {
+ return {
+ caseId: report.caseId,
+ mode: report.mode,
+ graphPolicy: report.graphPolicy,
+ metric: report.metric,
+ graph: report.graph,
+ fromNodeId: report.from?.nodeId ?? null,
+ toNodeId: report.to?.nodeId ?? null,
+ reachable: report.reachable,
+ algorithmAgreement: report.algorithmAgreement,
+ algorithmCosts: {
+ astar:
+ report.algorithmCosts.astar === null
+ ? null
+ : Number(report.algorithmCosts.astar.toFixed(3)),
+ dijkstra:
+ report.algorithmCosts.dijkstra === null
+ ? null
+ : Number(report.algorithmCosts.dijkstra.toFixed(3)),
+ },
+ policyLimitation: report.policyLimitation,
+ path: report.path
+ ? {
+ nodeIds: report.path.nodeIds,
+ wayIds: report.path.wayIds,
+ highways: report.path.highways,
+ distanceMeters: Number(report.path.distanceMeters.toFixed(3)),
+ timeSeconds: Number(report.path.timeSeconds.toFixed(3)),
+ optimizedCost: Number(report.path.optimizedCost.toFixed(3)),
+ }
+ : null,
+ };
+}
+
+export function routingReportsToGeoJson(
+ reports: readonly RoutingCaseReport[],
+): FeatureCollection {
+ return {
+ type: "FeatureCollection",
+ features: reports.flatMap((report) =>
+ report.path
+ ? [
+ {
+ type: "Feature" as const,
+ properties: {
+ caseId: report.caseId,
+ mode: report.mode,
+ distanceMeters: report.path.distanceMeters,
+ timeSeconds: report.path.timeSeconds,
+ wayIds: report.path.wayIds.join(","),
+ },
+ geometry: {
+ type: "LineString" as const,
+ coordinates: report.path.coordinates,
+ },
+ },
+ ]
+ : [],
+ ),
+ };
+}
+
+/** Write diagnostics only when explicitly called by a developer or debugging script. */
+export async function writeRoutingDiagnostics(
+ reports: readonly RoutingCaseReport[],
+ directory: string,
+): Promise