dsh-logicprobe 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/engine.js +261 -0
- package/lib/tool.js +15 -3
- package/lib/types/engine.d.ts +18 -0
- package/lib/types/tool.d.ts +4 -2
- package/package.json +1 -1
- package/skills/logicprobe/SKILL.md +2 -0
- package/skills/logicprobe/references/dsh-model-schema.md +26 -1
- package/src/engine.ts +299 -0
- package/src/tool.ts +15 -3
package/README.md
CHANGED
|
@@ -62,11 +62,12 @@ git clone https://github.com/AmethystLuna/logicprobe.git ~/.claude/plugins/dev/l
|
|
|
62
62
|
|
|
63
63
|
- 技能遵循 Agent Skills 开放标准,被 dsh 的 `skill-filesystem` provider 原样发现——零代码。
|
|
64
64
|
- bundle 将 claim 验证门禁(1% Rule / Red Flags / 主动建议)注入每个 agent 会话的第一个模型步骤——是 Claude `SessionStart` hook 在 dsh 的原生对应物,并注册模型可见目录条目(`cordis_inspect`)、原生工具 `logicprobe_verify`(`ctx.tools`)以及策略感知上下文 `logicprobe:mode`(`ctx.systemPrompt`)。
|
|
65
|
+
- `logicprobe_verify` 支持 `beforeModel` + `stateMapping` 的 BEFORE/AFTER 对比(D1-D4),可直接验证重构/迁移的行为保持、不变量连续性、回归增量和死锁/活性回归。
|
|
65
66
|
- 与 embedded-workbench bundle 的 Plan Verification Gate 配合,在 dsh 中闭环了 claim 验证链路。
|
|
66
67
|
|
|
67
68
|
安装:参见 [`.dsh/INSTALL.md`](.dsh/INSTALL.md)(四种方式,从纯技能拷贝到 `dsh plugin add`)。
|
|
68
69
|
|
|
69
|
-
> DSH
|
|
70
|
+
> DSH 安装注意:npm 包名为 `dsh-logicprobe`(无 scope)。在 web profile 的 `package.json` 中,依赖键与 `dsh.profile.bundles` 必须写 `dsh-logicprobe`;否则 dsh 加载器会因找不到 `node_modules/dsh-logicprobe` 而启动失败。
|
|
70
71
|
|
|
71
72
|
## 使用
|
|
72
73
|
|
package/lib/engine.js
CHANGED
|
@@ -1042,6 +1042,233 @@ function A7_shortestViolations(model, options) {
|
|
|
1042
1042
|
}));
|
|
1043
1043
|
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length);
|
|
1044
1044
|
}
|
|
1045
|
+
function mapStateId(mapping, state) {
|
|
1046
|
+
return mapping[state] ?? state;
|
|
1047
|
+
}
|
|
1048
|
+
function D1_behavioralPreservation(before, after, mapping) {
|
|
1049
|
+
const findings = [];
|
|
1050
|
+
const afterStates = new Set(after.states.map((state) => state.id));
|
|
1051
|
+
const afterByFromEvent = new Map();
|
|
1052
|
+
for (const transition of after.transitions) {
|
|
1053
|
+
const key = transition.from + '|' + transition.event;
|
|
1054
|
+
const set = afterByFromEvent.get(key) ?? new Set();
|
|
1055
|
+
set.add(transition.to);
|
|
1056
|
+
afterByFromEvent.set(key, set);
|
|
1057
|
+
}
|
|
1058
|
+
const beforeInitMapped = mapStateId(mapping, before.init);
|
|
1059
|
+
if (beforeInitMapped !== after.init) {
|
|
1060
|
+
findings.push({
|
|
1061
|
+
code: 'D1_INIT_MISMATCH',
|
|
1062
|
+
severity: 'warning',
|
|
1063
|
+
message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
|
|
1064
|
+
evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
for (const state of before.states) {
|
|
1068
|
+
const mapped = mapStateId(mapping, state.id);
|
|
1069
|
+
if (!afterStates.has(mapped)) {
|
|
1070
|
+
findings.push({
|
|
1071
|
+
code: 'D1_MAPPED_STATE_MISSING',
|
|
1072
|
+
severity: 'error',
|
|
1073
|
+
message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
|
|
1074
|
+
evidence: { beforeState: state.id, mappedState: mapped },
|
|
1075
|
+
});
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
|
|
1079
|
+
const key = mapped + '|' + transition.event;
|
|
1080
|
+
const targets = afterByFromEvent.get(key);
|
|
1081
|
+
if (targets === undefined || targets.size === 0) {
|
|
1082
|
+
findings.push({
|
|
1083
|
+
code: 'D1_EVENT_DISABLED',
|
|
1084
|
+
severity: 'error',
|
|
1085
|
+
message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
|
|
1086
|
+
path: [{ from: state.id, event: transition.event, to: transition.to }],
|
|
1087
|
+
evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length);
|
|
1093
|
+
}
|
|
1094
|
+
function mapInvariantForComparison(invariant, mapping) {
|
|
1095
|
+
if (invariant.kind === 'never-states') {
|
|
1096
|
+
return {
|
|
1097
|
+
...invariant,
|
|
1098
|
+
id: invariant.id + ':before',
|
|
1099
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1100
|
+
states: invariant.states.map((state) => mapStateId(mapping, state)),
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (invariant.kind === 'event-before-state') {
|
|
1104
|
+
return {
|
|
1105
|
+
...invariant,
|
|
1106
|
+
id: invariant.id + ':before',
|
|
1107
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1108
|
+
state: mapStateId(mapping, invariant.state),
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1112
|
+
}
|
|
1113
|
+
function D2_invariantContinuity(before, after, options, mapping) {
|
|
1114
|
+
const findings = [];
|
|
1115
|
+
const afterStates = new Set(after.states.map((state) => state.id));
|
|
1116
|
+
const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name));
|
|
1117
|
+
for (const invariant of before.invariants ?? []) {
|
|
1118
|
+
const mapped = mapInvariantForComparison(invariant, mapping);
|
|
1119
|
+
if (mapped.kind === 'never-states') {
|
|
1120
|
+
for (const state of mapped.states) {
|
|
1121
|
+
if (!afterStates.has(state)) {
|
|
1122
|
+
findings.push({
|
|
1123
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1124
|
+
severity: 'warning',
|
|
1125
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1126
|
+
evidence: { invariant: invariant.id, state },
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
else if (mapped.kind === 'event-before-state') {
|
|
1132
|
+
if (!afterStates.has(mapped.state)) {
|
|
1133
|
+
findings.push({
|
|
1134
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1135
|
+
severity: 'warning',
|
|
1136
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
|
|
1137
|
+
evidence: { invariant: invariant.id, state: mapped.state },
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
else if (mapped.kind === 'var-in-range') {
|
|
1142
|
+
if (!afterVariables.has(mapped.variable)) {
|
|
1143
|
+
findings.push({
|
|
1144
|
+
code: 'D2_VARIABLE_MISSING',
|
|
1145
|
+
severity: 'warning',
|
|
1146
|
+
message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
|
|
1147
|
+
evidence: { invariant: invariant.id, variable: mapped.variable },
|
|
1148
|
+
});
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const violation = shortestViolationForInvariant(after, options, mapped);
|
|
1153
|
+
if (violation !== undefined) {
|
|
1154
|
+
findings.push({
|
|
1155
|
+
code: 'D2_INVARIANT_REGRESSION',
|
|
1156
|
+
severity: 'error',
|
|
1157
|
+
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
1158
|
+
path: violation.path,
|
|
1159
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length);
|
|
1164
|
+
}
|
|
1165
|
+
function D3_regressionDelta(before, after, mapping) {
|
|
1166
|
+
const afterStateIds = new Set(after.states.map((state) => state.id));
|
|
1167
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
|
|
1168
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
|
|
1169
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
|
|
1170
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
|
|
1171
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event));
|
|
1172
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
|
|
1173
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
|
|
1174
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
|
|
1175
|
+
candidate.event === transition.event &&
|
|
1176
|
+
candidate.to === mapStateId(mapping, transition.to)));
|
|
1177
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
|
|
1178
|
+
candidate.event === transition.event &&
|
|
1179
|
+
mapStateId(mapping, candidate.to) === transition.to));
|
|
1180
|
+
const findings = [];
|
|
1181
|
+
for (const state of removedStates) {
|
|
1182
|
+
findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } });
|
|
1183
|
+
}
|
|
1184
|
+
for (const event of removedEvents) {
|
|
1185
|
+
findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } });
|
|
1186
|
+
}
|
|
1187
|
+
for (const transition of removedTransitions) {
|
|
1188
|
+
findings.push({
|
|
1189
|
+
code: 'D3_REMOVED_TRANSITION',
|
|
1190
|
+
severity: 'warning',
|
|
1191
|
+
message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
|
|
1192
|
+
evidence: { transition },
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions';
|
|
1196
|
+
return checkResult('D3', 'Regression Delta', findings, detail);
|
|
1197
|
+
}
|
|
1198
|
+
function deadlockStateIds(model) {
|
|
1199
|
+
const outgoing = new Set(model.transitions.map((transition) => transition.from));
|
|
1200
|
+
return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id);
|
|
1201
|
+
}
|
|
1202
|
+
function closedSccStateSets(model) {
|
|
1203
|
+
return sccs(model)
|
|
1204
|
+
.filter((component) => {
|
|
1205
|
+
const componentSet = new Set(component);
|
|
1206
|
+
const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
|
|
1207
|
+
const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
|
|
1208
|
+
return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state));
|
|
1209
|
+
})
|
|
1210
|
+
.map((component) => component.sort());
|
|
1211
|
+
}
|
|
1212
|
+
function D4_deadlockLivenessRegression(before, after, mapping) {
|
|
1213
|
+
const findings = [];
|
|
1214
|
+
const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)));
|
|
1215
|
+
const afterDeadlock = deadlockStateIds(after);
|
|
1216
|
+
for (const state of afterDeadlock) {
|
|
1217
|
+
if (!beforeDeadlock.has(state)) {
|
|
1218
|
+
findings.push({
|
|
1219
|
+
code: 'D4_DEADLOCK_REGRESSION',
|
|
1220
|
+
severity: 'error',
|
|
1221
|
+
message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
|
|
1222
|
+
evidence: { state },
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')));
|
|
1227
|
+
const afterScc = closedSccStateSets(after);
|
|
1228
|
+
for (const component of afterScc) {
|
|
1229
|
+
const key = component.join(',');
|
|
1230
|
+
if (!beforeScc.has(key)) {
|
|
1231
|
+
findings.push({
|
|
1232
|
+
code: 'D4_LIVENESS_REGRESSION',
|
|
1233
|
+
severity: 'error',
|
|
1234
|
+
message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
|
|
1235
|
+
evidence: { states: component },
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length);
|
|
1240
|
+
}
|
|
1241
|
+
function buildComparisonSummary(before, after, mapping) {
|
|
1242
|
+
const afterStateIds = new Set(after.states.map((state) => state.id));
|
|
1243
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
|
|
1244
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
|
|
1245
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
|
|
1246
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
|
|
1247
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event));
|
|
1248
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
|
|
1249
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
|
|
1250
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
|
|
1251
|
+
candidate.event === transition.event &&
|
|
1252
|
+
candidate.to === mapStateId(mapping, transition.to)));
|
|
1253
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
|
|
1254
|
+
candidate.event === transition.event &&
|
|
1255
|
+
mapStateId(mapping, candidate.to) === transition.to));
|
|
1256
|
+
return {
|
|
1257
|
+
beforeModelHash: modelHash(before),
|
|
1258
|
+
afterModelHash: modelHash(after),
|
|
1259
|
+
stateMapping: mapping,
|
|
1260
|
+
beforeStates: before.states.length,
|
|
1261
|
+
beforeTransitions: before.transitions.length,
|
|
1262
|
+
afterStates: after.states.length,
|
|
1263
|
+
afterTransitions: after.transitions.length,
|
|
1264
|
+
addedStates,
|
|
1265
|
+
removedStates,
|
|
1266
|
+
addedEvents,
|
|
1267
|
+
removedEvents,
|
|
1268
|
+
addedTransitions,
|
|
1269
|
+
removedTransitions,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1045
1272
|
// ---------------------------------------------------------------------------
|
|
1046
1273
|
// main entry
|
|
1047
1274
|
// ---------------------------------------------------------------------------
|
|
@@ -1084,6 +1311,39 @@ export function runVerification(input, options = {}) {
|
|
|
1084
1311
|
A6_resourceInjection(model),
|
|
1085
1312
|
A7_shortestViolations(model, normalized),
|
|
1086
1313
|
];
|
|
1314
|
+
let comparison;
|
|
1315
|
+
if (options.beforeModel !== undefined) {
|
|
1316
|
+
const beforeValidation = validateModel(options.beforeModel);
|
|
1317
|
+
if (!beforeValidation.ok) {
|
|
1318
|
+
return {
|
|
1319
|
+
ok: false,
|
|
1320
|
+
schemaVersion: 1,
|
|
1321
|
+
modelHash: modelHash(model),
|
|
1322
|
+
summary: {
|
|
1323
|
+
states: model.states.length,
|
|
1324
|
+
transitions: model.transitions.length,
|
|
1325
|
+
errors: beforeValidation.errors.length,
|
|
1326
|
+
warnings: 0,
|
|
1327
|
+
checksRun: checks.length + 1,
|
|
1328
|
+
truncated: exploration.truncated,
|
|
1329
|
+
},
|
|
1330
|
+
checks: [
|
|
1331
|
+
...checks,
|
|
1332
|
+
{
|
|
1333
|
+
id: 'BEFORE_MODEL',
|
|
1334
|
+
name: 'Before Model Validation',
|
|
1335
|
+
status: 'fail',
|
|
1336
|
+
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
1337
|
+
findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
const before = beforeValidation.model;
|
|
1343
|
+
const mapping = options.stateMapping ?? {};
|
|
1344
|
+
checks.push(D1_behavioralPreservation(before, model, mapping), D2_invariantContinuity(before, model, normalized, mapping), D3_regressionDelta(before, model, mapping), D4_deadlockLivenessRegression(before, model, mapping));
|
|
1345
|
+
comparison = buildComparisonSummary(before, model, mapping);
|
|
1346
|
+
}
|
|
1087
1347
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
1088
1348
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
1089
1349
|
return {
|
|
@@ -1099,5 +1359,6 @@ export function runVerification(input, options = {}) {
|
|
|
1099
1359
|
truncated: exploration.truncated,
|
|
1100
1360
|
},
|
|
1101
1361
|
checks,
|
|
1362
|
+
...(comparison === undefined ? {} : { comparison }),
|
|
1102
1363
|
};
|
|
1103
1364
|
}
|
package/lib/tool.js
CHANGED
|
@@ -4,12 +4,14 @@ export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify';
|
|
|
4
4
|
/**
|
|
5
5
|
* Model-visible DSH tool wrapping the bundled TypeScript verification engine.
|
|
6
6
|
* The model passes a LogicModelV1 object; the engine validates it and returns
|
|
7
|
-
* the 14-check report
|
|
8
|
-
*
|
|
7
|
+
* the 14-check report, or an 18-check report when beforeModel is supplied
|
|
8
|
+
* (D1 behavioral preservation, D2 invariant continuity, D3 regression delta,
|
|
9
|
+
* D4 deadlock/liveness regression). This is the dsh-native replacement for
|
|
10
|
+
* hand-filling the Python template shipped in the skill references.
|
|
9
11
|
*/
|
|
10
12
|
export const logicProbeVerifyTool = defineTool({
|
|
11
13
|
name: LOGICPROBE_VERIFY_TOOL_NAME,
|
|
12
|
-
description: 'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range, and event-before-state. Returns a report with S1-S7 structural checks and A1-A7 adversarial probes including shortest counterexample paths. See skills/logicprobe/references/dsh-model-schema.md.',
|
|
14
|
+
description: 'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range, and event-before-state. Returns a report with S1-S7 structural checks and A1-A7 adversarial probes including shortest counterexample paths. If beforeModel is provided, also runs D1-D4 before/after regression checks. See skills/logicprobe/references/dsh-model-schema.md.',
|
|
13
15
|
parameters: {
|
|
14
16
|
model: {
|
|
15
17
|
type: 'json',
|
|
@@ -24,6 +26,14 @@ export const logicProbeVerifyTool = defineTool({
|
|
|
24
26
|
type: 'integer',
|
|
25
27
|
description: 'Maximum event count for A3 order permutation. Default 5.',
|
|
26
28
|
},
|
|
29
|
+
beforeModel: {
|
|
30
|
+
type: 'json',
|
|
31
|
+
description: 'Optional BEFORE LogicModelV1 state machine model for refactoring/migration regression comparison.',
|
|
32
|
+
},
|
|
33
|
+
stateMapping: {
|
|
34
|
+
type: 'json',
|
|
35
|
+
description: 'Optional object mapping BEFORE state ids to AFTER state ids. Omit for identity mapping (same state names).',
|
|
36
|
+
},
|
|
27
37
|
},
|
|
28
38
|
output: {
|
|
29
39
|
schema: {
|
|
@@ -40,6 +50,8 @@ export const logicProbeVerifyTool = defineTool({
|
|
|
40
50
|
return runVerification(args.model, {
|
|
41
51
|
maxStates: args.maxStates,
|
|
42
52
|
maxPermutationEvents: args.maxPermutationEvents,
|
|
53
|
+
beforeModel: args.beforeModel,
|
|
54
|
+
stateMapping: args.stateMapping,
|
|
43
55
|
});
|
|
44
56
|
},
|
|
45
57
|
});
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface LogicModelV1 {
|
|
|
83
83
|
export interface VerificationOptions {
|
|
84
84
|
maxStates?: number;
|
|
85
85
|
maxPermutationEvents?: number;
|
|
86
|
+
beforeModel?: unknown;
|
|
87
|
+
stateMapping?: Record<string, string>;
|
|
86
88
|
}
|
|
87
89
|
export interface PathStep {
|
|
88
90
|
from: string;
|
|
@@ -116,6 +118,22 @@ export interface VerificationReport {
|
|
|
116
118
|
truncated?: boolean;
|
|
117
119
|
};
|
|
118
120
|
checks: CheckResult[];
|
|
121
|
+
comparison?: ComparisonSummary;
|
|
122
|
+
}
|
|
123
|
+
export interface ComparisonSummary {
|
|
124
|
+
beforeModelHash: string;
|
|
125
|
+
afterModelHash: string;
|
|
126
|
+
stateMapping: Record<string, string>;
|
|
127
|
+
beforeStates: number;
|
|
128
|
+
beforeTransitions: number;
|
|
129
|
+
afterStates: number;
|
|
130
|
+
afterTransitions: number;
|
|
131
|
+
addedStates: string[];
|
|
132
|
+
removedStates: string[];
|
|
133
|
+
addedEvents: string[];
|
|
134
|
+
removedEvents: string[];
|
|
135
|
+
addedTransitions: TransitionSpec[];
|
|
136
|
+
removedTransitions: TransitionSpec[];
|
|
119
137
|
}
|
|
120
138
|
export interface RuntimeState {
|
|
121
139
|
state: string;
|
package/lib/types/tool.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ export declare const LOGICPROBE_VERIFY_TOOL_NAME = "logicprobe_verify";
|
|
|
2
2
|
/**
|
|
3
3
|
* Model-visible DSH tool wrapping the bundled TypeScript verification engine.
|
|
4
4
|
* The model passes a LogicModelV1 object; the engine validates it and returns
|
|
5
|
-
* the 14-check report
|
|
6
|
-
*
|
|
5
|
+
* the 14-check report, or an 18-check report when beforeModel is supplied
|
|
6
|
+
* (D1 behavioral preservation, D2 invariant continuity, D3 regression delta,
|
|
7
|
+
* D4 deadlock/liveness regression). This is the dsh-native replacement for
|
|
8
|
+
* hand-filling the Python template shipped in the skill references.
|
|
7
9
|
*/
|
|
8
10
|
export declare const logicProbeVerifyTool: import("@deepseek-ai/dsh-tools").ToolDefinition;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-logicprobe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Design document & plan claim verification — enumerate claims, verify against codebase facts, then escalate to logic-primitive verification (7 structural checks + 7 adversarial probes) for behavioral claims. Before/after model comparison for refactoring regression detection. Ships a native DeepSeek Harness (dsh) bundle that injects the claim-verification gate into the first model step.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -159,6 +159,8 @@ When the document under review is a refactoring plan (modifying existing state m
|
|
|
159
159
|
| Complexity claim | If plan claims "simpler": count states + transitions + guards. Is AFTER objectively simpler? | Warning — unsubstantiated claim |
|
|
160
160
|
| Unreachable code | New states added in AFTER must be reachable (otherwise they're dead code from the start) | Warning |
|
|
161
161
|
|
|
162
|
+
In DSH, pass `beforeModel` and optional `stateMapping` to `logicprobe_verify`; the engine runs D1 Behavioral Preservation, D2 Invariant Continuity, D3 Regression Delta, and D4 Deadlock/Liveness Regression automatically.
|
|
163
|
+
|
|
162
164
|
6. **Flag any behavioral delta not documented in the plan** — the most common refactoring bug is an unintended side effect that the plan doesn't acknowledge
|
|
163
165
|
|
|
164
166
|
**Detection step**: Before generating any verification code, run `python3 --version 2>&1` or `python --version 2>&1`. Check the output:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DSH Model Schema v1 — logicprobe_verify
|
|
2
2
|
|
|
3
|
-
The dsh-native `logicprobe_verify` tool accepts a structured JSON model. The engine runs 14 checks (S1-S7 structural, A1-A7 adversarial) and returns a JSON report. Guards and updates are structured data — no code strings, no arbitrary execution.
|
|
3
|
+
The dsh-native `logicprobe_verify` tool accepts a structured JSON model. The engine runs 14 checks (S1-S7 structural, A1-A7 adversarial) and returns a JSON report. When `beforeModel` is supplied, it also runs D1-D4 before/after regression checks. Guards and updates are structured data — no code strings, no arbitrary execution.
|
|
4
4
|
|
|
5
5
|
## Top-level model
|
|
6
6
|
|
|
@@ -97,6 +97,31 @@ In `interaction=auto`, do NOT call `ask_user_question` for model confirmation. R
|
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
## Before/after comparison (D1-D4)
|
|
101
|
+
|
|
102
|
+
When `beforeModel` is passed to `logicprobe_verify`, the engine treats `model` as AFTER and runs four extra checks after S1-A7:
|
|
103
|
+
|
|
104
|
+
| Check | Purpose |
|
|
105
|
+
|---|---|
|
|
106
|
+
| D1 Behavioral Preservation | Every BEFORE (state, event) that could fire must still be fireable from the mapped AFTER state |
|
|
107
|
+
| D2 Invariant Continuity | Every BEFORE invariant (mapped through `stateMapping`) must still hold in AFTER |
|
|
108
|
+
| D3 Regression Delta | Lists added/removed states, events, and transitions |
|
|
109
|
+
| D4 Deadlock/Liveness Regression | New deadlock states or closed SCCs not present in BEFORE |
|
|
110
|
+
|
|
111
|
+
`stateMapping` maps BEFORE state ids to AFTER state ids. Omit it when state names are unchanged.
|
|
112
|
+
|
|
113
|
+
Example tool call shape:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"model": { "...": "AFTER LogicModelV1" },
|
|
118
|
+
"beforeModel": { "...": "BEFORE LogicModelV1" },
|
|
119
|
+
"stateMapping": { "OLD_INIT": "INIT", "OLD_ACTIVE": "ACTIVE" }
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The report's `comparison` object includes both model hashes, state/transition counts, and delta arrays.
|
|
124
|
+
|
|
100
125
|
## Limits
|
|
101
126
|
|
|
102
127
|
- State-space exploration caps at `maxStates` (default 10000); larger guards/domains may report truncation instead of a false pass.
|
package/src/engine.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface LogicModelV1 {
|
|
|
86
86
|
export interface VerificationOptions {
|
|
87
87
|
maxStates?: number
|
|
88
88
|
maxPermutationEvents?: number
|
|
89
|
+
beforeModel?: unknown
|
|
90
|
+
stateMapping?: Record<string, string>
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export interface PathStep {
|
|
@@ -123,6 +125,23 @@ export interface VerificationReport {
|
|
|
123
125
|
truncated?: boolean
|
|
124
126
|
}
|
|
125
127
|
checks: CheckResult[]
|
|
128
|
+
comparison?: ComparisonSummary
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ComparisonSummary {
|
|
132
|
+
beforeModelHash: string
|
|
133
|
+
afterModelHash: string
|
|
134
|
+
stateMapping: Record<string, string>
|
|
135
|
+
beforeStates: number
|
|
136
|
+
beforeTransitions: number
|
|
137
|
+
afterStates: number
|
|
138
|
+
afterTransitions: number
|
|
139
|
+
addedStates: string[]
|
|
140
|
+
removedStates: string[]
|
|
141
|
+
addedEvents: string[]
|
|
142
|
+
removedEvents: string[]
|
|
143
|
+
addedTransitions: TransitionSpec[]
|
|
144
|
+
removedTransitions: TransitionSpec[]
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
export interface RuntimeState {
|
|
@@ -1102,6 +1121,247 @@ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions):
|
|
|
1102
1121
|
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
|
|
1103
1122
|
}
|
|
1104
1123
|
|
|
1124
|
+
function mapStateId(mapping: Record<string, string>, state: string): string {
|
|
1125
|
+
return mapping[state] ?? state
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function D1_behavioralPreservation(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1129
|
+
const findings: Finding[] = []
|
|
1130
|
+
const afterStates = new Set(after.states.map((state) => state.id))
|
|
1131
|
+
const afterByFromEvent = new Map<string, Set<string>>()
|
|
1132
|
+
for (const transition of after.transitions) {
|
|
1133
|
+
const key = transition.from + '|' + transition.event
|
|
1134
|
+
const set = afterByFromEvent.get(key) ?? new Set<string>()
|
|
1135
|
+
set.add(transition.to)
|
|
1136
|
+
afterByFromEvent.set(key, set)
|
|
1137
|
+
}
|
|
1138
|
+
const beforeInitMapped = mapStateId(mapping, before.init)
|
|
1139
|
+
if (beforeInitMapped !== after.init) {
|
|
1140
|
+
findings.push({
|
|
1141
|
+
code: 'D1_INIT_MISMATCH',
|
|
1142
|
+
severity: 'warning',
|
|
1143
|
+
message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
|
|
1144
|
+
evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
for (const state of before.states) {
|
|
1148
|
+
const mapped = mapStateId(mapping, state.id)
|
|
1149
|
+
if (!afterStates.has(mapped)) {
|
|
1150
|
+
findings.push({
|
|
1151
|
+
code: 'D1_MAPPED_STATE_MISSING',
|
|
1152
|
+
severity: 'error',
|
|
1153
|
+
message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
|
|
1154
|
+
evidence: { beforeState: state.id, mappedState: mapped },
|
|
1155
|
+
})
|
|
1156
|
+
continue
|
|
1157
|
+
}
|
|
1158
|
+
for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
|
|
1159
|
+
const key = mapped + '|' + transition.event
|
|
1160
|
+
const targets = afterByFromEvent.get(key)
|
|
1161
|
+
if (targets === undefined || targets.size === 0) {
|
|
1162
|
+
findings.push({
|
|
1163
|
+
code: 'D1_EVENT_DISABLED',
|
|
1164
|
+
severity: 'error',
|
|
1165
|
+
message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
|
|
1166
|
+
path: [{ from: state.id, event: transition.event, to: transition.to }],
|
|
1167
|
+
evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<string, string>): InvariantSpec {
|
|
1176
|
+
if (invariant.kind === 'never-states') {
|
|
1177
|
+
return {
|
|
1178
|
+
...invariant,
|
|
1179
|
+
id: invariant.id + ':before',
|
|
1180
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1181
|
+
states: invariant.states.map((state) => mapStateId(mapping, state)),
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (invariant.kind === 'event-before-state') {
|
|
1185
|
+
return {
|
|
1186
|
+
...invariant,
|
|
1187
|
+
id: invariant.id + ':before',
|
|
1188
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1189
|
+
state: mapStateId(mapping, invariant.state),
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, options: NormalizedOptions, mapping: Record<string, string>): CheckResult {
|
|
1196
|
+
const findings: Finding[] = []
|
|
1197
|
+
const afterStates = new Set(after.states.map((state) => state.id))
|
|
1198
|
+
const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name))
|
|
1199
|
+
for (const invariant of before.invariants ?? []) {
|
|
1200
|
+
const mapped = mapInvariantForComparison(invariant, mapping)
|
|
1201
|
+
if (mapped.kind === 'never-states') {
|
|
1202
|
+
for (const state of mapped.states) {
|
|
1203
|
+
if (!afterStates.has(state)) {
|
|
1204
|
+
findings.push({
|
|
1205
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1206
|
+
severity: 'warning',
|
|
1207
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1208
|
+
evidence: { invariant: invariant.id, state },
|
|
1209
|
+
})
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
} else if (mapped.kind === 'event-before-state') {
|
|
1213
|
+
if (!afterStates.has(mapped.state)) {
|
|
1214
|
+
findings.push({
|
|
1215
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1216
|
+
severity: 'warning',
|
|
1217
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
|
|
1218
|
+
evidence: { invariant: invariant.id, state: mapped.state },
|
|
1219
|
+
})
|
|
1220
|
+
}
|
|
1221
|
+
} else if (mapped.kind === 'var-in-range') {
|
|
1222
|
+
if (!afterVariables.has(mapped.variable)) {
|
|
1223
|
+
findings.push({
|
|
1224
|
+
code: 'D2_VARIABLE_MISSING',
|
|
1225
|
+
severity: 'warning',
|
|
1226
|
+
message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
|
|
1227
|
+
evidence: { invariant: invariant.id, variable: mapped.variable },
|
|
1228
|
+
})
|
|
1229
|
+
continue
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const violation = shortestViolationForInvariant(after, options, mapped)
|
|
1233
|
+
if (violation !== undefined) {
|
|
1234
|
+
findings.push({
|
|
1235
|
+
code: 'D2_INVARIANT_REGRESSION',
|
|
1236
|
+
severity: 'error',
|
|
1237
|
+
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
1238
|
+
path: violation.path,
|
|
1239
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length)
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function D3_regressionDelta(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1247
|
+
const afterStateIds = new Set(after.states.map((state) => state.id))
|
|
1248
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
|
|
1249
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
|
|
1250
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
|
|
1251
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
|
|
1252
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event))
|
|
1253
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
|
|
1254
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
|
|
1255
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
|
|
1256
|
+
candidate.from === mapStateId(mapping, transition.from) &&
|
|
1257
|
+
candidate.event === transition.event &&
|
|
1258
|
+
candidate.to === mapStateId(mapping, transition.to)
|
|
1259
|
+
))
|
|
1260
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
|
|
1261
|
+
mapStateId(mapping, candidate.from) === transition.from &&
|
|
1262
|
+
candidate.event === transition.event &&
|
|
1263
|
+
mapStateId(mapping, candidate.to) === transition.to
|
|
1264
|
+
))
|
|
1265
|
+
const findings: Finding[] = []
|
|
1266
|
+
for (const state of removedStates) {
|
|
1267
|
+
findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } })
|
|
1268
|
+
}
|
|
1269
|
+
for (const event of removedEvents) {
|
|
1270
|
+
findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } })
|
|
1271
|
+
}
|
|
1272
|
+
for (const transition of removedTransitions) {
|
|
1273
|
+
findings.push({
|
|
1274
|
+
code: 'D3_REMOVED_TRANSITION',
|
|
1275
|
+
severity: 'warning',
|
|
1276
|
+
message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
|
|
1277
|
+
evidence: { transition },
|
|
1278
|
+
})
|
|
1279
|
+
}
|
|
1280
|
+
const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions'
|
|
1281
|
+
return checkResult('D3', 'Regression Delta', findings, detail)
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function deadlockStateIds(model: LogicModelV1): string[] {
|
|
1285
|
+
const outgoing = new Set(model.transitions.map((transition) => transition.from))
|
|
1286
|
+
return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function closedSccStateSets(model: LogicModelV1): string[][] {
|
|
1290
|
+
return sccs(model)
|
|
1291
|
+
.filter((component) => {
|
|
1292
|
+
const componentSet = new Set(component)
|
|
1293
|
+
const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
|
|
1294
|
+
const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
|
|
1295
|
+
return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state))
|
|
1296
|
+
})
|
|
1297
|
+
.map((component) => component.sort())
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function D4_deadlockLivenessRegression(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1301
|
+
const findings: Finding[] = []
|
|
1302
|
+
const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)))
|
|
1303
|
+
const afterDeadlock = deadlockStateIds(after)
|
|
1304
|
+
for (const state of afterDeadlock) {
|
|
1305
|
+
if (!beforeDeadlock.has(state)) {
|
|
1306
|
+
findings.push({
|
|
1307
|
+
code: 'D4_DEADLOCK_REGRESSION',
|
|
1308
|
+
severity: 'error',
|
|
1309
|
+
message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
|
|
1310
|
+
evidence: { state },
|
|
1311
|
+
})
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')))
|
|
1315
|
+
const afterScc = closedSccStateSets(after)
|
|
1316
|
+
for (const component of afterScc) {
|
|
1317
|
+
const key = component.join(',')
|
|
1318
|
+
if (!beforeScc.has(key)) {
|
|
1319
|
+
findings.push({
|
|
1320
|
+
code: 'D4_LIVENESS_REGRESSION',
|
|
1321
|
+
severity: 'error',
|
|
1322
|
+
message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
|
|
1323
|
+
evidence: { states: component },
|
|
1324
|
+
})
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): ComparisonSummary {
|
|
1331
|
+
const afterStateIds = new Set(after.states.map((state) => state.id))
|
|
1332
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
|
|
1333
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
|
|
1334
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
|
|
1335
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
|
|
1336
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event))
|
|
1337
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
|
|
1338
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
|
|
1339
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
|
|
1340
|
+
candidate.from === mapStateId(mapping, transition.from) &&
|
|
1341
|
+
candidate.event === transition.event &&
|
|
1342
|
+
candidate.to === mapStateId(mapping, transition.to)
|
|
1343
|
+
))
|
|
1344
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
|
|
1345
|
+
mapStateId(mapping, candidate.from) === transition.from &&
|
|
1346
|
+
candidate.event === transition.event &&
|
|
1347
|
+
mapStateId(mapping, candidate.to) === transition.to
|
|
1348
|
+
))
|
|
1349
|
+
return {
|
|
1350
|
+
beforeModelHash: modelHash(before),
|
|
1351
|
+
afterModelHash: modelHash(after),
|
|
1352
|
+
stateMapping: mapping,
|
|
1353
|
+
beforeStates: before.states.length,
|
|
1354
|
+
beforeTransitions: before.transitions.length,
|
|
1355
|
+
afterStates: after.states.length,
|
|
1356
|
+
afterTransitions: after.transitions.length,
|
|
1357
|
+
addedStates,
|
|
1358
|
+
removedStates,
|
|
1359
|
+
addedEvents,
|
|
1360
|
+
removedEvents,
|
|
1361
|
+
addedTransitions,
|
|
1362
|
+
removedTransitions,
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1105
1365
|
// ---------------------------------------------------------------------------
|
|
1106
1366
|
// main entry
|
|
1107
1367
|
// ---------------------------------------------------------------------------
|
|
@@ -1145,6 +1405,44 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
1145
1405
|
A6_resourceInjection(model),
|
|
1146
1406
|
A7_shortestViolations(model, normalized),
|
|
1147
1407
|
]
|
|
1408
|
+
let comparison: ComparisonSummary | undefined
|
|
1409
|
+
if (options.beforeModel !== undefined) {
|
|
1410
|
+
const beforeValidation = validateModel(options.beforeModel)
|
|
1411
|
+
if (!beforeValidation.ok) {
|
|
1412
|
+
return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
schemaVersion: 1,
|
|
1415
|
+
modelHash: modelHash(model),
|
|
1416
|
+
summary: {
|
|
1417
|
+
states: model.states.length,
|
|
1418
|
+
transitions: model.transitions.length,
|
|
1419
|
+
errors: beforeValidation.errors.length,
|
|
1420
|
+
warnings: 0,
|
|
1421
|
+
checksRun: checks.length + 1,
|
|
1422
|
+
truncated: exploration.truncated,
|
|
1423
|
+
},
|
|
1424
|
+
checks: [
|
|
1425
|
+
...checks,
|
|
1426
|
+
{
|
|
1427
|
+
id: 'BEFORE_MODEL',
|
|
1428
|
+
name: 'Before Model Validation',
|
|
1429
|
+
status: 'fail',
|
|
1430
|
+
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
1431
|
+
findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
|
|
1432
|
+
},
|
|
1433
|
+
],
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const before = beforeValidation.model
|
|
1437
|
+
const mapping = options.stateMapping ?? {}
|
|
1438
|
+
checks.push(
|
|
1439
|
+
D1_behavioralPreservation(before, model, mapping),
|
|
1440
|
+
D2_invariantContinuity(before, model, normalized, mapping),
|
|
1441
|
+
D3_regressionDelta(before, model, mapping),
|
|
1442
|
+
D4_deadlockLivenessRegression(before, model, mapping),
|
|
1443
|
+
)
|
|
1444
|
+
comparison = buildComparisonSummary(before, model, mapping)
|
|
1445
|
+
}
|
|
1148
1446
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
|
|
1149
1447
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
|
|
1150
1448
|
return {
|
|
@@ -1160,5 +1458,6 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
1160
1458
|
truncated: exploration.truncated,
|
|
1161
1459
|
},
|
|
1162
1460
|
checks,
|
|
1461
|
+
...(comparison === undefined ? {} : { comparison }),
|
|
1163
1462
|
}
|
|
1164
1463
|
}
|
package/src/tool.ts
CHANGED
|
@@ -6,13 +6,15 @@ export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify'
|
|
|
6
6
|
/**
|
|
7
7
|
* Model-visible DSH tool wrapping the bundled TypeScript verification engine.
|
|
8
8
|
* The model passes a LogicModelV1 object; the engine validates it and returns
|
|
9
|
-
* the 14-check report
|
|
10
|
-
*
|
|
9
|
+
* the 14-check report, or an 18-check report when beforeModel is supplied
|
|
10
|
+
* (D1 behavioral preservation, D2 invariant continuity, D3 regression delta,
|
|
11
|
+
* D4 deadlock/liveness regression). This is the dsh-native replacement for
|
|
12
|
+
* hand-filling the Python template shipped in the skill references.
|
|
11
13
|
*/
|
|
12
14
|
export const logicProbeVerifyTool = defineTool({
|
|
13
15
|
name: LOGICPROBE_VERIFY_TOOL_NAME,
|
|
14
16
|
description:
|
|
15
|
-
'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range, and event-before-state. Returns a report with S1-S7 structural checks and A1-A7 adversarial probes including shortest counterexample paths. See skills/logicprobe/references/dsh-model-schema.md.',
|
|
17
|
+
'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range, and event-before-state. Returns a report with S1-S7 structural checks and A1-A7 adversarial probes including shortest counterexample paths. If beforeModel is provided, also runs D1-D4 before/after regression checks. See skills/logicprobe/references/dsh-model-schema.md.',
|
|
16
18
|
parameters: {
|
|
17
19
|
model: {
|
|
18
20
|
type: 'json',
|
|
@@ -27,6 +29,14 @@ export const logicProbeVerifyTool = defineTool({
|
|
|
27
29
|
type: 'integer',
|
|
28
30
|
description: 'Maximum event count for A3 order permutation. Default 5.',
|
|
29
31
|
},
|
|
32
|
+
beforeModel: {
|
|
33
|
+
type: 'json',
|
|
34
|
+
description: 'Optional BEFORE LogicModelV1 state machine model for refactoring/migration regression comparison.',
|
|
35
|
+
},
|
|
36
|
+
stateMapping: {
|
|
37
|
+
type: 'json',
|
|
38
|
+
description: 'Optional object mapping BEFORE state ids to AFTER state ids. Omit for identity mapping (same state names).',
|
|
39
|
+
},
|
|
30
40
|
},
|
|
31
41
|
output: {
|
|
32
42
|
schema: {
|
|
@@ -43,6 +53,8 @@ export const logicProbeVerifyTool = defineTool({
|
|
|
43
53
|
return runVerification(args.model, {
|
|
44
54
|
maxStates: args.maxStates,
|
|
45
55
|
maxPermutationEvents: args.maxPermutationEvents,
|
|
56
|
+
beforeModel: args.beforeModel,
|
|
57
|
+
stateMapping: args.stateMapping as Record<string, string> | undefined,
|
|
46
58
|
}) as unknown as JsonValue
|
|
47
59
|
},
|
|
48
60
|
})
|