dsh-logicprobe 0.3.1 → 0.5.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.
Files changed (34) hide show
  1. package/README.en-US.md +7 -1
  2. package/README.md +9 -2
  3. package/lib/concurrency-tool.js +34 -0
  4. package/lib/concurrency.js +76 -0
  5. package/lib/data-engine.js +930 -0
  6. package/lib/data-tool.js +61 -0
  7. package/lib/engine.js +1622 -1103
  8. package/lib/index.js +291 -277
  9. package/lib/tool.js +57 -45
  10. package/lib/types/concurrency-tool.d.ts +8 -0
  11. package/lib/types/concurrency.d.ts +20 -0
  12. package/lib/types/data-engine.d.ts +199 -0
  13. package/lib/types/data-tool.d.ts +10 -0
  14. package/lib/types/engine.d.ts +171 -133
  15. package/lib/types/tool.d.ts +4 -2
  16. package/package.json +79 -78
  17. package/skills/logicprobe/SKILL.md +285 -266
  18. package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
  19. package/skills/logicprobe/references/concurrency-risk-guide.md +54 -0
  20. package/skills/logicprobe/references/dsh-model-schema.md +145 -104
  21. package/skills/logicprobe/references/logic-verification-guide.md +463 -413
  22. package/skills/logicprobe/references/verification-harness.py +806 -582
  23. package/skills/logicprobe-datamodel/SKILL.md +124 -0
  24. package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
  25. package/skills/logicprobe-datamodel/references/data-model-guide.md +62 -0
  26. package/skills/logicprobe-datamodel/references/data-model-harness.py +528 -0
  27. package/skills/logicprobe-datamodel/references/data-model-schema.md +128 -0
  28. package/src/concurrency-tool.ts +37 -0
  29. package/src/concurrency.ts +102 -0
  30. package/src/data-engine.ts +1001 -0
  31. package/src/data-tool.ts +65 -0
  32. package/src/engine.ts +533 -0
  33. package/src/index.ts +315 -301
  34. package/src/tool.ts +60 -48
@@ -0,0 +1,65 @@
1
+ import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools'
2
+ import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.js'
3
+
4
+ export const LOGICPROBE_DATAMODEL_VERIFY_TOOL_NAME = 'logicprobe_datamodel_verify'
5
+
6
+ /**
7
+ * Model-visible DSH tool wrapping the bundled data-model verification engine.
8
+ * The model passes a DataModelV1 object; the engine validates it and returns
9
+ * DS/DA/DD checks. Optional beforeModel/fieldMapping/copyPairs/migrationMappings
10
+ * enable before/after data-model regression and migration coverage checks.
11
+ */
12
+ export const logicProbeDataModelVerifyTool = defineTool({
13
+ name: LOGICPROBE_DATAMODEL_VERIFY_TOOL_NAME,
14
+ description:
15
+ 'Run executable data-model verification (logicprobe-datamodel). Takes a DataModelV1 object with schemaVersion=1, entities ({name, fields, primaryKey?, uniqueKeys?, indexes?}), relationships?, invariants?, boundaryChecks?. Optional beforeModel/fieldMapping/copyPairs/migrationMappings/backupPairs enable migration coverage, copy consistency, rollback symmetry, and before/after data regression (DD1-DD4). Returns a report with DS/DA/DD checks. See skills/logicprobe-datamodel/references/data-model-schema.md.',
16
+ parameters: {
17
+ model: {
18
+ type: 'json',
19
+ required: true,
20
+ description: 'DataModelV1 data model to verify.',
21
+ },
22
+ beforeModel: {
23
+ type: 'json',
24
+ description: 'Optional BEFORE DataModelV1 data model for regression comparison.',
25
+ },
26
+ fieldMapping: {
27
+ type: 'json',
28
+ description: 'Optional object mapping BEFORE "Entity.field" paths to AFTER "Entity.field" paths.',
29
+ },
30
+ copyPairs: {
31
+ type: 'json',
32
+ description: 'Optional copy pairs: { id, sourceEntity, targetEntity, mapping: {sourceField: targetField} }.',
33
+ },
34
+ migrationMappings: {
35
+ type: 'json',
36
+ description: 'Optional migration mappings: { from: "Entity.field", to: "Entity.field", transform?, note? }.',
37
+ },
38
+ backupPairs: {
39
+ type: 'json',
40
+ description: 'Optional backup/restore pairs for DA7 rollback symmetry.',
41
+ },
42
+ },
43
+ output: {
44
+ schema: {
45
+ type: 'json',
46
+ description: 'logicprobe-datamodel verification report with summary and per-check findings.',
47
+ },
48
+ render(_args, value) {
49
+ return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
50
+ },
51
+ },
52
+ timeoutMs: 10_000,
53
+ isConcurrencySafe: () => true,
54
+ async execute(args) {
55
+ return runDataVerification(args.model, {
56
+ beforeModel: args.beforeModel,
57
+ fieldMapping: args.fieldMapping as Record<string, string> | undefined,
58
+ copyPairs: args.copyPairs as Array<{ id: string; sourceEntity: string; targetEntity: string; mapping: Record<string, string> }> | undefined,
59
+ migrationMappings: args.migrationMappings as Array<{ from: string; to: string; transform?: 'copy' | 'rename' | 'split' | 'merge' | 'drop' | 'manual'; note?: string }> | undefined,
60
+ backupPairs: args.backupPairs as Array<{ id: string; sourceEntity: string; targetEntity: string; mapping: Record<string, string> }> | undefined,
61
+ }) as unknown as JsonValue
62
+ },
63
+ })
64
+
65
+ export { DATA_ENGINE_SCHEMA_VERSION }
package/src/engine.ts CHANGED
@@ -52,12 +52,16 @@ export interface VariableSpec {
52
52
  init: number | boolean
53
53
  min?: number
54
54
  max?: number
55
+ monotonic?: 'inc' | 'dec'
55
56
  }
