feat: insert direct edit constraint solver stage

Adds resolveDirectEditConstraints between compileRoadModel and
compileGeometry, per the parent design's data flow. The stage is an
identity transform until the individual constraint kinds land: it
returns empty road profiles, junction plans and handle manifest, so
compileGeometry's geometry code is untouched and output stays
byte-identical.

Constraint planning is separated from solving up front. Only `exact`
and `recheck` constraints may reach the solver; `disabled`,
`pending`, `conflicted` and `stale` are reported as unapplied with a
reason. SOLVED_KINDS starts empty and grows one entry per kind, so a
partially delivered solver reports what it skipped instead of
publishing half-solved geometry.

diagnostic() moves to src/compile/diagnostics.js so the solver can
share the compiler's diagnostic shape without depending on fs, which
would break the pure-function boundary that lets preview, the full
compile and the CLI export share one implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:29:45 +08:00
parent c5e762e1a9
commit 3297d0c97a
9 changed files with 274 additions and 14 deletions

View File

@@ -3,7 +3,7 @@
"name": "direct-edit-solver-api", "name": "direct-edit-solver-api",
"title": "约束求解器与编辑 API", "title": "约束求解器与编辑 API",
"description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API", "description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API",
"status": "planning", "status": "in_progress",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,

View File

