dsh-logicprobe 0.8.0 → 0.9.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.en-US.md +8 -0
- package/README.md +8 -0
- package/lib/concurrency-tool.js +1 -1
- package/lib/engine.js +275 -48
- package/lib/index.js +12 -3
- package/lib/tool.js +1 -1
- package/lib/types/engine.d.ts +1 -1
- package/package.json +17 -12
- package/skills/logicprobe/SKILL.md +16 -3
- package/skills/logicprobe/references/dsh-model-schema.md +6 -5
- package/skills/logicprobe/references/logic-verification-guide.md +7 -3
- package/skills/logicprobe/references/logicprobe-engine.py +232 -42
- package/skills/logicprobe-datamodel/SKILL.md +1 -1
- package/src/concurrency-tool.ts +2 -2
- package/src/engine.ts +262 -39
- package/src/index.ts +12 -3
- package/src/tool.ts +2 -2
package/src/engine.ts
CHANGED
|
@@ -154,7 +154,7 @@ export type InvariantSpec =
|
|
|
154
154
|
when?: InvariantWhen
|
|
155
155
|
}
|
|
156
156
|
| { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
|
|
157
|
-
| { id: string; description: string; kind: 'leads-to'; from: string; to: string }
|
|
157
|
+
| { id: string; description: string; kind: 'leads-to'; from: string; to: string | string[] }
|
|
158
158
|
| { id: string; description: string; kind: 'sequence'; events: string[] }
|
|
159
159
|
| { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
|
|
160
160
|
| { id: string; description: string; kind: 'budget'; budget: number }
|
|
@@ -469,8 +469,27 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
469
469
|
} else if (invariant.kind === 'leads-to') {
|
|
470
470
|
if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
|
|
471
471
|
else if (!invariantStateIds.has(invariant.from)) bad(path + '.from', 'references unknown state ' + invariant.from)
|
|
472
|
-
|
|
473
|
-
|
|
472
|
+
// `to` is one state id or a target set. A set with a duplicate member is a
|
|
473
|
+
// model-authoring slip that would otherwise give one meaning two hashes, so it
|
|
474
|
+
// is rejected rather than silently deduplicated.
|
|
475
|
+
if (typeof invariant.to === 'string') {
|
|
476
|
+
if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
|
|
477
|
+
else if (!invariantStateIds.has(invariant.to)) bad(path + '.to', 'references unknown state ' + invariant.to)
|
|
478
|
+
} else if (Array.isArray(invariant.to)) {
|
|
479
|
+
if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty array of state ids')
|
|
480
|
+
else {
|
|
481
|
+
const targets = new Set<string>()
|
|
482
|
+
invariant.to.forEach((target, targetIndex) => {
|
|
483
|
+
const at = path + '.to[' + targetIndex + ']'
|
|
484
|
+
if (typeof target !== 'string' || target.length === 0) bad(at, 'must be a non-empty string')
|
|
485
|
+
else if (!invariantStateIds.has(target)) bad(at, 'references unknown state ' + target)
|
|
486
|
+
else if (targets.has(target)) bad(at, 'duplicates state ' + target + ' in the target set')
|
|
487
|
+
else targets.add(target)
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
bad(path + '.to', 'must be a state id or a non-empty array of state ids')
|
|
492
|
+
}
|
|
474
493
|
} else if (invariant.kind === 'sequence') {
|
|
475
494
|
if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
|
|
476
495
|
else invariant.events.forEach((event, eventIndex) => {
|
|
@@ -1108,6 +1127,96 @@ function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedO
|
|
|
1108
1127
|
return undefined
|
|
1109
1128
|
}
|
|
1110
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* Whether an invariant asserts a property of whole runs rather than of a single
|
|
1132
|
+
* runtime state. These kinds cannot be decided by a state predicate, so they are not
|
|
1133
|
+
* part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
|
|
1134
|
+
* states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
|
|
1135
|
+
* verdicts available to A7 and D2.
|
|
1136
|
+
*/
|
|
1137
|
+
function isPathPropertyInvariant(invariant: InvariantSpec): boolean {
|
|
1138
|
+
return invariant.kind === 'leads-to'
|
|
1139
|
+
|| invariant.kind === 'sequence'
|
|
1140
|
+
|| invariant.kind === 'atomicity'
|
|
1141
|
+
|| invariant.kind === 'budget'
|
|
1142
|
+
|| invariant.kind === 'probability'
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Whether a probability invariant's bound fails for the computed hit probability. */
|
|
1146
|
+
function probabilityViolated(invariant: Extract<InvariantSpec, { kind: 'probability' }>, probability: number): boolean {
|
|
1147
|
+
const eps = 1e-9
|
|
1148
|
+
if (invariant.op === '>=') return probability < invariant.p - eps
|
|
1149
|
+
if (invariant.op === '>') return probability <= invariant.p + eps
|
|
1150
|
+
if (invariant.op === '<=') return probability > invariant.p + eps
|
|
1151
|
+
return probability >= invariant.p - eps
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Every runtime state reachable from init, capped like the other searches. */
|
|
1155
|
+
function reachableRuntimeStates(model: LogicModelV1, options: NormalizedOptions): RuntimeState[] {
|
|
1156
|
+
const init = initialState(model)
|
|
1157
|
+
const visited = new Set<string>([runtimeKey(init)])
|
|
1158
|
+
const reached: RuntimeState[] = [init]
|
|
1159
|
+
const queue: RuntimeState[] = [init]
|
|
1160
|
+
let steps = 0
|
|
1161
|
+
while (queue.length > 0) {
|
|
1162
|
+
if (++steps > options.maxStates) break
|
|
1163
|
+
const current = queue.shift()!
|
|
1164
|
+
for (const event of allEvents(model)) {
|
|
1165
|
+
for (const next of stepRuntime(model, current, event)) {
|
|
1166
|
+
const key = runtimeKey(next)
|
|
1167
|
+
if (visited.has(key)) continue
|
|
1168
|
+
visited.add(key)
|
|
1169
|
+
reached.push(next)
|
|
1170
|
+
queue.push(next)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return reached
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Violation of a path-property invariant, or undefined when it holds. Each kind
|
|
1179
|
+
* delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
|
|
1180
|
+
* atomicity, A12 budget, A13 probability), so a verdict reported here can never
|
|
1181
|
+
* disagree with the probe that owns the kind.
|
|
1182
|
+
*/
|
|
1183
|
+
function pathPropertyViolation(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
|
|
1184
|
+
if (invariant.kind === 'leads-to') {
|
|
1185
|
+
const targets = new Set(leadsToTargets(invariant))
|
|
1186
|
+
const label = leadsToTargetLabel(invariant)
|
|
1187
|
+
for (const runtime of reachableRuntimeStates(model, options)) {
|
|
1188
|
+
if (runtime.state !== invariant.from) continue
|
|
1189
|
+
const bad = findLeadsToBadPath(model, runtime, targets, label)
|
|
1190
|
+
if (bad !== undefined) return { invariant, path: bad.path, reason: bad.reason }
|
|
1191
|
+
}
|
|
1192
|
+
return undefined
|
|
1193
|
+
}
|
|
1194
|
+
if (invariant.kind === 'sequence') return findSequenceViolation(model, options, invariant)
|
|
1195
|
+
if (invariant.kind === 'atomicity') return findAtomicityViolation(model, options, invariant)
|
|
1196
|
+
if (invariant.kind === 'budget') {
|
|
1197
|
+
const violation = findBudgetViolation(model, options, invariant)
|
|
1198
|
+
if (violation === undefined) return undefined
|
|
1199
|
+
return {
|
|
1200
|
+
invariant,
|
|
1201
|
+
path: violation.path,
|
|
1202
|
+
reason: violation.unbounded
|
|
1203
|
+
? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
|
|
1204
|
+
: 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (invariant.kind === 'probability') {
|
|
1208
|
+
const { probability, converged } = computeHitProbability(model, options, invariant.target)
|
|
1209
|
+
if (!probabilityViolated(invariant, probability)) return undefined
|
|
1210
|
+
return {
|
|
1211
|
+
invariant,
|
|
1212
|
+
path: [],
|
|
1213
|
+
reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
|
|
1214
|
+
+ (converged ? '' : ' (value iteration did not converge)'),
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return undefined
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1111
1220
|
function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
|
|
1112
1221
|
const init = initialState(model)
|
|
1113
1222
|
const seenInitially = false
|
|
@@ -1138,10 +1247,12 @@ function shortestEventBeforeStateViolation(model: LogicModelV1, options: Normali
|
|
|
1138
1247
|
return undefined
|
|
1139
1248
|
}
|
|
1140
1249
|
|
|
1141
|
-
function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
|
|
1250
|
+
function runInvariants(model: LogicModelV1, options: NormalizedOptions, includePathProperties = false): InvariantViolation[] {
|
|
1142
1251
|
const violations: InvariantViolation[] = []
|
|
1143
1252
|
for (const invariant of model.invariants ?? []) {
|
|
1144
|
-
const violation =
|
|
1253
|
+
const violation = includePathProperties && isPathPropertyInvariant(invariant)
|
|
1254
|
+
? pathPropertyViolation(model, options, invariant)
|
|
1255
|
+
: shortestViolationForInvariant(model, options, invariant)
|
|
1145
1256
|
if (violation !== undefined) violations.push(violation)
|
|
1146
1257
|
}
|
|
1147
1258
|
return violations
|
|
@@ -1507,14 +1618,27 @@ function A6_resourceInjection(model: LogicModelV1): CheckResult {
|
|
|
1507
1618
|
}
|
|
1508
1619
|
|
|
1509
1620
|
function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1621
|
+
// Path-property invariants are included: "for any invariant that fails" is this
|
|
1622
|
+
// check's contract, and a check that silently skips five of the eight kinds cannot
|
|
1623
|
+
// honour it. The searches for the state kinds, for sequence, atomicity and budget
|
|
1624
|
+
// are breadth-first, so their first witness is the shortest one; the leads-to walk
|
|
1625
|
+
// and the probability bound have no such guarantee, and their message says what it
|
|
1626
|
+
// found instead of claiming minimality.
|
|
1627
|
+
const violations = runInvariants(model, options, true)
|
|
1628
|
+
const findings: Finding[] = violations.map((violation) => {
|
|
1629
|
+
const pathLength = violation.path.length
|
|
1630
|
+
const message = isPathPropertyInvariant(violation.invariant)
|
|
1631
|
+
? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
|
|
1632
|
+
+ (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
|
|
1633
|
+
: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '')
|
|
1634
|
+
return {
|
|
1635
|
+
code: 'A7_SHORTEST_COUNTEREXAMPLE',
|
|
1636
|
+
severity: 'warning' as const,
|
|
1637
|
+
message,
|
|
1638
|
+
path: violation.path,
|
|
1639
|
+
evidence: { invariant: violation.invariant.id },
|
|
1640
|
+
}
|
|
1641
|
+
})
|
|
1518
1642
|
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
|
|
1519
1643
|
}
|
|
1520
1644
|
|
|
@@ -1595,6 +1719,25 @@ function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<str
|
|
|
1595
1719
|
}
|
|
1596
1720
|
return mapped
|
|
1597
1721
|
}
|
|
1722
|
+
if (invariant.kind === 'leads-to') {
|
|
1723
|
+
// A target set maps member by member; a rename that drops a state is reported by
|
|
1724
|
+
// D2's own mapped-state check rather than silently changing the property.
|
|
1725
|
+
return {
|
|
1726
|
+
...invariant,
|
|
1727
|
+
id: invariant.id + ':before',
|
|
1728
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1729
|
+
from: mapStateId(mapping, invariant.from),
|
|
1730
|
+
to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (invariant.kind === 'probability') {
|
|
1734
|
+
return {
|
|
1735
|
+
...invariant,
|
|
1736
|
+
id: invariant.id + ':before',
|
|
1737
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1738
|
+
target: mapStateId(mapping, invariant.target),
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1598
1741
|
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
|
|
1599
1742
|
}
|
|
1600
1743
|
|
|
@@ -1634,8 +1777,36 @@ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, optio
|
|
|
1634
1777
|
})
|
|
1635
1778
|
continue
|
|
1636
1779
|
}
|
|
1780
|
+
} else if (mapped.kind === 'leads-to') {
|
|
1781
|
+
// The state references of a path-property invariant must follow stateMapping too,
|
|
1782
|
+
// otherwise a pure rename searches for a state AFTER does not declare.
|
|
1783
|
+
for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
|
|
1784
|
+
if (!afterStates.has(state)) {
|
|
1785
|
+
findings.push({
|
|
1786
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1787
|
+
severity: 'warning',
|
|
1788
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1789
|
+
evidence: { invariant: invariant.id, state },
|
|
1790
|
+
})
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
} else if (mapped.kind === 'probability') {
|
|
1794
|
+
if (!afterStates.has(mapped.target)) {
|
|
1795
|
+
findings.push({
|
|
1796
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1797
|
+
severity: 'warning',
|
|
1798
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
|
|
1799
|
+
evidence: { invariant: invariant.id, state: mapped.target },
|
|
1800
|
+
})
|
|
1801
|
+
}
|
|
1637
1802
|
}
|
|
1638
|
-
|
|
1803
|
+
// Every invariant the engine can decide is re-decided against AFTER. Path-property
|
|
1804
|
+
// kinds go through the same searches their dedicated probes use, so D2 can no
|
|
1805
|
+
// longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
|
|
1806
|
+
// very same model.
|
|
1807
|
+
const violation = isPathPropertyInvariant(mapped)
|
|
1808
|
+
? pathPropertyViolation(after, options, mapped)
|
|
1809
|
+
: shortestViolationForInvariant(after, options, mapped)
|
|
1639
1810
|
if (violation !== undefined) {
|
|
1640
1811
|
findings.push({
|
|
1641
1812
|
code: 'D2_INVARIANT_REGRESSION',
|
|
@@ -1824,35 +1995,93 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
|
|
|
1824
1995
|
return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
|
|
1825
1996
|
}
|
|
1826
1997
|
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1998
|
+
/**
|
|
1999
|
+
* Target states of a `leads-to` invariant. The schema accepts one state id or a
|
|
2000
|
+
* non-empty array of them, so "reach any of these" needs no separate invariant kind.
|
|
2001
|
+
*/
|
|
2002
|
+
function leadsToTargets(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string[] {
|
|
2003
|
+
return typeof invariant.to === 'string' ? [invariant.to] : invariant.to
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
|
|
2007
|
+
function leadsToTargetLabel(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string {
|
|
2008
|
+
return leadsToTargets(invariant).join(', ')
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* Find a run from `start` that reaches none of `targets`, or return undefined when
|
|
2013
|
+
* every run reaches at least one of them. The property is universal (see the A9 row
|
|
2014
|
+
* in `SKILL.md`): one branch that loops forever, or that stops before any target,
|
|
2015
|
+
* refutes it.
|
|
2016
|
+
*
|
|
2017
|
+
* The search is a depth-first walk of the run graph with three colours, and runs at a
|
|
2018
|
+
* target are success leaves that are never expanded:
|
|
2019
|
+
*
|
|
2020
|
+
* - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
|
|
2021
|
+
* every target, so some run never reaches one;
|
|
2022
|
+
* - a node with no outgoing step at all stops the machine where it stands, which is
|
|
2023
|
+
* the same violation for a different reason;
|
|
2024
|
+
* - a node whose walk completed without a violation is BLACK, and reaching it again
|
|
2025
|
+
* from another branch is a shared sub-graph, not a cycle.
|
|
2026
|
+
*
|
|
2027
|
+
* That last point is why the colour map cannot be a plain visited set: a diamond
|
|
2028
|
+
* (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
|
|
2029
|
+
* A run is identified by its state plus its variable values, so the same state id
|
|
2030
|
+
* with different values is a different node.
|
|
2031
|
+
*/
|
|
2032
|
+
function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string): { path: PathStep[]; reason: string } | undefined {
|
|
2033
|
+
if (targets.has(start.state)) return undefined
|
|
2034
|
+
const GRAY = 1
|
|
2035
|
+
const BLACK = 2
|
|
2036
|
+
interface LeadFrame {
|
|
2037
|
+
runtime: RuntimeState
|
|
2038
|
+
path: PathStep[]
|
|
2039
|
+
/** Outgoing steps, filled on first expansion; `undefined` means "not expanded yet". */
|
|
2040
|
+
nexts: Array<{ next: RuntimeState; event: string }> | undefined
|
|
2041
|
+
index: number
|
|
2042
|
+
}
|
|
2043
|
+
const color = new Map<string, number>()
|
|
2044
|
+
const stack: LeadFrame[] = [{ runtime: start, path: [], nexts: undefined, index: 0 }]
|
|
2045
|
+
color.set(runtimeKey(start), GRAY)
|
|
2046
|
+
while (stack.length > 0) {
|
|
2047
|
+
const frame = stack[stack.length - 1]
|
|
2048
|
+
if (frame.nexts === undefined) {
|
|
2049
|
+
const nexts: Array<{ next: RuntimeState; event: string }> = []
|
|
2050
|
+
for (const event of allEvents(model)) {
|
|
2051
|
+
for (const next of stepRuntime(model, frame.runtime, event)) nexts.push({ next, event })
|
|
2052
|
+
}
|
|
2053
|
+
frame.nexts = nexts
|
|
2054
|
+
if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + label }
|
|
1840
2055
|
}
|
|
1841
|
-
if (nexts.length
|
|
1842
|
-
|
|
1843
|
-
|
|
2056
|
+
if (frame.index >= frame.nexts.length) {
|
|
2057
|
+
color.set(runtimeKey(frame.runtime), BLACK)
|
|
2058
|
+
stack.pop()
|
|
2059
|
+
continue
|
|
1844
2060
|
}
|
|
2061
|
+
const { next, event } = frame.nexts[frame.index]
|
|
2062
|
+
frame.index += 1
|
|
2063
|
+
const step: PathStep = { from: frame.runtime.state, event, to: next.state }
|
|
2064
|
+
if (targets.has(next.state)) continue
|
|
2065
|
+
const key = runtimeKey(next)
|
|
2066
|
+
const seen = color.get(key)
|
|
2067
|
+
if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label }
|
|
2068
|
+
if (seen === BLACK) continue
|
|
2069
|
+
color.set(key, GRAY)
|
|
2070
|
+
stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 })
|
|
1845
2071
|
}
|
|
1846
|
-
|
|
2072
|
+
// Every branch either reached a target or rejoined a branch that did.
|
|
2073
|
+
return undefined
|
|
1847
2074
|
}
|
|
1848
2075
|
|
|
1849
2076
|
function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
|
|
1850
2077
|
const findings: Finding[] = []
|
|
1851
2078
|
for (const invariant of model.invariants ?? []) {
|
|
1852
2079
|
if (invariant.kind !== 'leads-to') continue
|
|
2080
|
+
const targets = new Set(leadsToTargets(invariant))
|
|
2081
|
+
const label = leadsToTargetLabel(invariant)
|
|
1853
2082
|
for (const runtime of exploration.reachable) {
|
|
1854
2083
|
if (runtime.state !== invariant.from) continue
|
|
1855
|
-
const bad = findLeadsToBadPath(model, runtime,
|
|
2084
|
+
const bad = findLeadsToBadPath(model, runtime, targets, label)
|
|
1856
2085
|
if (bad !== undefined) {
|
|
1857
2086
|
findings.push({
|
|
1858
2087
|
code: 'A9_LEADS_TO_VIOLATION',
|
|
@@ -2276,16 +2505,10 @@ function computeHitProbability(model: LogicModelV1, options: NormalizedOptions,
|
|
|
2276
2505
|
|
|
2277
2506
|
function A13_probability(model: LogicModelV1, options: NormalizedOptions): CheckResult {
|
|
2278
2507
|
const findings: Finding[] = []
|
|
2279
|
-
const eps = 1e-9
|
|
2280
2508
|
for (const invariant of model.invariants ?? []) {
|
|
2281
2509
|
if (invariant.kind !== 'probability') continue
|
|
2282
2510
|
const { probability, converged } = computeHitProbability(model, options, invariant.target)
|
|
2283
|
-
|
|
2284
|
-
if (invariant.op === '>=') violated = probability < invariant.p - eps
|
|
2285
|
-
else if (invariant.op === '>') violated = probability <= invariant.p + eps
|
|
2286
|
-
else if (invariant.op === '<=') violated = probability > invariant.p + eps
|
|
2287
|
-
else violated = probability >= invariant.p - eps
|
|
2288
|
-
if (violated) {
|
|
2511
|
+
if (probabilityViolated(invariant, probability)) {
|
|
2289
2512
|
findings.push({
|
|
2290
2513
|
code: 'A13_PROBABILITY_VIOLATION',
|
|
2291
2514
|
severity: 'error',
|
package/src/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ export type InteractionMode = 'ask' | 'auto' | 'follow-approval'
|
|
|
75
75
|
const DEFAULT_GATE_CONTENT = `<EXTREMELY_IMPORTANT>
|
|
76
76
|
Plugin logicprobe is active. Documents are not truth — code is. Verify every verifiable claim before accepting or acting on any design.
|
|
77
77
|
|
|
78
|
-
**1% Rule**: If there is even a 1% chance the logicprobe skill applies — reviewing design documents, architecture specs, technical proposals, or refactoring plans that make claims about API names, file locations, enum values, mechanism feasibility, state machines, protocol logic, data models, schema migrations, data invariants,
|
|
78
|
+
**1% Rule**: If there is even a 1% chance the logicprobe skill applies — reviewing design documents, architecture specs, technical proposals, or refactoring plans that make claims about API names, file locations, enum values, mechanism feasibility, state machines, protocol logic, data models, schema migrations, data invariants, behavioral guarantees ("always"/"never"/"guaranteed"), worst-case path cost or declared budgets, probabilistic reachability ("≥90% of runs reach SAFE"), deadlines ("must leave within 2 ticks"), cross-machine handshakes, or concurrency guarantees ("thread-safe"/"lock-free"/"ISR-safe") — load it with the skill tool before responding. The cost of loading is trivial compared to the cost of a false claim.
|
|
79
79
|
|
|
80
80
|
**Red Flags** — if you think any of these, STOP. You are rationalizing:
|
|
81
81
|
|
|
@@ -86,7 +86,16 @@ Plugin logicprobe is active. Documents are not truth — code is. Verify every v
|
|
|
86
86
|
| "I'll verify while implementing" | Verification happens before implementation, not during. |
|
|
87
87
|
| "I can check this with reasoning alone" | Behavioral claims are verified with code/models, not intuition. One counter-example refutes a universal claim. |
|
|
88
88
|
|
|
89
|
-
**Native verification path
|
|
89
|
+
**Native verification path** (dsh) — six model-invocable tools:
|
|
90
|
+
|
|
91
|
+
- \`logicprobe_verify\` — state machines: S1-S8 structural + A1-A14 adversarial, including A12 worst-case path cost against a declared \`budget\`, A13 probabilistic reachability over a DTMC, and A14 discrete-tick deadlines. Pass \`beforeModel\` + \`stateMapping\` to add the D1-D4 before/after regression.
|
|
92
|
+
- \`logicprobe_datamodel_verify\` — data models and schema migrations: DS/DA checks, migration coverage, copy consistency, rollback symmetry, DD1-DD4 data regression.
|
|
93
|
+
- \`logicprobe_compose_verify\` — two or more machines checked together (rendezvous handshake semantics): C1 composition deadlock, C2 rendezvous that never fires.
|
|
94
|
+
- \`logicprobe_concurrency_scan\` — mine concurrency claims (thread-safe, lock-free, race condition, mutex, ISR-safe) and route them to dedicated verification; logicprobe does not prove concurrency safety.
|
|
95
|
+
- \`logicprobe_export\` — emit external-checker input from a verified model: UPPAAL, TLA+, PRISM, SPIN.
|
|
96
|
+
- \`logicprobe_uml\` — model a code flow as UML (render), read a diagram back into a model (parse), or audit the modelling (review: UML001-UML019 structural defects, documentation gaps, diagram-versus-model round-trip fidelity).
|
|
97
|
+
|
|
98
|
+
Python harnesses in the skill references remain the fallback for non-dsh hosts.
|
|
90
99
|
|
|
91
100
|
**Proactive suggestion**: When a user asks code-level behavioral questions — "could this state machine deadlock", "is this retry limit safe", "check this timing sequence for bugs", "is this migration non-breaking", "does this copy cover all required fields" — suggest logicprobe as an optional verification pass (do not auto-escalate).
|
|
92
101
|
</EXTREMELY_IMPORTANT>`
|
|
@@ -211,7 +220,7 @@ function resolveInteraction(config: Config, session: Session): 'ask' | 'auto' {
|
|
|
211
220
|
function modeContextText(config: Config, session: Session): string {
|
|
212
221
|
const interaction = resolveInteraction(config, session)
|
|
213
222
|
const lines = [
|
|
214
|
-
'logicprobe:
|
|
223
|
+
'logicprobe: native tools — `logicprobe_verify` (state machines: S1-S8 + A1-A14, incl. A12 budget/worst-case path cost, A13 probabilistic reachability, A14 deadlines; `beforeModel` adds D1-D4 regression), `logicprobe_datamodel_verify` (data models and schema migrations: DS/DA, migration coverage, copy consistency, rollback symmetry, DD1-DD4), `logicprobe_compose_verify` (two or more machines: C1 composition deadlock, C2 rendezvous never fires), `logicprobe_concurrency_scan` (mine concurrency claims — it does not prove concurrency safety), `logicprobe_export` (UPPAAL, TLA+, PRISM, SPIN input from a verified model).',
|
|
215
224
|
'logicprobe: use `logicprobe_uml` to model a code flow as UML (render), to read a UML diagram back into a model (parse), or to audit the modelling (review: structural defects, documentation gaps, diagram-vs-model round-trip fidelity).',
|
|
216
225
|
interaction === 'auto'
|
|
217
226
|
? 'logicprobe interaction=auto: do NOT call ask_user_question for model confirmation; run round-trip validation of the extracted transition table and mark the result UNCONFIRMED.'
|
package/src/tool.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
3
|
import { runVerification } from './engine.js'
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify'
|
|
|
16
16
|
export const logicProbeVerifyTool = defineTool({
|
|
17
17
|
name: LOGICPROBE_VERIFY_TOOL_NAME,
|
|
18
18
|
description:
|
|
19
|
-
'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?, cost?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?, idempotentEvents?, narrative?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range ({variable, min?, max?, when?} — when scopes the range to a state ({state}) or a guard node, is evaluated on the post-state of every transition, leaves the initial state checked unconditionally, and is rejected on any other invariant kind), event-before-state, leads-to, sequence, atomicity, budget (transition cost defaults to 1; A12 checks every reachable path stays within the declared budget and reports the shortest over-budget counterexample), and probability (transition weight, default 1, makes the model a DTMC; A13 checks P(hit target) against the bound by value iteration). The schema is closed: an undeclared key on any model part (model, state, transition, update, variable, invariant, boundaryCheck, resourcePair, narrative, scenario) is a validation error, never ignored. Variables support monotonic inc/dec. The optional narrative block carries natural-language descriptions of states (narrative.states), events (narrative.events), and (state, event) scenarios (narrative.scenarios: [{from, event, scenario}]); when present it must fully cover the model and is echoed in the report. Returns a report with S1-S8 structural checks and A1-
|
|
19
|
+
'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?, cost?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?, idempotentEvents?, narrative?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range ({variable, min?, max?, when?} — when scopes the range to a state ({state}) or a guard node, is evaluated on the post-state of every transition, leaves the initial state checked unconditionally, and is rejected on any other invariant kind), event-before-state, leads-to (`to` is one state id or a non-empty array of them: every path must reach at least one member), sequence, atomicity, budget (transition cost defaults to 1; A12 checks every reachable path stays within the declared budget and reports the shortest over-budget counterexample), and probability (transition weight, default 1, makes the model a DTMC; A13 checks P(hit target) against the bound by value iteration), and deadline (declaring maxTicks on a state is how many tick steps it may stay resident and the top-level tickEvents list names those steps; A14 reports the over-residency path, and A14_NO_TICK_EVENTS when maxTicks is declared without tickEvents). The schema is closed: an undeclared key on any model part (model, state, transition, update, variable, invariant, boundaryCheck, resourcePair, narrative, scenario) is a validation error, never ignored. Variables support monotonic inc/dec. The optional narrative block carries natural-language descriptions of states (narrative.states), events (narrative.events), and (state, event) scenarios (narrative.scenarios: [{from, event, scenario}]); when present it must fully cover the model and is echoed in the report. Returns a report with S1-S8 structural checks and A1-A14 adversarial, cost, probability and deadline probes, including counterexample paths (shortest where the search is breadth-first). If beforeModel is provided, also runs D1-D4 before/after regression checks. See skills/logicprobe/references/dsh-model-schema.md.',
|
|
20
20
|
parameters: {
|
|
21
21
|
model: {
|
|
22
22
|
type: 'json',
|