56
57
 
57
58
  export type InvariantSpec =
58
59
  | { id: string; description: string; kind: 'never-states'; states: string[] }
59
60
  | { id: string; description: string; kind: 'var-in-range'; variable: string; min?: number; max?: number }
60
61
  | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
62
+ | { id: string; description: string; kind: 'leads-to'; from: string; to: string }
63
+ | { id: string; description: string; kind: 'sequence'; events: string[] }
64
+ | { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
61
65
 
62
66
  export interface ResourcePairSpec {
63
67
  resource: string
@@ -81,11 +85,14 @@ export interface LogicModelV1 {
81
85
  concurrentPairs?: [string, string][]
82
86
  boundaryChecks?: BoundaryCheckSpec[]
83
87
  resourcePairs?: ResourcePairSpec[]
88
+ idempotentEvents?: string[]
84
89
  }
85
90
 
86
91
  export interface VerificationOptions {
87
92
  maxStates?: number
88
93
  maxPermutationEvents?: number
94
+ beforeModel?: unknown
95
+ stateMapping?: Record<string, string>
89
96
  }
90
97
 
91
98
  export interface PathStep {
@@ -123,6 +130,23 @@ export interface VerificationReport {
123
130
  truncated?: boolean
124
131
  }
125
132
  checks: CheckResult[]
133
+ comparison?: ComparisonSummary
134
+ }
135
+
136
+ export interface ComparisonSummary {
137
+ beforeModelHash: string
138
+ afterModelHash: string
139
+ stateMapping: Record<string, string>
140
+ beforeStates: number
141
+ beforeTransitions: number
142
+ afterStates: number
143
+ afterTransitions: number
144
+ addedStates: string[]
145
+ removedStates: string[]
146
+ addedEvents: string[]
147
+ removedEvents: string[]
148
+ addedTransitions: TransitionSpec[]
149
+ removedTransitions: TransitionSpec[]
126
150
  }
127
151
 
128
152
  export interface RuntimeState {
@@ -226,6 +250,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
226
250
  if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
227
251
  if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
228
252
  if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max) bad(path + '.max', 'must be >= min')
253
+ if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec') bad(path + '.monotonic', "must be 'inc' or 'dec'")
229
254
  if (variable.kind === 'integer' && typeof variable.init === 'number') {
230
255
  if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
231
256
  if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
@@ -259,6 +284,21 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
259
284
  } else if (invariant.kind === 'event-before-state') {
260
285
  if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
261
286
  if (typeof invariant.state !== 'string' || invariant.state.length === 0) bad(path + '.state', 'must be a non-empty string')
287
+ } else if (invariant.kind === 'leads-to') {
288
+ if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
289
+ if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
290
+ } else if (invariant.kind === 'sequence') {
291
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
292
+ else invariant.events.forEach((event, eventIndex) => {
293
+ if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
294
+ })
295
+ } else if (invariant.kind === 'atomicity') {
296
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
297
+ else invariant.events.forEach((event, eventIndex) => {
298
+ if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
299
+ })
300
+ if (typeof invariant.commit !== 'string' || invariant.commit.length === 0) bad(path + '.commit', 'must be a non-empty string')
301
+ if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string') bad(path + '.rollback', 'must be a string')
262
302
  } else {
263
303
  bad(path + '.kind', 'unknown invariant kind')
264
304
  }
@@ -286,6 +326,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
286
326
  if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
287
327
  })
