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:
@@ -3,7 +3,7 @@
|
||||
"name": "direct-edit-solver-api",
|
||||
"title": "约束求解器与编辑 API",
|
||||
"description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API",
|
||||
"status": "planning",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"road-compiler": "bin/road-compiler.js"
|
||||
},
|
||||
"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",
|
||||
"road:workbench": "node bin/road-workbench.js",
|
||||
"road:export": "node bin/road-compiler.js",
|
||||
|
||||
@@ -13,6 +13,8 @@ const {
|
||||
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
|
||||
const { loadOrGenerate, runtime } = require('../native-traffic-signals');
|
||||
const { readAreaConfigSnapshot } = require('./road-revisions');
|
||||
const { loadEditDocument } = require('./native-road-edits');
|
||||
const { resolveDirectEditConstraints } = require('./direct-edit-solver');
|
||||
|
||||
function compileInput(input) {
|
||||
validateInput(input);
|
||||
@@ -40,10 +42,17 @@ function compileInput(input) {
|
||||
for (const item of validated.stale)
|
||||
console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`);
|
||||
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, {
|
||||
edgeLines: area.nativeRoad.edgeLines,
|
||||
junctionTemplates: area.nativeRoad.junctionTemplates,
|
||||
directEdit,
|
||||
});
|
||||
compiled.diagnostics.push(...directEdit.diagnostics);
|
||||
compiled.diagnostics.push(
|
||||
...validated.stale.map((item) => ({
|
||||
id: `diagnostic:stale-override:${item.id}`,
|
||||
@@ -169,6 +178,8 @@ function validateInput(input) {
|
||||
(typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile)
|
||||
)
|
||||
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) {
|
||||
|
||||
19
src/compile/diagnostics.js
Normal file
19
src/compile/diagnostics.js
Normal 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 };
|
||||
85
src/compile/direct-edit-solver.js
Normal file
85
src/compile/direct-edit-solver.js
Normal 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,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
|
||||
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
|
||||
const { diagnostic } = require('./diagnostics');
|
||||
|
||||
const OVERRIDE_SCHEMA = 'native-road-overrides/v1';
|
||||
const MOTOR_HIGHWAYS = new Set([
|
||||
@@ -3089,17 +3090,6 @@ function unproject(point, origin) {
|
||||
const scale = 111320;
|
||||
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) {
|
||||
const attrs = {};
|
||||
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
116
test/direct-edit-solver.js
Normal 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');
|
||||
@@ -7,6 +7,7 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
const { nativeRoadEdits, roadRevisions } = require('../src');
|
||||
const { compileInput } = require('../src/compile/compiler');
|
||||
const { snapshot: outputSnapshot } = require('./fixture-baseline');
|
||||
|
||||
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';
|
||||
@@ -127,5 +128,39 @@ assert.throws(
|
||||
/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 });
|
||||
console.log('road revision storage tests passed');
|
||||
|
||||
@@ -324,7 +324,11 @@ function readUpload(request, session) {
|
||||
function compileWorkbenchInput(input) {
|
||||
const workspace = path.dirname(input.osmFile);
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user