dsh-logicprobe 0.5.6 → 0.6.1

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.
@@ -0,0 +1,47 @@
1
+ import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools'
2
+ import { exportModel, type ExportFormat } from './exporters.js'
3
+
4
+ export const LOGICPROBE_EXPORT_TOOL_NAME = 'logicprobe_export'
5
+
6
+ /**
7
+ * DSH tool wrapping the external-tool exporters: turn a LogicModelV1 machine into
8
+ * native input for the tool logicprobe routes to (UPPAAL / TLA+ / PRISM / SPIN).
9
+ * v1 translates the core machine; unrepresentable invariants become warnings.
10
+ */
11
+ export const logicProbeExportTool = defineTool({
12
+ name: LOGICPROBE_EXPORT_TOOL_NAME,
13
+ description:
14
+ 'Export a LogicModelV1 state machine into native input for an external verification tool (logicprobe routing). format is one of uppaal (.xta + queries), tla (TLC spec + safety property), prism (.pm + .pctl), spin (Promela + ltl). Booleans are exported as 0/1 integers; invariants the target cannot express are returned as warnings, never silently dropped. Returns { ok, format, primary, extras?, warnings? } or { ok: false, error } when the model is invalid or the PRISM enumeration is too large.',
15
+ parameters: {
16
+ model: {
17
+ type: 'json',
18
+ required: true,
19
+ description: 'LogicModelV1 state-machine model to export.',
20
+ },
21
+ format: {
22
+ type: 'string',
23
+ required: true,
24
+ enum: ['uppaal', 'tla', 'prism', 'spin'],
25
+ description: 'Target tool format.',
26
+ },
27
+ },
28
+ output: {
29
+ schema: {
30
+ type: 'json',
31
+ description: 'Export result with generated file content.',
32
+ },
33
+ render(_args, value) {
34
+ return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
35
+ },
36
+ },
37
+ timeoutMs: 10_000,
38
+ isConcurrencySafe: () => true,
39
+ async execute(args) {
40
+ try {
41
+ const result = exportModel(args.model, args.format as ExportFormat)
42
+ return { ok: true, format: result.format, primary: result.primary, ...(result.extras === undefined ? {} : { extras: result.extras }), warnings: result.warnings } as unknown as JsonValue
43
+ } catch (error) {
44
+ return { ok: false, error: error instanceof Error ? error.message : String(error) } as unknown as JsonValue
45
+ }
46
+ },
47
+ })
@@ -0,0 +1,347 @@
1
+ // External-tool exporters: turn a validated LogicModelV1 into native input for the
2
+ // tools logicprobe routes to (see skills/logicprobe/references/gap-routing-guide.md).
3
+ // v1 translates the CORE machine (states, events, transitions, integer/boolean guards
4
+ // and updates). Constructs a target cannot express are emitted as warnings/comments,
5
+ // never silently dropped. Booleans are exported as 0/1 integers everywhere.
6
+ import { validateModel, type LogicModelV1, type GuardNode, type UpdateSpec } from './engine.js'
7
+
8
+ export type ExportFormat = 'uppaal' | 'tla' | 'prism' | 'spin'
9
+
10
+ export interface ExportResult {
11
+ format: ExportFormat
12
+ primary: string
13
+ extras?: Record<string, string>
14
+ warnings: string[]
15
+ }
16
+
17
+ export interface ExportedModel {
18
+ model: LogicModelV1
19
+ stateIds: string[]
20
+ indexOf: Map<string, number>
21
+ initId: string
22
+ stateIdOf: Map<string, string>
23
+ }
24
+
25
+ export function prepareModel(input: unknown): ExportedModel {
26
+ const validation = validateModel(input)
27
+ if (!validation.ok) throw new Error('model invalid: ' + validation.errors.join('; '))
28
+ const model = validation.model
29
+ const used = new Set<string>()
30
+ const stateIds: string[] = []
31
+ const indexOf = new Map<string, number>()
32
+ const stateIdOf = new Map<string, string>()
33
+ const sanitize = (raw: string): string => {
34
+ let out = raw.replace(/[^A-Za-z0-9_]/g, '_')
35
+ if (out.length === 0 || /^[0-9]/.test(out)) out = 's_' + out
36
+ return out
37
+ }
38
+ model.states.forEach((state, index) => {
39
+ let candidate = sanitize(state.id)
40
+ if (used.has(candidate)) candidate = candidate + '_' + index
41
+ used.add(candidate)
42
+ stateIds.push(candidate)
43
+ indexOf.set(state.id, index)
44
+ stateIdOf.set(state.id, candidate)
45
+ })
46
+ return { model, stateIds, indexOf, initId: stateIdOf.get(model.init) ?? stateIds[0], stateIdOf }
47
+ }
48
+
49
+ function numValue(value: number | boolean): number {
50
+ return typeof value === 'boolean' ? (value ? 1 : 0) : value
51
+ }
52
+
53
+ function leafExpr(node: { variable: string; op: string; value: number | boolean }, varName: (v: string) => string): string {
54
+ return varName(node.variable) + ' ' + node.op + ' ' + String(numValue(node.value))
55
+ }
56
+
57
+ function guardExpr(node: GuardNode | undefined, and: string, or: string, not: string, varName: (v: string) => string, opFor: (op: string) => string = (op) => op): string {
58
+ if (node === undefined) return ''
59
+ if ('variable' in node) return varName(node.variable) + ' ' + opFor(node.op) + ' ' + String(numValue(node.value))
60
+ if ('all' in node) return '(' + node.all.map((g) => guardExpr(g, and, or, not, varName, opFor)).join(' ' + and + ' ') + ')'
61
+ if ('any' in node) return '(' + node.any.map((g) => guardExpr(g, and, or, not, varName, opFor)).join(' ' + or + ' ') + ')'
62
+ if ('not' in node) return not + '(' + guardExpr(node.not, and, or, not, varName, opFor) + ')'
63
+ return 'true'
64
+ }
65
+
66
+ function updateAssignments(updates: UpdateSpec[] | undefined, varName: (v: string) => string): string[] {
67
+ const out: string[] = []
68
+ for (const update of updates ?? []) {
69
+ const name = varName(update.variable)
70
+ const value = update.value ?? 1
71
+ if (update.op === 'set') out.push(name + ' := ' + String(value))
72
+ else if (update.op === 'inc') out.push(name + ' := ' + name + ' + ' + String(value))
73
+ else out.push(name + ' := ' + name + ' - ' + String(value))
74
+ }
75
+ return out
76
+ }
77
+
78
+ function forbiddenIndexes(ex: ExportedModel): number[] {
79
+ const out: number[] = []
80
+ for (const invariant of ex.model.invariants ?? []) {
81
+ if (invariant.kind === 'never-states') {
82
+ for (const s of invariant.states) {
83
+ const index = ex.indexOf.get(s)
84
+ if (index !== undefined) out.push(index)
85
+ }
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ // ---------------------------------------------------------------- UPPAAL ----
92
+ // UPPAAL .xta is XML (flat-1_6 DTD): nta/template/location/transition with label
93
+ // kinds guard | assignment | synchronisation. Queries are plain text (one per line).
94
+ function exportUppaal(ex: ExportedModel): ExportResult {
95
+ const { model, stateIds, indexOf, initId } = ex
96
+ const warnings: string[] = []
97
+ const xml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
98
+ const safeEvent = (event: string): string => xml(event.replace(/[^A-Za-z0-9_]/g, '_'))
99
+ const globals = (model.variables ?? []).map((v) => 'int ' + v.name + ' = ' + String(numValue(v.init)) + ';').join('\n')
100
+ const locs = model.states.map((state, i) => {
101
+ const nameEl = '\n <name x=\"16\" y=\"16\">' + xml(stateIdOf(ex, state.id)) + '</name>'
102
+ return ' <location id=\"id' + i + '\" x=\"' + (i * 120) + '\" y=\"0\">' + nameEl + '\n </location>'
103
+ }).join('\n')
104
+ const edges = model.transitions.map((tr, i) => {
105
+ const guard = guardExpr(tr.guard, '&&', '||', '!', (v) => v)
106
+ const assigns = updateAssignments(tr.updates, (v) => v)
107
+ const labels: string[] = []
108
+ if (guard !== '') labels.push(' <label kind=\"guard\" x=\"16\" y=\"16\">' + xml(guard) + '</label>')
109
+ if (assigns.length > 0) labels.push(' <label kind=\"assignment\" x=\"16\" y=\"16\">' + xml(assigns.join(',\n')) + '</label>')
110
+ labels.push(' <label kind=\"synchronisation\" x=\"16\" y=\"16\">' + safeEvent(tr.event) + '!</label>')
111
+ return ' <transition id=\"id' + String(1000 + i) + '\">\n <source ref=\"id' + String(indexOf.get(tr.from)) + '\"/>\n <target ref=\"id' + String(indexOf.get(tr.to)) + '\"/>\n' + labels.join('\n') + '\n </transition>'
112
+ }).join('\n')
113
+ const queries: string[] = []
114
+ for (const state of model.states) if (state.terminal) queries.push('E<> LogicProbe.' + stateIdOf(ex, state.id))
115
+ for (const index of forbiddenIndexes(ex)) queries.push('A[] not LogicProbe.' + stateIds[index])
116
+ for (const invariant of model.invariants ?? []) {
117
+ if (invariant.kind !== 'never-states') warnings.push('UPPAAL export carries invariant kind ' + invariant.kind + ' only as a comment (not expressible in the exported XML).')
118
+ }
119
+ const initIndex = indexOf.get(model.init) ?? 0
120
+ const xta = '<?xml version="1.0" encoding="utf-8"?>\n' +
121
+ '<!DOCTYPE nta PUBLIC \'-//Uppaal Team//DTD Flat System 1.6//EN\' \'http://www.it.uu.se/research/group/darts/uppaal/flat-1_6.dtd\'>\n' +
122
+ '<nta>\n' +
123
+ ' <declaration>// Generated by logicprobe (UPPAAL XML export; booleans as int).\n' + (globals === '' ? '' : globals + '\n') + '</declaration>\n' +
124
+ ' <template>\n' +
125
+ ' <name x=\"5\" y=\"5\">LogicProbe</name>\n' +
126
+ ' <declaration/>\n' +
127
+ locs + '\n' +
128
+ ' <init ref=\"id' + String(initIndex) + '\"/>\n' +
129
+ edges + '\n' +
130
+ ' </template>\n' +
131
+ ' <system>system LogicProbe;</system>\n' +
132
+ '</nta>\n'
133
+ const extras: Record<string, string> = { queries: queries.join('\n') + (queries.length ? '\n' : '') }
134
+ void initId
135
+ return { format: 'uppaal', primary: xta, extras, warnings }
136
+ }
137
+
138
+ function stateIdOf(ex: ExportedModel, id: string): string {
139
+ return ex.stateIdOf.get(id) ?? id
140
+ }
141
+
142
+ // ------------------------------------------------------------------ TLA+ ----
143
+ // ------------------------------------------------------------------ TLA+ ----
144
+ function exportTla(ex: ExportedModel): ExportResult {
145
+ const { model, stateIds, stateIdOf, initId } = ex
146
+ const warnings: string[] = []
147
+ const bs = String.fromCharCode(92) // backslash, composed so no escape pitfalls
148
+ const CONJ = '/' + bs // /\ (TLA conjunction)
149
+ const DISJ = bs + '/' // \/ (TLA disjunction)
150
+ const MEM = bs + 'in' // \in membership
151
+ const NOTIN = bs + 'notin' // \notin
152
+ const variables = ['pc'].concat((model.variables ?? []).map((v) => v.name))
153
+ const typeVar = (model.variables ?? []).map((v) => v.name + ' ' + MEM + ' ' + String(numValue(v.min ?? 0)) + '..' + String(Math.max(1, numValue(v.max ?? 1)))).join(' ' + CONJ + ' ')
154
+ const initParts = ['pc = "' + initId + '"'].concat((model.variables ?? []).map((v) => v.name + ' = ' + String(numValue(v.init)))).join(' ' + CONJ + ' ')
155
+ const nextParts = model.transitions.map((tr) => {
156
+ const guard = guardExpr(tr.guard, CONJ, DISJ, '~', (v) => v).replace(/ == /g, ' = ').replace(/ != /g, ' /= ')
157
+ const updates: string[] = []
158
+ for (const u of tr.updates ?? []) {
159
+ const value = u.value ?? 1
160
+ if (u.op === 'set') updates.push(u.variable + ' = ' + String(value))
161
+ else if (u.op === 'inc') updates.push(u.variable + ' = ' + u.variable + ' + ' + String(value))
162
+ else updates.push(u.variable + ' = ' + u.variable + ' - ' + String(value))
163
+ }
164
+ const pcPart = 'pc' + "'" + ' = "' + stateIdOf.get(tr.to) + '"'
165
+ const rest = updates.length ? ' ' + CONJ + ' ' + updates.join(' ' + CONJ + ' ') : ''
166
+ const guardPart = guard === '' ? 'pc = "' + stateIdOf.get(tr.from) + '"' : 'pc = "' + stateIdOf.get(tr.from) + '" ' + CONJ + ' ' + guard
167
+ return DISJ + ' ' + guardPart + ' ' + CONJ + ' ' + pcPart + rest
168
+ }).join('\n')
169
+ const forbids = forbiddenIndexes(ex).map((i) => '"' + stateIds[i] + '"')
170
+ const props: string[] = []
171
+ if (forbids.length) props.push('CheckSafety == [] (pc ' + NOTIN + ' {' + forbids.join(', ') + '})')
172
+ for (const invariant of model.invariants ?? []) {
173
+ if (invariant.kind !== 'never-states') warnings.push('TLA+ v1 does not translate invariant kind ' + invariant.kind + '; only never-states becomes a property.')
174
+ }
175
+ let spec = '---- MODULE LogicProbe ----\n' +
176
+ 'EXTENDS Integers\n' +
177
+ 'VARIABLES ' + variables.join(', ') + '\n' +
178
+ 'States == {' + stateIds.map((s) => '"' + s + '"').join(', ') + '}\n' +
179
+ 'TypeOK == pc ' + MEM + ' States' + (typeVar === '' ? '' : ' ' + CONJ + ' ' + typeVar) + '\n' +
180
+ 'Init == ' + initParts + '\n' +
181
+ 'Next ==\n' + nextParts + '\n' +
182
+ (props.length ? '\n' + props.join('\n\n') + '\n' : '') +
183
+ '====\n'
184
+ const extras: Record<string, string> = props.length ? { properties: props.join('\n') + '\n' } : {}
185
+ return { format: 'tla', primary: spec, extras, warnings }
186
+ }
187
+
188
+ // ----------------------------------------------------------------- PRISM ----
189
+ function exportPrism(ex: ExportedModel): ExportResult {
190
+ const { model, indexOf, stateIdOf, stateIds } = ex
191
+ const warnings: string[] = []
192
+ const ranges = (model.variables ?? []).map((v) => ({
193
+ name: v.name,
194
+ lo: numValue(v.min ?? 0),
195
+ hi: Math.max(1, numValue(v.max ?? 3)),
196
+ init: numValue(v.init),
197
+ }))
198
+ const valuations: Array<Record<string, number>> = []
199
+ const build = (i: number, acc: Record<string, number>) => {
200
+ if (i === ranges.length) { valuations.push(acc); return }
201
+ for (let v = ranges[i].lo; v <= ranges[i].hi; v++) { const copy = { ...acc }; copy[ranges[i].name] = v; build(i + 1, copy) }
202
+ }
203
+ build(0, {})
204
+ const size = model.states.length * Math.max(1, valuations.length)
205
+ if (size > 20000) throw new Error('PRISM enumeration too large: ' + model.states.length + ' x ' + valuations.length)
206
+ const evalGuard = (guard: GuardNode | undefined, val: Record<string, number>): boolean => {
207
+ if (guard === undefined) return true
208
+ if ('variable' in guard) {
209
+ const left = val[guard.variable] ?? numValue((model.variables ?? []).find((v) => v.name === guard.variable)?.init ?? 0)
210
+ const right = numValue(guard.value)
211
+ switch (guard.op) { case '==': return left === right; case '!=': return left !== right; case '<': return left < right; case '<=': return left <= right; case '>': return left > right; case '>=': return left >= right; default: return false }
212
+ }
213
+ if ('all' in guard) return guard.all.every((g) => evalGuard(g, val))
214
+ if ('any' in guard) return guard.any.some((g) => evalGuard(g, val))
215
+ if ('not' in guard) return !evalGuard(guard.not, val)
216
+ return false
217
+ }
218
+ const applyUpdates = (t: typeof model.transitions[number], val: Record<string, number>): Record<string, number> => {
219
+ const out = { ...val }
220
+ for (const u of t.updates ?? []) {
221
+ const cur = out[u.variable] ?? 0
222
+ const value = u.value ?? 1
223
+ if (u.op === 'set') out[u.variable] = value
224
+ else if (u.op === 'inc') out[u.variable] = cur + value
225
+ else out[u.variable] = cur - value
226
+ }
227
+ return out
228
+ }
229
+ const commands: string[] = []
230
+ for (const state of model.states) {
231
+ for (const val of valuations) {
232
+ const groups = new Map<string, typeof model.transitions>()
233
+ for (const t of model.transitions) if (t.from === state.id) { const list = groups.get(t.event) ?? []; list.push(t); groups.set(t.event, list) }
234
+ const outcomes = new Map<string, { weight: number; pc: number; val: Record<string, number> }>()
235
+ let totalW = 0
236
+ for (const group of groups.values()) {
237
+ const guarded = group.filter((t) => t.guard !== undefined && evalGuard(t.guard, val))
238
+ const chosen = guarded.length > 0 ? guarded : group.filter((t) => t.guard === undefined)
239
+ for (const t of chosen) {
240
+ const weight = t.weight ?? 1
241
+ const nv = applyUpdates(t, val)
242
+ const key = String(indexOf.get(t.to)) + '|' + JSON.stringify(nv)
243
+ const cur = outcomes.get(key)
244
+ if (cur === undefined) { outcomes.set(key, { weight, pc: indexOf.get(t.to) ?? 0, val: nv }); totalW += weight }
245
+ else cur.weight += weight
246
+ }
247
+ }
248
+ if (outcomes.size === 0) continue
249
+ const guardParts = ['pc = ' + String(indexOf.get(state.id) ?? 0)]
250
+ for (const r of ranges) guardParts.push(r.name + ' = ' + String(val[r.name] ?? r.init))
251
+ const terms: string[] = []
252
+ for (const item of outcomes.values()) {
253
+ const p = totalW > 0 ? item.weight / totalW : 0
254
+ const prob = p.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
255
+ const upd = ["(pc' = " + String(item.pc) + ")"]
256
+ for (const r of ranges) {
257
+ const v = item.val[r.name] ?? r.init
258
+ if (v !== (val[r.name] ?? r.init)) upd.push("(" + r.name + "' = " + String(v) + ")")
259
+ }
260
+ terms.push(prob + ' : ' + upd.join(' & '))
261
+ }
262
+ commands.push('[] ' + guardParts.join(' & ') + ' ->\n ' + terms.join(' +\n '))
263
+ }
264
+ }
265
+ let module = '// Generated by logicprobe (DTMC export; booleans as 0/1 integers).\n' +
266
+ 'dtmc\n\n' +
267
+ 'module LogicProbe\n' +
268
+ ' pc : [0..' + String(model.states.length - 1) + '] init ' + String(indexOf.get(model.init) ?? 0) + ';\n' +
269
+ (ranges.map((r) => ' ' + r.name + ' : [' + String(r.lo) + '..' + String(r.hi) + '] init ' + String(r.init) + ';').join('\n')) + '\n\n' +
270
+ commands.join('\n\n') + '\n' +
271
+ 'endmodule\n'
272
+ const labels: string[] = []
273
+ for (const state of model.states) if (state.terminal) labels.push('label "term_' + (stateIdOf.get(state.id) ?? 's') + '" = pc = ' + String(indexOf.get(state.id)))
274
+ const forb = forbiddenIndexes(ex)
275
+ if (forb.length) labels.push('label "forbidden" = ' + forb.map((i) => 'pc = ' + String(i)).join(' | '))
276
+ const pctl: string[] = []
277
+ for (const invariant of model.invariants ?? []) {
278
+ if (invariant.kind === 'probability') {
279
+ const target = indexOf.get(invariant.target)
280
+ if (target !== undefined) {
281
+ labels.push('label "target" = pc = ' + String(target))
282
+ pctl.push('P' + invariant.op + String(invariant.p) + ' [ F "target" ]')
283
+ }
284
+ }
285
+ }
286
+ if (forb.length) pctl.push('P>=1 [ G !"forbidden" ]')
287
+ module = module + '\n' + labels.join('\n') + '\n'
288
+ const extras: Record<string, string> = { properties: pctl.join('\n') + '\n' }
289
+ for (const invariant of model.invariants ?? []) {
290
+ if (invariant.kind !== 'probability' && invariant.kind !== 'never-states') {
291
+ warnings.push('PRISM v1 does not translate invariant kind ' + invariant.kind + '.')
292
+ }
293
+ }
294
+ void stateIds
295
+ return { format: 'prism', primary: module, extras, warnings }
296
+ }
297
+
298
+ // ------------------------------------------------------------------ SPIN ----
299
+ function exportSpin(ex: ExportedModel): ExportResult {
300
+ const { model, indexOf } = ex
301
+ const warnings: string[] = []
302
+ const varDecl = (model.variables ?? []).map((v) => 'int ' + v.name + ' = ' + String(numValue(v.init)) + ';').join('\n')
303
+ const lines: string[] = []
304
+ const promAssigns = (updates: UpdateSpec[] | undefined): string[] => {
305
+ const out: string[] = []
306
+ for (const u of updates ?? []) {
307
+ const value = u.value ?? 1
308
+ if (u.op === 'set') out.push(u.variable + ' = ' + String(value))
309
+ else if (u.op === 'inc') out.push(u.variable + ' = ' + u.variable + ' + ' + String(value))
310
+ else out.push(u.variable + ' = ' + u.variable + ' - ' + String(value))
311
+ }
312
+ return out
313
+ }
314
+ for (const t of model.transitions) {
315
+ const guard = guardExpr(t.guard, '&&', '||', '!', (v) => v)
316
+ const assigns = promAssigns(t.updates)
317
+ const cond = guard === '' ? 'pc == ' + String(indexOf.get(t.from)) : 'pc == ' + String(indexOf.get(t.from)) + ' && ' + guard
318
+ const prefix = assigns.length ? ' ' + assigns.join('; ') + ';' : ''
319
+ lines.push(' :: (' + cond + ') ->' + prefix + ' pc = ' + String(indexOf.get(t.to)) + ';')
320
+ }
321
+ for (const state of model.states) if (state.terminal) lines.push(' :: (pc == ' + String(indexOf.get(state.id)) + ') -> goto done;')
322
+ const promela = '// Generated by logicprobe (Promela v1; booleans as 0/1 integers).\n' +
323
+ 'int pc = ' + String(indexOf.get(model.init) ?? 0) + ';\n' +
324
+ (varDecl === '' ? '' : varDecl + '\n') +
325
+ 'active proctype LogicProbe() {\n' +
326
+ ' do\n' +
327
+ lines.join('\n') + '\n' +
328
+ ' od\n' +
329
+ 'done: skip\n' +
330
+ '}\n'
331
+ const extras: Record<string, string> = {}
332
+ const forb = forbiddenIndexes(ex)
333
+ if (forb.length) extras.properties = 'ltl safety { [] (!(pc == ' + forb.map((i) => String(i)).join(') && !(pc == ') + ')) }\n'
334
+ for (const invariant of model.invariants ?? []) {
335
+ if (invariant.kind !== 'never-states') warnings.push('SPIN v1 does not translate invariant kind ' + invariant.kind + '.')
336
+ }
337
+ return { format: 'spin', primary: promela, extras, warnings }
338
+ }
339
+
340
+ export function exportModel(input: unknown, format: ExportFormat): ExportResult {
341
+ const ex = prepareModel(input)
342
+ if (format === 'uppaal') return exportUppaal(ex)
343
+ if (format === 'tla') return exportTla(ex)
344
+ if (format === 'prism') return exportPrism(ex)
345
+ return exportSpin(ex)
346
+ }
347
+