288
328
  }
329
+ if (root.idempotentEvents !== undefined) {
330
+ if (!Array.isArray(root.idempotentEvents)) bad('idempotentEvents', 'must be an array')
331
+ else root.idempotentEvents.forEach((entry, index) => {
332
+ if (typeof entry !== 'string' || entry.length === 0) bad('idempotentEvents[' + index + ']', 'must be a non-empty string')
333
+ })
334
+ }
289
335
  if (root.resourcePairs !== undefined) {
290
336
  if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
291
337
  else root.resourcePairs.forEach((entry, index) => {
@@ -1102,6 +1148,449 @@ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions):
1102
1148
  return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1103
1149
  }
1104
1150
 
1151
+ function mapStateId(mapping: Record<string, string>, state: string): string {
1152
+ return mapping[state] ?? state
1153
+ }
1154
+
1155
+ function D1_behavioralPreservation(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1156
+ const findings: Finding[] = []
1157
+ const afterStates = new Set(after.states.map((state) => state.id))
1158
+ const afterByFromEvent = new Map<string, Set<string>>()
1159
+ for (const transition of after.transitions) {
1160
+ const key = transition.from + '|' + transition.event
1161
+ const set = afterByFromEvent.get(key) ?? new Set<string>()
1162
+ set.add(transition.to)
1163
+ afterByFromEvent.set(key, set)
1164
+ }
1165
+ const beforeInitMapped = mapStateId(mapping, before.init)
1166
+ if (beforeInitMapped !== after.init) {
1167
+ findings.push({
1168
+ code: 'D1_INIT_MISMATCH',
1169
+ severity: 'warning',
1170
+ message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
1171
+ evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
1172
+ })
1173
+ }
1174
+ for (const state of before.states) {
1175
+ const mapped = mapStateId(mapping, state.id)
1176
+ if (!afterStates.has(mapped)) {
1177
+ findings.push({
1178
+ code: 'D1_MAPPED_STATE_MISSING',
1179
+ severity: 'error',
1180
+ message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
1181
+ evidence: { beforeState: state.id, mappedState: mapped },
1182
+ })
1183
+ continue
1184
+ }
1185
+ for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
1186
+ const key = mapped + '|' + transition.event
1187
+ const targets = afterByFromEvent.get(key)
1188
+ if (targets === undefined || targets.size === 0) {
1189
+ findings.push({
1190
+ code: 'D1_EVENT_DISABLED',
1191
+ severity: 'error',
1192
+ message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1193
+ path: [{ from: state.id, event: transition.event, to: transition.to }],
1194
+ evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
1195
+ })
1196
+ }
1197
+ }
1198
+ }
1199
+ return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length)
1200
+ }
1201
+
1202
+ function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<string, string>): InvariantSpec {
1203
+ if (invariant.kind === 'never-states') {
1204
+ return {
1205
+ ...invariant,
1206
+ id: invariant.id + ':before',
1207
+ description: invariant.description + ' (from BEFORE)',
1208
+ states: invariant.states.map((state) => mapStateId(mapping, state)),
1209
+ }
1210
+ }
1211
+ if (invariant.kind === 'event-before-state') {
1212
+ return {
1213
+ ...invariant,
1214
+ id: invariant.id + ':before',
1215
+ description: invariant.description + ' (from BEFORE)',
1216
+ state: mapStateId(mapping, invariant.state),
1217
+ }
1218
+ }
1219
+ return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
1220
+ }
1221
+
1222
+ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, options: NormalizedOptions, mapping: Record<string, string>): CheckResult {
1223
+ const findings: Finding[] = []
1224
+ const afterStates = new Set(after.states.map((state) => state.id))
1225
+ const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name))
1226
+ for (const invariant of before.invariants ?? []) {
1227
+ const mapped = mapInvariantForComparison(invariant, mapping)
1228
+ if (mapped.kind === 'never-states') {
1229
+ for (const state of mapped.states) {
1230
+ if (!afterStates.has(state)) {
1231
+ findings.push({
1232
+ code: 'D2_MAPPED_STATE_MISSING',
1233
+ severity: 'warning',
1234
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1235
+ evidence: { invariant: invariant.id, state },
1236
+ })
1237
+ }
1238
+ }
1239
+ } else if (mapped.kind === 'event-before-state') {
1240
+ if (!afterStates.has(mapped.state)) {
1241
+ findings.push({
1242
+ code: 'D2_MAPPED_STATE_MISSING',
1243
+ severity: 'warning',
1244
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
1245
+ evidence: { invariant: invariant.id, state: mapped.state },
1246
+ })
1247
+ }
1248
+ } else if (mapped.kind === 'var-in-range') {
1249
+ if (!afterVariables.has(mapped.variable)) {
1250
+ findings.push({
1251
+ code: 'D2_VARIABLE_MISSING',
1252
+ severity: 'warning',
1253
+ message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
1254
+ evidence: { invariant: invariant.id, variable: mapped.variable },
1255
+ })
1256
+ continue
1257
+ }
1258
+ }
1259
+ const violation = shortestViolationForInvariant(after, options, mapped)
1260
+ if (violation !== undefined) {
1261
+ findings.push({
1262
+ code: 'D2_INVARIANT_REGRESSION',
1263
+ severity: 'error',
1264
+ message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
1265
+ path: violation.path,
1266
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
1267
+ })
1268
+ }
1269
+ }
1270
+ return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length)
1271
+ }
1272
+
1273
+ function D3_regressionDelta(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1274
+ const afterStateIds = new Set(after.states.map((state) => state.id))
1275
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1276
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1277
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1278
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1279
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1280
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1281
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1282
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1283
+ candidate.from === mapStateId(mapping, transition.from) &&
1284
+ candidate.event === transition.event &&
1285
+ candidate.to === mapStateId(mapping, transition.to)
1286
+ ))
1287
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1288
+ mapStateId(mapping, candidate.from) === transition.from &&
1289
+ candidate.event === transition.event &&
1290
+ mapStateId(mapping, candidate.to) === transition.to
1291
+ ))
1292
+ const findings: Finding[] = []
1293
+ for (const state of removedStates) {
1294
+ findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } })
1295
+ }
1296
+ for (const event of removedEvents) {
1297
+ findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } })
1298
+ }
1299
+ for (const transition of removedTransitions) {
1300
+ findings.push({
1301
+ code: 'D3_REMOVED_TRANSITION',
1302
+ severity: 'warning',
1303
+ message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
1304
+ evidence: { transition },
1305
+ })
1306
+ }
1307
+ const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions'
1308
+ return checkResult('D3', 'Regression Delta', findings, detail)
1309
+ }
1310
+
1311
+ function deadlockStateIds(model: LogicModelV1): string[] {
1312
+ const outgoing = new Set(model.transitions.map((transition) => transition.from))
1313
+ return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id)
1314
+ }
1315
+
1316
+ function closedSccStateSets(model: LogicModelV1): string[][] {
1317
+ return sccs(model)
1318
+ .filter((component) => {
1319
+ const componentSet = new Set(component)
1320
+ const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
1321
+ const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
1322
+ return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state))
1323
+ })
1324
+ .map((component) => component.sort())
1325
+ }
1326
+
1327
+ function D4_deadlockLivenessRegression(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1328
+ const findings: Finding[] = []
1329
+ const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)))
1330
+ const afterDeadlock = deadlockStateIds(after)
1331
+ for (const state of afterDeadlock) {
1332
+ if (!beforeDeadlock.has(state)) {
1333
+ findings.push({
1334
+ code: 'D4_DEADLOCK_REGRESSION',
1335
+ severity: 'error',
1336
+ message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1337
+ evidence: { state },
1338
+ })
1339
+ }
1340
+ }
1341
+ const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')))
1342
+ const afterScc = closedSccStateSets(after)
1343
+ for (const component of afterScc) {
1344
+ const key = component.join(',')
1345
+ if (!beforeScc.has(key)) {
1346
+ findings.push({
1347
+ code: 'D4_LIVENESS_REGRESSION',
1348
+ severity: 'error',
1349
+ message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1350
+ evidence: { states: component },
1351
+ })
1352
+ }
1353
+ }
1354
+ return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length)
1355
+ }
1356
+
1357
+ function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): ComparisonSummary {
1358
+ const afterStateIds = new Set(after.states.map((state) => state.id))
1359
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1360
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1361
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1362
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1363
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1364
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1365
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1366
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1367
+ candidate.from === mapStateId(mapping, transition.from) &&
1368
+ candidate.event === transition.event &&
1369
+ candidate.to === mapStateId(mapping, transition.to)
1370
+ ))
1371
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1372
+ mapStateId(mapping, candidate.from) === transition.from &&
1373
+ candidate.event === transition.event &&
1374
+ mapStateId(mapping, candidate.to) === transition.to
1375
+ ))
1376
+ return {
1377
+ beforeModelHash: modelHash(before),
1378
+ afterModelHash: modelHash(after),
1379
+ stateMapping: mapping,
1380
+ beforeStates: before.states.length,
1381
+ beforeTransitions: before.transitions.length,
1382
+ afterStates: after.states.length,
1383
+ afterTransitions: after.transitions.length,
1384
+ addedStates,
1385
+ removedStates,
1386
+ addedEvents,
1387
+ removedEvents,
1388
+ addedTransitions,
1389
+ removedTransitions,
1390
+ }
1391
+ }
1392
+ function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): CheckResult {
1393
+ const events = model.idempotentEvents ?? []
1394
+ const findings: Finding[] = []
1395
+ for (const event of events) {
1396
+ for (const runtime of exploration.reachable) {
1397
+ const onceOptions = stepRuntime(model, runtime, event)
1398
+ if (onceOptions.length === 0) continue
1399
+ for (const once of onceOptions) {
1400
+ const twiceOptions = stepRuntime(model, once, event)
1401
+ if (twiceOptions.length === 0) {
1402
+ findings.push({
1403
+ code: 'A8_NOT_REPLAYABLE',
1404
+ severity: 'warning',
1405
+ message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
1406
+ path: [{ from: runtime.state, event, to: once.state }],
1407
+ evidence: { state: runtime.state, event },
1408
+ })
1409
+ continue
1410
+ }
1411
+ for (const twice of twiceOptions) {
1412
+ if (runtimeKey(twice) !== runtimeKey(once)) {
1413
+ findings.push({
1414
+ code: 'A8_NOT_IDEMPOTENT',
1415
+ severity: 'error',
1416
+ message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
1417
+ path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
1418
+ evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
1419
+ })
1420
+ break
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
1427
+ }
1428
+ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1429
+ const findings: Finding[] = []
1430
+ for (const variable of model.variables ?? []) {
1431
+ if (variable.monotonic === undefined) continue
1432
+ for (const transition of model.transitions) {
1433
+ for (const update of transition.updates ?? []) {
1434
+ if (update.variable !== variable.name) continue
1435
+ if (variable.monotonic === 'inc' && update.op === 'dec') {
1436
+ findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1437
+ } else if (variable.monotonic === 'dec' && update.op === 'inc') {
1438
+ findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1439
+ } else if (update.op === 'set') {
1440
+ findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } })
1441
+ }
1442
+ }
1443
+ }
1444
+ }
1445
+ return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
1446
+ }
1447
+
1448
+ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
1449
+ if (start.state === target) return undefined
1450
+ const visited = new Set<string>()
1451
+ const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: start, path: [] }]
1452
+ while (queue.length > 0) {
1453
+ const entry = queue.shift()!
1454
+ const key = runtimeKey(entry.runtime)
1455
+ if (entry.runtime.state === target) continue
1456
+ if (visited.has(key)) return { path: entry.path, reason: 'Cycle avoids target ' + target }
1457
+ visited.add(key)
1458
+ const nexts: Array<{ next: RuntimeState; event: string }> = []
1459
+ for (const event of allEvents(model)) {
1460
+ for (const next of stepRuntime(model, entry.runtime, event)) nexts.push({ next, event })
1461
+ }
1462
+ if (nexts.length === 0) return { path: entry.path, reason: 'Dead end before target ' + target }
1463
+ for (const { next, event } of nexts) {
1464
+ queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] })
1465
+ }
1466
+ }
1467
+ return { path: [], reason: 'No path reaches target ' + target }
1468
+ }
1469
+
1470
+ function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
1471
+ const findings: Finding[] = []
1472
+ for (const invariant of model.invariants ?? []) {
1473
+ if (invariant.kind !== 'leads-to') continue
1474
+ for (const runtime of exploration.reachable) {
1475
+ if (runtime.state !== invariant.from) continue
1476
+ const bad = findLeadsToBadPath(model, runtime, invariant.to)
1477
+ if (bad !== undefined) {
1478
+ findings.push({
1479
+ code: 'A9_LEADS_TO_VIOLATION',
1480
+ severity: 'error',
1481
+ message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
1482
+ path: bad.path,
1483
+ evidence: { invariant },
1484
+ })
1485
+ break
1486
+ }
1487
+ }
1488
+ }
1489
+ return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length)
1490
+ }
1491
+
1492
+ function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
1493
+ const events = invariant.events
1494
+ const init = initialState(model)
1495
+ const key = (runtime: RuntimeState, progress: number): string => runtimeKey(runtime) + '|' + progress
1496
+ const visited = new Set<string>([key(init, 0)])
1497
+ const queue: Array<{ runtime: RuntimeState; progress: number; path: PathStep[] }> = [{ runtime: init, progress: 0, path: [] }]
1498
+ let steps = 0
1499
+ while (queue.length > 0) {
1500
+ const entry = queue.shift()!
1501
+ if (++steps > options.maxStates) break
1502
+ for (const event of allEvents(model)) {
1503
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1504
+ let progress = entry.progress
1505
+ let violation = false
1506
+ if (progress < events.length && event === events[progress]) {
1507
+ progress += 1
1508
+ } else {
1509
+ const index = events.indexOf(event)
1510
+ if (index > progress) violation = true
1511
+ }
1512
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1513
+ if (violation) {
1514
+ return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] }
1515
+ }
1516
+ const nextKey = key(next, progress)
1517
+ if (!visited.has(nextKey)) {
1518
+ visited.add(nextKey)
1519
+ queue.push({ runtime: next, progress, path })
1520
+ }
1521
+ }
1522
+ }
1523
+ }
1524
+ return undefined
1525
+ }
1526
+
1527
+ function A10_sequenceOrder(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1528
+ const findings: Finding[] = []
1529
+ for (const invariant of model.invariants ?? []) {
1530
+ if (invariant.kind !== 'sequence') continue
1531
+ const violation = findSequenceViolation(model, options, invariant)
1532
+ if (violation !== undefined) {
1533
+ findings.push({
1534
+ code: 'A10_SEQUENCE_VIOLATION',
1535
+ severity: 'error',
1536
+ message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
1537
+ path: violation.path,
1538
+ evidence: { invariant },
1539
+ })
1540
+ }
1541
+ }
1542
+ return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length)
1543
+ }
1544
+
1545
+ function findAtomicityViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'atomicity' }>): InvariantViolation | undefined {
1546
+ const atomic = new Set(invariant.events)
1547
+ const init = initialState(model)
1548
+ const key = (runtime: RuntimeState, started: boolean, closed: boolean): string => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0')
1549
+ const visited = new Set<string>([key(init, false, false)])
1550
+ const queue: Array<{ runtime: RuntimeState; started: boolean; closed: boolean; path: PathStep[] }> = [{ runtime: init, started: false, closed: false, path: [] }]
1551
+ let steps = 0
1552
+ while (queue.length > 0) {
1553
+ const entry = queue.shift()!
1554
+ if (++steps > options.maxStates) break
1555
+ for (const event of allEvents(model)) {
1556
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1557
+ const started = entry.started || atomic.has(event)
1558
+ const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback)
1559
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1560
+ if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
1561
+ return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' }
1562
+ }
1563
+ if (started && !closed && isTerminal(model, next.state)) {
1564
+ return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' }
1565
+ }
1566
+ const nextKey = key(next, started, closed)
1567
+ if (!visited.has(nextKey)) {
1568
+ visited.add(nextKey)
1569
+ queue.push({ runtime: next, started, closed, path })
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+ return undefined
1575
+ }
1576
+
1577
+ function A11_atomicity(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1578
+ const findings: Finding[] = []
1579
+ for (const invariant of model.invariants ?? []) {
1580
+ if (invariant.kind !== 'atomicity') continue
1581
+ const violation = findAtomicityViolation(model, options, invariant)
1582
+ if (violation !== undefined) {
1583
+ findings.push({
1584
+ code: 'A11_ATOMICITY_VIOLATION',
1585
+ severity: 'error',
1586
+ message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
1587
+ path: violation.path,
1588
+ evidence: { invariant },
1589
+ })
1590
+ }
1591
+ }
1592
+ return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length)
1593
+ }
1105
1594
  // ---------------------------------------------------------------------------