@@ -8,7 +8,7 @@
"road-compiler": "bin/road-compiler.js" "road-compiler": "bin/road-compiler.js"
}, },
"scripts": { "scripts": {
"test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/fixtures.js", "test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/direct-edit-solver.js && node test/fixtures.js",
"test:fixtures:update-baseline": "node test/update-fixture-baselines.js", "test:fixtures:update-baseline": "node test/update-fixture-baselines.js",
"road:workbench": "node bin/road-workbench.js", "road:workbench": "node bin/road-workbench.js",
"road:export": "node bin/road-compiler.js", "road:export": "node bin/road-compiler.js",

View File

@@ -13,6 +13,8 @@ const {
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest'); const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
const { loadOrGenerate, runtime } = require('../native-traffic-signals'); const { loadOrGenerate, runtime } = require('../native-traffic-signals');
const { readAreaConfigSnapshot } = require('./road-revisions'); const { readAreaConfigSnapshot } = require('./road-revisions');
const { loadEditDocument } = require('./native-road-edits');
const { resolveDirectEditConstraints } = require('./direct-edit-solver');
function compileInput(input) { function compileInput(input) {
validateInput(input); validateInput(input);
@@ -40,10 +42,17 @@ function compileInput(input) {
for (const item of validated.stale) for (const item of validated.stale)
console.warn(`[warning] 忽略失效的 override目标已不存在${item.id} -> ${item.target}`); console.warn(`[warning] 忽略失效的 override目标已不存在${item.id} -> ${item.target}`);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
// Direct edits resolve against the model *after* v1 overrides, then feed
// compileGeometry. An area with no v2 document resolves to an empty result,
// which leaves the geometry byte-identical to a compile without this stage.
const directEdits = input.editsFile ? loadEditDocument(input.editsFile) : null;
const directEdit = resolveDirectEditConstraints(model, directEdits);
const compiled = compileGeometry(model, overrides, { const compiled = compileGeometry(model, overrides, {
edgeLines: area.nativeRoad.edgeLines, edgeLines: area.nativeRoad.edgeLines,
junctionTemplates: area.nativeRoad.junctionTemplates, junctionTemplates: area.nativeRoad.junctionTemplates,
directEdit,
}); });
compiled.diagnostics.push(...directEdit.diagnostics);
compiled.diagnostics.push( compiled.diagnostics.push(
...validated.stale.map((item) => ({ ...validated.stale.map((item) => ({
id: `diagnostic:stale-override:${item.id}`, id: `diagnostic:stale-override:${item.id}`,
@@ -169,6 +178,8 @@ function validateInput(input) {
(typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile) (typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile)
) )
throw new Error('RoadCompilerInput.areaConfigSnapshotFile must be a non-empty string when present'); throw new Error('RoadCompilerInput.areaConfigSnapshotFile must be a non-empty string when present');
if (input.editsFile !== undefined && (typeof input.editsFile !== 'string' || !input.editsFile))
throw new Error('RoadCompilerInput.editsFile must be a non-empty string when present');
} }
function validateOptions(options) { function validateOptions(options) {

View File

@@ -0,0 +1,19 @@
'use strict';
// Shared so the geometry compiler and the constraint solver report problems in
// one shape. The solver must stay free of `fs`, which rules out reaching into
// native-road.js for this, and re-declaring the shape there would let the two
// drift apart field by field.
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) {
return {
id: `diagnostic:${rule}:${subjectId}`,
severity,
subjectId,
sourceIds,
rule,
message,
geometry: coordinate ? { type: 'Point', coordinates: coordinate } : null,
};
}
module.exports = { diagnostic };

View File

@@ -0,0 +1,85 @@
'use strict';
const { diagnostic } = require('./diagnostics');
const RESOLUTION_SCHEMA = 'road-edit-resolution/v1';
const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1';
// Only these two statuses may reshape geometry. `pending`, `conflicted` and
// `stale` are reported and skipped on purpose: applying a constraint whose
// anchor no longer resolves would silently reshape the wrong road, which is the
// exact failure the replay rules exist to prevent. Matching decides the status;
// this module only obeys it.
const ACTIVE_STATUSES = new Set(['exact', 'recheck']);
// Grows one entry per kind as the solver learns to apply it. A kind absent here
// is reported as unapplied rather than approximated, so a partially delivered
// solver never publishes half-solved geometry.
const SOLVED_KINDS = new Set();
function emptyHandleManifest(context) {
return {
schema: HANDLE_MANIFEST_SCHEMA,
revisionId: typeof context.revisionId === 'string' ? context.revisionId : null,
previewSeq: Number.isInteger(context.previewSeq) ? context.previewSeq : 0,
handles: [],
reserves: [],
};
}
function constraintState(constraint, applied, reason) {
return { constraintId: constraint.id, kind: constraint.kind, status: constraint.status, applied, reason };
}
// Phase one: decide which constraints are allowed to touch geometry and record
// why the rest are not. Keeping this separate from solving is what stops a
// diagnostic and a geometry decision from being derived from each other.
function planConstraints(constraints, diagnostics) {
return constraints.map((constraint) => {
if (!constraint.enabled) return constraintState(constraint, false, 'disabled');
if (!ACTIVE_STATUSES.has(constraint.status))
return constraintState(constraint, false, `status-${constraint.status}`);
if (!SOLVED_KINDS.has(constraint.kind)) {
diagnostics.push(
diagnostic(
'info',
`constraint:${constraint.id}`,
[],
'direct-edit-kind-not-solved',
`直接编辑约束 ${constraint.kind} 尚未接入求解器,本次编译按基线几何输出。`,
null,
),
);
return constraintState(constraint, false, 'kind-not-solved');
}
return constraintState(constraint, true, null);
});
}
// Pure: no file or network access, so preview, the full compile and the CLI
// export can share one implementation and unit fixtures can drive it directly.
// `model` is the road model after v1 overrides are applied; `editDocument` is a
// validated `native-road-edits/v2` document, or null when an area has no edits.
function resolveDirectEditConstraints(model, editDocument = null, context = {}) {
const diagnostics = [];
const constraints = Array.isArray(editDocument && editDocument.constraints) ? editDocument.constraints : [];
return {
schema: RESOLUTION_SCHEMA,
// Empty means "use the baseline cross-section / junction geometry". Later
// steps populate these per road and per node; an empty resolution is an
// identity transform over compileGeometry by construction.
roadProfiles: new Map(),
junctionPlans: new Map(),
handles: emptyHandleManifest(context),
constraintStates: planConstraints(constraints, diagnostics),
diagnostics,
};
}
module.exports = {
RESOLUTION_SCHEMA,
HANDLE_MANIFEST_SCHEMA,
ACTIVE_STATUSES,
SOLVED_KINDS,
resolveDirectEditConstraints,
};

View File

@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows'); const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction'); const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
const { diagnostic } = require('./diagnostics');
const OVERRIDE_SCHEMA = 'native-road-overrides/v1'; const OVERRIDE_SCHEMA = 'native-road-overrides/v1';
const MOTOR_HIGHWAYS = new Set([ const MOTOR_HIGHWAYS = new Set([
@@ -3089,17 +3090,6 @@ function unproject(point, origin) {
const scale = 111320; const scale = 111320;
return [point[0] / (scale * Math.cos((origin[1] * Math.PI) / 180)) + origin[0], point[1] / scale + origin[1]]; return [point[0] / (scale * Math.cos((origin[1] * Math.PI) / 180)) + origin[0], point[1] / scale + origin[1]];
} }
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) {
return {
id: `diagnostic:${rule}:${subjectId}`,
severity,
subjectId,
sourceIds,
rule,
message,
geometry: coordinate ? { type: 'Point', coordinates: coordinate } : null,
};
}
function xmlAttrs(text) { function xmlAttrs(text) {
const attrs = {}; const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3];

116
test/direct-edit-solver.js Normal file
View File

@@ -0,0 +1,116 @@
'use strict';
const assert = require('assert/strict');
const { resolveDirectEditConstraints, SOLVED_KINDS } = require('../src/compile/direct-edit-solver');
const model = { roads: [], endpoints: [], connections: [], diagnostics: [] };
function constraint(overrides = {}) {
return {
id: 'constraint-1',
kind: 'road-edge-offset',
anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.3, endStation: 0.7 },
anchorSnapshot: {
coordinate: [113.1, 30.2],
tangentAzimuth: 45,
roadLengthMeters: 120,
osmNodeIds: ['1', '2'],
},
value: { offsetMeters: 1.5, transition: 'smoothstep' },
enabled: true,
status: 'exact',
provenance: { operationId: 'operation-1', createdAt: '2026-08-27T00:00:00.000Z' },
...overrides,
};
}
function document(constraints) {
return { schema: 'native-road-edits/v2', documentVersion: 1, base: {}, constraints, operations: [] };
}
// A missing or empty document must resolve to the identity: this is what keeps
// the stage insertable ahead of compileGeometry without moving any geometry.
for (const empty of [null, undefined, document([])]) {
const resolution = resolveDirectEditConstraints(model, empty);
assert.equal(resolution.schema, 'road-edit-resolution/v1');
assert.equal(resolution.roadProfiles.size, 0);
assert.equal(resolution.junctionPlans.size, 0);
assert.deepEqual(resolution.constraintStates, []);
assert.deepEqual(resolution.diagnostics, []);
assert.equal(resolution.handles.schema, 'road-edit-handles/v1');
assert.deepEqual(resolution.handles.handles, []);
assert.deepEqual(resolution.handles.reserves, []);
assert.equal(resolution.handles.revisionId, null);
assert.equal(resolution.handles.previewSeq, 0);
}
// Handle manifest context is echoed so a preview response can be matched to the
// request that asked for it.
const withContext = resolveDirectEditConstraints(model, null, { revisionId: 'rev-0007', previewSeq: 42 });
assert.equal(withContext.handles.revisionId, 'rev-0007');
assert.equal(withContext.handles.previewSeq, 42);
// A disabled constraint is reported, never applied, and produces no diagnostic:
// the user turned it off deliberately, so it is not a problem to report.
const disabled = resolveDirectEditConstraints(model, document([constraint({ enabled: false })]));
assert.deepEqual(disabled.constraintStates, [
{ constraintId: 'constraint-1', kind: 'road-edge-offset', status: 'exact', applied: false, reason: 'disabled' },
]);
assert.deepEqual(disabled.diagnostics, []);
// Only `exact` and `recheck` may reach the solver. The other three statuses mean
// the anchor is not trustworthy, so they are skipped with the status as reason.
for (const status of ['pending', 'conflicted', 'stale']) {
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
assert.deepEqual(resolution.constraintStates, [
{ constraintId: 'constraint-1', kind: 'road-edge-offset', status, applied: false, reason: `status-${status}` },
]);
assert.deepEqual(resolution.diagnostics, []);
}
// Until a kind is wired into the solver its constraints are reported as
// unapplied with an explicit diagnostic, rather than approximated or ignored.
for (const status of ['exact', 'recheck']) {
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
const expected = SOLVED_KINDS.has('road-edge-offset')
? { applied: true, reason: null, diagnostics: 0 }
: { applied: false, reason: 'kind-not-solved', diagnostics: 1 };
assert.deepEqual(resolution.constraintStates, [
{
constraintId: 'constraint-1',
kind: 'road-edge-offset',
status,
applied: expected.applied,
reason: expected.reason,
},
]);
assert.equal(resolution.diagnostics.length, expected.diagnostics);
for (const item of resolution.diagnostics) {
assert.equal(item.severity, 'info');
assert.equal(item.rule, 'direct-edit-kind-not-solved');
assert.equal(item.subjectId, 'constraint:constraint-1');
assert.equal(item.geometry, null);
}
}
// Every constraint gets exactly one state, in document order, so a caller can
// report per-row status without re-deriving the mapping.
const many = resolveDirectEditConstraints(
model,
document([
constraint({ id: 'a', enabled: false }),
constraint({ id: 'b', status: 'stale' }),
constraint({ id: 'c' }),
]),
);
assert.deepEqual(
many.constraintStates.map((state) => state.constraintId),
['a', 'b', 'c'],
);
// The solver must be free of file and network access so preview can share it.
const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8');
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])
assert.equal(source.includes(forbidden), false, `direct-edit-solver must not ${forbidden}`);
console.log('direct edit solver tests passed');

View File

@@ -7,6 +7,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const { nativeRoadEdits, roadRevisions } = require('../src'); const { nativeRoadEdits, roadRevisions } = require('../src');
const { compileInput } = require('../src/compile/compiler'); const { compileInput } = require('../src/compile/compiler');
const { snapshot: outputSnapshot } = require('./fixture-baseline');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-'));
const source = '<osm version="0.6"><node id="1" lon="114.1" lat="22.5"/></osm>\n'; const source = '<osm version="0.6"><node id="1" lon="114.1" lat="22.5"/></osm>\n';
@@ -127,5 +128,39 @@ assert.throws(
/Area config snapshot is missing/, /Area config snapshot is missing/,
); );
// The workbench compile is the one caller that passes editsFile. With no v2
// constraints the direct-edit solver stage must be an identity transform, so
// both compiles have to publish byte-identical outputs.
const activeEditsFile = path.join(directory, 'active', 'native-road-edits.json');
assert.ok(fs.existsSync(activeEditsFile));
assert.deepEqual(nativeRoadEdits.loadEditDocument(activeEditsFile).constraints, []);
const identityBase = { ...compileBase, areaId: 'edits-identity-test' };
const withoutEdits = {
...identityBase,
outDir: path.join(directory, 'no-edits-output'),
stagingDir: path.join(directory, 'no-edits-pipeline'),
};
const withEdits = {
...identityBase,
outDir: path.join(directory, 'with-edits-output'),
stagingDir: path.join(directory, 'with-edits-pipeline'),
editsFile: activeEditsFile,
};
compileInput(withoutEdits);
compileInput(withEdits);
assert.deepEqual(
outputSnapshot(withEdits).files,
outputSnapshot(withoutEdits).files,
'an empty v2 document must not change compiled output',
);
assert.throws(
() => compileInput({ ...identityBase, editsFile: '' }),
/editsFile must be a non-empty string when present/,
);
assert.throws(
() => compileInput({ ...identityBase, editsFile: '' }),
/editsFile must be a non-empty string when present/,
);
fs.rmSync(directory, { recursive: true, force: true }); fs.rmSync(directory, { recursive: true, force: true });
console.log('road revision storage tests passed'); console.log('road revision storage tests passed');

View File

@@ -324,7 +324,11 @@ function readUpload(request, session) {
function compileWorkbenchInput(input) { function compileWorkbenchInput(input) {
const workspace = path.dirname(input.osmFile); const workspace = path.dirname(input.osmFile);
const initialized = ensureRevisionStore(workspace, input.options); const initialized = ensureRevisionStore(workspace, input.options);
return compileInput({ ...input, areaConfigSnapshotFile: initialized.paths.activeAreaConfig }); return compileInput({
...input,
areaConfigSnapshotFile: initialized.paths.activeAreaConfig,
editsFile: initialized.paths.activeEdits,
});
} }
function readMultipart(request, limit) { function readMultipart(request, limit) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {