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 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 安装注意:包名已使用 scoped 形式 `dsh-logicprobe`。在 web profile 的 `package.json` 中,依赖键与 `dsh.profile.bundles` 必须写 `dsh-logicprobe`;否则 dsh 加载器会因找不到 `node_modules/dsh-logicprobe` 而启动失败。
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. This is the dsh-native replacement for hand-filling the
8
- * Python template shipped in the skill references.
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
  });
@@ -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;
@@ -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. This is the dsh-native replacement for hand-filling the
6
- * Python template shipped in the skill references.
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.1",
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. This is the dsh-native replacement for hand-filling the
10
- * Python template shipped in the skill references.
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
  })