1106
1595
  // main entry
1107
1596
  // ---------------------------------------------------------------------------
@@ -1137,6 +1626,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
1137
1626
  S5_eventCompleteness(model),
1138
1627
  S6_guardCompleteness(model),
1139
1628
  S7_invariants(model, normalized),
1629
+ S8_monotonicVariables(model),
1140
1630
  A1_unexpectedEvents(model),
1141
1631
  A2_raceInterleaving(model, exploration),
1142
1632
  A3_orderPermutation(model, normalized),
@@ -1144,7 +1634,49 @@ export function runVerification(input: unknown, options: VerificationOptions = {
1144
1634
  A5_boundaryBlast(model),
1145
1635
  A6_resourceInjection(model),
1146
1636
  A7_shortestViolations(model, normalized),
1637
+ A8_idempotentReplay(model, exploration),
1638
+ A9_leadsTo(model, exploration),
1639
+ A10_sequenceOrder(model, normalized),
1640
+ A11_atomicity(model, normalized),
1147
1641
  ]
1642
+ let comparison: ComparisonSummary | undefined
1643
+ if (options.beforeModel !== undefined) {
1644
+ const beforeValidation = validateModel(options.beforeModel)
1645
+ if (!beforeValidation.ok) {
1646
+ return {
1647
+ ok: false,
1648
+ schemaVersion: 1,
1649
+ modelHash: modelHash(model),
1650
+ summary: {
1651
+ states: model.states.length,
1652
+ transitions: model.transitions.length,
1653
+ errors: beforeValidation.errors.length,
1654
+ warnings: 0,
1655
+ checksRun: checks.length + 1,
1656
+ truncated: exploration.truncated,
1657
+ },
1658
+ checks: [
1659
+ ...checks,
1660
+ {
1661
+ id: 'BEFORE_MODEL',
1662
+ name: 'Before Model Validation',
1663
+ status: 'fail',
1664
+ detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
1665
+ findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
1666
+ },
1667
+ ],
1668
+ }
1669
+ }
1670
+ const before = beforeValidation.model
1671
+ const mapping = options.stateMapping ?? {}
1672
+ checks.push(
1673
+ D1_behavioralPreservation(before, model, mapping),
1674
+ D2_invariantContinuity(before, model, normalized, mapping),
1675
+ D3_regressionDelta(before, model, mapping),
1676
+ D4_deadlockLivenessRegression(before, model, mapping),
1677
+ )
1678
+ comparison = buildComparisonSummary(before, model, mapping)
1679
+ }
1148
1680
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
1149
1681
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
1150
1682
  return {
@@ -1160,5 +1692,6 @@ export function runVerification(input: unknown, options: VerificationOptions = {
1160
1692
  truncated: exploration.truncated,
1161
1693
  },
1162
1694
  checks,
1695
+ ...(comparison === undefined ? {} : { comparison }),
1163
1696
  }
1164
1697
  }