dsh-logicprobe 0.4.0 → 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 (33) hide show
  1. package/README.en-US.md +7 -1
  2. package/README.md +7 -1
  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 -1364
  8. package/lib/index.js +291 -277
  9. package/lib/tool.js +57 -57
  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 -151
  15. package/package.json +79 -78
  16. package/skills/logicprobe/SKILL.md +285 -268
  17. package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
  18. package/skills/logicprobe/references/concurrency-risk-guide.md +54 -0
  19. package/skills/logicprobe/references/dsh-model-schema.md +145 -129
  20. package/skills/logicprobe/references/logic-verification-guide.md +463 -413
  21. package/skills/logicprobe/references/verification-harness.py +806 -582
  22. package/skills/logicprobe-datamodel/SKILL.md +124 -0
  23. package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
  24. package/skills/logicprobe-datamodel/references/data-model-guide.md +62 -0
  25. package/skills/logicprobe-datamodel/references/data-model-harness.py +528 -0
  26. package/skills/logicprobe-datamodel/references/data-model-schema.md +128 -0
  27. package/src/concurrency-tool.ts +37 -0
  28. package/src/concurrency.ts +102 -0
  29. package/src/data-engine.ts +1001 -0
  30. package/src/data-tool.ts +65 -0
  31. package/src/engine.ts +234 -0
  32. package/src/index.ts +315 -301
  33. package/src/tool.ts +60 -60
@@ -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,6 +85,7 @@ 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 {
@@ -245,6 +250,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
245
250
  if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
246
251
  if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
247
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'")
248
254
  if (variable.kind === 'integer' && typeof variable.init === 'number') {
249
255
  if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
250
256
  if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
@@ -278,6 +284,21 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
278
284
  } else if (invariant.kind === 'event-before-state') {
279
285
  if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
280
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')
281
302
  } else {
282
303
  bad(path + '.kind', 'unknown invariant kind')
283
304
  }
@@ -305,6 +326,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
305
326
  if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
306
327
  })
307
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
+ }
308
335
  if (root.resourcePairs !== undefined) {
309
336
  if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
310
337
  else root.resourcePairs.forEach((entry, index) => {
@@ -1362,6 +1389,208 @@ function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mappi
1362
1389
  removedTransitions,
1363
1390
  }
1364
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
+ }
1365
1594
  // ---------------------------------------------------------------------------
1366
1595
  // main entry
1367
1596
  // ---------------------------------------------------------------------------
@@ -1397,6 +1626,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
1397
1626
  S5_eventCompleteness(model),
1398
1627
  S6_guardCompleteness(model),
1399
1628
  S7_invariants(model, normalized),
1629
+ S8_monotonicVariables(model),
1400
1630
  A1_unexpectedEvents(model),
1401
1631
  A2_raceInterleaving(model, exploration),
1402
1632
  A3_orderPermutation(model, normalized),
@@ -1404,6 +1634,10 @@ export function runVerification(input: unknown, options: VerificationOptions = {
1404
1634
  A5_boundaryBlast(model),
1405
1635
  A6_resourceInjection(model),
1406
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),
1407
1641
  ]
1408
1642
  let comparison: ComparisonSummary | undefined
1409
1643
  if (options.beforeModel !== undefined) {