dsh-model-everything 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +104 -76
- package/README.md +84 -71
- package/lib/automaton-tool.js +70 -0
- package/lib/automaton.js +328 -0
- package/lib/compose-tool.js +7 -0
- package/lib/concurrency-tool.js +7 -0
- package/lib/data-tool.js +7 -0
- package/lib/design-tool.js +8 -1
- package/lib/edit-tool.js +113 -0
- package/lib/editor.js +293 -0
- package/lib/engine.js +7 -0
- package/lib/export-tool.js +8 -0
- package/lib/index.js +4 -0
- package/lib/presentation.js +309 -0
- package/lib/report-diff-tool.js +7 -0
- package/lib/report-view.js +62 -10
- package/lib/routing.js +85 -0
- package/lib/scan.js +547 -0
- package/lib/structure-tool.js +35 -1
- package/lib/structure.js +28 -3
- package/lib/tool.js +7 -0
- package/lib/types/automaton-tool.d.ts +2 -0
- package/lib/types/automaton.d.ts +57 -0
- package/lib/types/edit-tool.d.ts +2 -0
- package/lib/types/editor.d.ts +46 -0
- package/lib/types/engine.d.ts +1 -0
- package/lib/types/presentation.d.ts +164 -0
- package/lib/types/routing.d.ts +31 -0
- package/lib/types/scan.d.ts +37 -0
- package/lib/types/structure.d.ts +16 -0
- package/lib/uml-tool.js +21 -6
- package/package.json +6 -3
- package/skills/model-everything/SKILL.md +6 -2
- package/skills/model-everything/references/automata.md +102 -0
- package/src/automaton-tool.ts +74 -0
- package/src/automaton.ts +376 -0
- package/src/compose-tool.ts +13 -5
- package/src/concurrency-tool.ts +8 -0
- package/src/data-tool.ts +8 -0
- package/src/design-tool.ts +76 -68
- package/src/edit-tool.ts +121 -0
- package/src/editor.ts +297 -0
- package/src/engine.ts +7 -0
- package/src/export-tool.ts +90 -81
- package/src/index.ts +4 -0
- package/src/presentation.ts +365 -0
- package/src/report-diff-tool.ts +8 -0
- package/src/report-view.ts +53 -2
- package/src/routing.ts +96 -0
- package/src/scan.ts +564 -0
- package/src/structure-tool.ts +34 -1
- package/src/structure.ts +37 -3
- package/src/tool.ts +7 -0
- package/src/uml-tool.ts +245 -230
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Automata as a verification target
|
|
2
|
+
|
|
3
|
+
What `model_automaton_verify` decides, why each decision is a theorem rather than a heuristic, and where
|
|
4
|
+
the boundary sits. Read it when a report has to be defended, or when a question lands on the edge:
|
|
5
|
+
liveness, infinite words, or a product that will not finish.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
Finite words over an explicit alphabet. A run starts at `init`, reads one symbol per transition, and is
|
|
10
|
+
accepted if it ends in `accepting`.
|
|
11
|
+
|
|
12
|
+
Not in scope, and the report says so:
|
|
13
|
+
|
|
14
|
+
- liveness, fairness, "eventually" properties on an infinite run;
|
|
15
|
+
- timing, clocks, deadlines;
|
|
16
|
+
- conformance by observation rather than by language;
|
|
17
|
+
- any abstraction. The tool reads a model, never program source.
|
|
18
|
+
|
|
19
|
+
## What each check decides, and on what authority
|
|
20
|
+
|
|
21
|
+
| Check | Decision | Authority |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `AUT003` nondeterminism | whether some state has two transitions on one symbol | the definition of a deterministic automaton |
|
|
24
|
+
| `AUT001` unreachable | whether a state is reachable from `init` | reachability is decidable in linear space |
|
|
25
|
+
| `AUT002` trap | whether a reachable state can still reach acceptance | the same reachability, run backwards |
|
|
26
|
+
| `AUT004` empty language | whether any accepting state is reachable | emptiness of a finite automaton is decidable |
|
|
27
|
+
| `AUT005` alphabet mismatch | whether the two machines can be compared at all | a language relation needs one alphabet |
|
|
28
|
+
| `AUT006` equivalence | whether two machines accept the same words | decide `L(A) \ L(B)` and `L(B) \ L(A)` |
|
|
29
|
+
| `AUT007` subset | whether every word of the first is a word of the second | decide `L(A) \ L(B)` |
|
|
30
|
+
| `AUT008` superset | the same, with the sides exchanged | decide `L(B) \ L(A)` |
|
|
31
|
+
| `AUT009` undecided | the product search stopped at its cap | a stopped search proves nothing |
|
|
32
|
+
|
|
33
|
+
Equivalence and inclusion are decided by exploring the product of the two automata and asking whether an
|
|
34
|
+
accepting side pairs with a rejecting one. A breadth-first walk returns the **shortest** word that does,
|
|
35
|
+
so the witness is the smallest counterexample there is, not merely one that exists.
|
|
36
|
+
|
|
37
|
+
## The wall, stated plainly
|
|
38
|
+
|
|
39
|
+
These are decidable problems, not approximations: automaton equivalence is NL-complete, inclusion for
|
|
40
|
+
nondeterministic machines and LTL model checking are PSPACE-complete. The cost is what grows, not the
|
|
41
|
+
certainty. Two machines of `n` states each can produce `n × n` pairs, and a model with counters or
|
|
42
|
+
queues in it can be unbounded before anyone writes a single transition.
|
|
43
|
+
|
|
44
|
+
So a cap is honest and a larger cap is not a strategy. When the product stops, the report carries
|
|
45
|
+
`AUT009_UNDECIDED` and sets `truncated: true`: the verdict is "not known", never "equal". If the same
|
|
46
|
+
happens to a state machine in `model_verify`, the report names the escalation instead of suggesting a
|
|
47
|
+
bigger budget.
|
|
48
|
+
|
|
49
|
+
## Reading, by what each one settles
|
|
50
|
+
|
|
51
|
+
Foundations, for the constructions above:
|
|
52
|
+
|
|
53
|
+
- Hopcroft, Motwani, Ullman, *Introduction to Automata Theory, Languages, and Computation* — subset
|
|
54
|
+
construction, product automata, the equivalence test.
|
|
55
|
+
- Sipser, *Introduction to the Theory of Computation* — closure properties and decidability, in the
|
|
56
|
+
gentler telling.
|
|
57
|
+
|
|
58
|
+
Model checking, which is where liveness lives and this family stops:
|
|
59
|
+
|
|
60
|
+
- Baier and Katoen, *Principles of Model Checking* — LTL, CTL, Büchi automata, fairness. The boundary in
|
|
61
|
+
the scope section above is this book's first half.
|
|
62
|
+
- Clarke, Grumberg, Peled, *Model Checking* — the classical account, including why explicit enumeration
|
|
63
|
+
runs out.
|
|
64
|
+
|
|
65
|
+
The automata-theoretic framing this tool is a finite-word shadow of:
|
|
66
|
+
|
|
67
|
+
- Kurshan, *Computer-Aided Verification of Coordinating Processes* — systems as automata, properties as
|
|
68
|
+
automata, language inclusion as the decision procedure.
|
|
69
|
+
- Vardi and Wolper, *An automata-theoretic approach to automatic program verification* — the same idea
|
|
70
|
+
for LTL, with Büchi acceptance.
|
|
71
|
+
|
|
72
|
+
Conformance, for what "implementation matches specification" means when it is made precise:
|
|
73
|
+
|
|
74
|
+
- Tretmans, *Model Based Testing with Labelled Transition Systems* — `ioco` and the family of
|
|
75
|
+
implementation relations; `subset` and `superset` are the language-level versions of that choice.
|
|
76
|
+
- Lynch and Vaandrager, *Forward and backward simulations* — refinement in two directions, which is
|
|
77
|
+
exactly the difference between asking `subset` and asking `superset`.
|
|
78
|
+
|
|
79
|
+
## What a pass does not mean
|
|
80
|
+
|
|
81
|
+
A passing report says the stated words are accepted or rejected as claimed. It does not say the model is
|
|
82
|
+
the system: the alphabet is whatever the modeller wrote, and a property that needs a symbol nobody
|
|
83
|
+
declared cannot be seen. State the alphabet next to the claim, or the verdict is about a machine that
|
|
84
|
+
exists only in the report.
|
|
85
|
+
|
|
86
|
+
## Provenance
|
|
87
|
+
|
|
88
|
+
Which parts are borrowed, and which are this repository's own decisions. A check that came from a
|
|
89
|
+
textbook is cited so a reader can check the argument; a choice that is not in any book is marked as one.
|
|
90
|
+
|
|
91
|
+
| Part | Where it comes from |
|
|
92
|
+
|---|---|
|
|
93
|
+
| subset construction for determinisation | Hopcroft, Motwani and Ullman |
|
|
94
|
+
| deciding equivalence and inclusion on the product | Hopcroft, Motwani and Ullman; Kurshan for the framing |
|
|
95
|
+
| the shortest-witness search | breadth-first search on the product; the choice to return the shortest word is this repository's |
|
|
96
|
+
| the check codes and their grouping | this repository's convention, shared with the other families |
|
|
97
|
+
| `truncated` and the `_UNDECIDED` suffix | this repository's convention: a stopped search is not a pass |
|
|
98
|
+
| the cap on the product | this repository's decision, and a practical one, not a theoretical boundary |
|
|
99
|
+
| `subset` and `superset` as the two refinements | Tretmans; Lynch and Vaandrager |
|
|
100
|
+
|
|
101
|
+
The theory above decides the checks. Nothing in this family is a heuristic dressed as a decision: every
|
|
102
|
+
code either proves a property or says it could not.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model_automaton_verify`: what language does this machine accept, and is it the one it claims.
|
|
3
|
+
*
|
|
4
|
+
* The description is written for the decision. A conformance claim - "the implementation allows every run
|
|
5
|
+
* the specification allows" - is a language inclusion, and the two machines can be small while the
|
|
6
|
+
* question is still beyond a reading: the witness is a word nobody thought to try.
|
|
7
|
+
*/
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
|
|
10
|
+
import { verifyAutomaton, type AutomatonRelation } from './automaton.js'
|
|
11
|
+
import type { JsonValue } from './json-value.js'
|
|
12
|
+
import { optionRefusal } from './option-shape.js'
|
|
13
|
+
import { renderReportText } from './report-view.js'
|
|
14
|
+
import { reportCallView, reportCard, reportResultView } from './presentation.js'
|
|
15
|
+
|
|
16
|
+
export const MODEL_AUTOMATON_TOOL_NAME = 'model_automaton_verify'
|
|
17
|
+
|
|
18
|
+
export const modelAutomatonTool = defineTool({
|
|
19
|
+
name: MODEL_AUTOMATON_TOOL_NAME,
|
|
20
|
+
description:
|
|
21
|
+
'Decide a finite-word automaton: what it accepts, whether it is empty, whether a state is a trap, and whether its language equals, contains or is contained in another automaton. Use it for a conformance claim - the protocol the code implements against the one the specification draws - where the answer is a word that one machine accepts and the other rejects, not an opinion. ' +
|
|
22
|
+
'Give `other` to compare two automata; `relation` picks equivalent (default), subset (the first is contained in the second) or superset. A failed relation returns the shortest witness word. ' +
|
|
23
|
+
'Determinism, reachability and trap states are reported whether or not `other` is given. ' +
|
|
24
|
+
'It decides finite words over an explicit alphabet. Liveness, fairness, timing and infinite words are out of scope: a Büchi or timed model is a different family, and this tool does not build one. For reachability or deadlock in a single state machine use `model_verify`; for machines that must handshake use `model_compose_verify`.',
|
|
25
|
+
parameters: {
|
|
26
|
+
automaton: {
|
|
27
|
+
type: 'json',
|
|
28
|
+
required: true,
|
|
29
|
+
description: 'The automaton: {states: [ids], alphabet: [symbols], transitions: [{from, symbol, to}], init, accepting: [ids]}. An NFA is accepted and determinised; nondeterminism is reported.',
|
|
30
|
+
},
|
|
31
|
+
other: {
|
|
32
|
+
type: 'json',
|
|
33
|
+
description: 'The automaton to compare against, in the same shape. Omit to check this one on its own.',
|
|
34
|
+
},
|
|
35
|
+
relation: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
enum: ['equivalent', 'subset', 'superset'],
|
|
38
|
+
description: 'Which language relation `other` must stand in. Default equivalent. subset: the first is contained in the second; superset: the second is contained in the first.',
|
|
39
|
+
},
|
|
40
|
+
reportView: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
enum: ['digest', 'full'],
|
|
43
|
+
description: 'digest (default) prints the readable body; full prints the structured result.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
output: {
|
|
47
|
+
schema: {
|
|
48
|
+
type: 'json',
|
|
49
|
+
description: 'the automaton report: verdict, findings, the witness word when a relation fails, and hashes of the inputs.',
|
|
50
|
+
},
|
|
51
|
+
render(args, value) {
|
|
52
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
53
|
+
},
|
|
54
|
+
// The card a UI shows, separated from the text the model reads: a verdict and the first thing wrong,
|
|
55
|
+
// never the whole report.
|
|
56
|
+
presentationMeta(args, value) {
|
|
57
|
+
return reportCard(value, MODEL_AUTOMATON_TOOL_NAME) as unknown as JsonValue
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
presentCall: reportCallView(MODEL_AUTOMATON_TOOL_NAME),
|
|
61
|
+
presentResult: reportResultView(MODEL_AUTOMATON_TOOL_NAME),
|
|
62
|
+
timeoutMs: 10_000,
|
|
63
|
+
isConcurrencySafe: () => true,
|
|
64
|
+
async execute(args) {
|
|
65
|
+
try {
|
|
66
|
+
return verifyAutomaton(args.automaton, {
|
|
67
|
+
...(args.other === undefined ? {} : { other: args.other }),
|
|
68
|
+
...(args.relation === undefined ? {} : { relation: args.relation as AutomatonRelation }),
|
|
69
|
+
}) as unknown as JsonValue
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return optionRefusal(error, 'AUTOMATON_OPTION_INVALID') as unknown as JsonValue
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
})
|
package/src/automaton.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automata as a verification target: the claims a state machine cannot express.
|
|
3
|
+
*
|
|
4
|
+
* A state machine in `model_verify` answers "can this configuration be reached". An automaton answers a
|
|
5
|
+
* different question: what language does this accept, and is that language contained in, or equal to,
|
|
6
|
+
* another one. That is the shape of a protocol conformance claim - "every run the implementation allows
|
|
7
|
+
* is a run the specification allows" - and it is not settled by reading either of the two machines.
|
|
8
|
+
*
|
|
9
|
+
* Scope, stated because it is what the report is allowed to claim:
|
|
10
|
+
*
|
|
11
|
+
* - finite words over an explicit alphabet; NFAs are determinised first;
|
|
12
|
+
* - the empty language, universality, reachability, dead states, and the relation between two automata;
|
|
13
|
+
* - not: liveness, fairness, timing, or infinite words. A Büchi or timed automaton is a different family.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
import { canonicalJson } from './engine.js'
|
|
18
|
+
import { OptionShapeError } from './option-shape.js'
|
|
19
|
+
|
|
20
|
+
export interface AutomatonTransition {
|
|
21
|
+
from: string
|
|
22
|
+
symbol: string
|
|
23
|
+
to: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Automaton {
|
|
27
|
+
states: string[]
|
|
28
|
+
alphabet: string[]
|
|
29
|
+
transitions: AutomatonTransition[]
|
|
30
|
+
init: string
|
|
31
|
+
accepting: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AutomatonFinding {
|
|
35
|
+
code: string
|
|
36
|
+
severity: 'error' | 'warning' | 'info'
|
|
37
|
+
message: string
|
|
38
|
+
detail?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One deterministic automaton, with an explicit sink so every word has exactly one run. */
|
|
42
|
+
interface Dfa {
|
|
43
|
+
start: number
|
|
44
|
+
accept: boolean[]
|
|
45
|
+
/** delta[state][symbolIndex] = next state */
|
|
46
|
+
delta: number[][]
|
|
47
|
+
/** Which machine state each index came from, for readable witnesses and findings. */
|
|
48
|
+
labels: string[][]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SINK = '\u0000sink'
|
|
52
|
+
|
|
53
|
+
/** Validate the shape, so a refusal says which field is wrong rather than throwing later. */
|
|
54
|
+
export function validateAutomaton(input: unknown): { ok: true; automaton: Automaton } | { ok: false; errors: string[] } {
|
|
55
|
+
const errors: string[] = []
|
|
56
|
+
const value = input as Record<string, unknown> | null
|
|
57
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
58
|
+
return { ok: false, errors: ['automaton must be an object with states, alphabet, transitions, init and accepting.'] }
|
|
59
|
+
}
|
|
60
|
+
const states = value.states
|
|
61
|
+
if (!Array.isArray(states) || states.length === 0 || states.some((state) => typeof state !== 'string' || state === '')) {
|
|
62
|
+
errors.push('states: must be a non-empty array of non-empty strings')
|
|
63
|
+
}
|
|
64
|
+
const alphabet = value.alphabet
|
|
65
|
+
if (!Array.isArray(alphabet) || alphabet.length === 0 || alphabet.some((symbol) => typeof symbol !== 'string' || symbol === '')) {
|
|
66
|
+
errors.push('alphabet: must be a non-empty array of non-empty strings')
|
|
67
|
+
}
|
|
68
|
+
const init = value.init
|
|
69
|
+
if (typeof init !== 'string' || init === '') errors.push('init: must be a non-empty string')
|
|
70
|
+
const accepting = value.accepting
|
|
71
|
+
if (!Array.isArray(accepting) || accepting.some((state) => typeof state !== 'string')) {
|
|
72
|
+
errors.push('accepting: must be an array of state ids')
|
|
73
|
+
}
|
|
74
|
+
const transitions = value.transitions
|
|
75
|
+
if (!Array.isArray(transitions)) errors.push('transitions: must be an array of {from, symbol, to}')
|
|
76
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
77
|
+
|
|
78
|
+
const known = new Set(states as string[])
|
|
79
|
+
if (typeof init === 'string' && !known.has(init)) errors.push(`init: names "${init}", which is not a declared state`)
|
|
80
|
+
for (const state of accepting as string[]) {
|
|
81
|
+
if (!known.has(state)) errors.push(`accepting: names "${state}", which is not a declared state`)
|
|
82
|
+
}
|
|
83
|
+
const symbols = new Set(alphabet as string[])
|
|
84
|
+
for (const [index, raw] of (transitions as unknown[]).entries()) {
|
|
85
|
+
const transition = raw as Record<string, unknown> | null
|
|
86
|
+
if (transition === null || typeof transition !== 'object') {
|
|
87
|
+
errors.push(`transitions[${index}]: must be an object`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (typeof transition.from !== 'string' || !known.has(transition.from)) errors.push(`transitions[${index}].from: "${String(transition.from)}" is not a declared state`)
|
|
91
|
+
if (typeof transition.to !== 'string' || !known.has(transition.to)) errors.push(`transitions[${index}].to: "${String(transition.to)}" is not a declared state`)
|
|
92
|
+
if (typeof transition.symbol !== 'string' || !symbols.has(transition.symbol)) errors.push(`transitions[${index}].symbol: "${String(transition.symbol)}" is not in the alphabet`)
|
|
93
|
+
}
|
|
94
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
automaton: {
|
|
98
|
+
states: states as string[],
|
|
99
|
+
alphabet: alphabet as string[],
|
|
100
|
+
transitions: (transitions as AutomatonTransition[]).map((transition) => ({ from: transition.from, symbol: transition.symbol, to: transition.to })),
|
|
101
|
+
init: init as string,
|
|
102
|
+
accepting: accepting as string[],
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Determinise by subset construction, adding one explicit sink so a word never stops early. */
|
|
108
|
+
function determinise(automaton: Automaton, symbols: string[]): Dfa {
|
|
109
|
+
const index = new Map<string, number>()
|
|
110
|
+
const subsets: string[][] = []
|
|
111
|
+
const delta: number[][] = []
|
|
112
|
+
const accept: boolean[] = []
|
|
113
|
+
const accepting = new Set(automaton.accepting)
|
|
114
|
+
const byFromAndSymbol = new Map<string, string[]>()
|
|
115
|
+
for (const transition of automaton.transitions) {
|
|
116
|
+
const key = transition.from + '\u0000' + transition.symbol
|
|
117
|
+
byFromAndSymbol.set(key, [...(byFromAndSymbol.get(key) ?? []), transition.to])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const intern = (label: string[], acceptingHere: boolean): number => {
|
|
121
|
+
const key = label.join('\u0000')
|
|
122
|
+
const existing = index.get(key)
|
|
123
|
+
if (existing !== undefined) return existing
|
|
124
|
+
const id = subsets.length
|
|
125
|
+
index.set(key, id)
|
|
126
|
+
subsets.push(label)
|
|
127
|
+
accept.push(acceptingHere)
|
|
128
|
+
delta.push([])
|
|
129
|
+
return id
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const start = intern([automaton.init], accepting.has(automaton.init))
|
|
133
|
+
// Each subset is expanded exactly once: a subset reached again - a self-loop, or a second path into
|
|
134
|
+
// the same set - only needs its edge recorded, not another pass through its transitions.
|
|
135
|
+
const pending = [start]
|
|
136
|
+
const expanded = new Set<number>([start])
|
|
137
|
+
while (pending.length > 0) {
|
|
138
|
+
const current = pending.shift() as number
|
|
139
|
+
for (const [symbolIndex, symbol] of symbols.entries()) {
|
|
140
|
+
const next = new Set<string>()
|
|
141
|
+
for (const state of subsets[current]) {
|
|
142
|
+
for (const target of byFromAndSymbol.get(state + '\u0000' + symbol) ?? []) next.add(target)
|
|
143
|
+
}
|
|
144
|
+
const label = [...next].sort()
|
|
145
|
+
const acceptingNext = label.some((state) => accepting.has(state))
|
|
146
|
+
const id = intern(label.length === 0 ? [SINK] : label, acceptingNext)
|
|
147
|
+
delta[current][symbolIndex] = id
|
|
148
|
+
if (!expanded.has(id)) {
|
|
149
|
+
expanded.add(id)
|
|
150
|
+
pending.push(id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { start, accept, delta, labels: subsets.map((subset) => (subset[0] === SINK ? [] : subset)) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The product search stopped at its cap, so the relation is undecided rather than true. */
|
|
158
|
+
const UNDECIDED = Symbol('undecided')
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Shortest word accepted by `a` but not by `b`, when one exists.
|
|
162
|
+
*
|
|
163
|
+
* The product has at most |a| x |b| states, which is fine for the machines this family is for and can
|
|
164
|
+
* still explode on two large ones. The cap turns that into "undecided", because a search that stopped
|
|
165
|
+
* is not a proof that the languages are equal.
|
|
166
|
+
*/
|
|
167
|
+
function difference(first: Dfa, second: Dfa, symbols: string[], limit = 10_000): string[] | undefined | typeof UNDECIDED {
|
|
168
|
+
const seen = new Set<string>()
|
|
169
|
+
const queue: { left: number; right: number; word: string[] }[] = [{ left: first.start, right: second.start, word: [] }]
|
|
170
|
+
seen.add(first.start + ':' + second.start)
|
|
171
|
+
let expanded = 0
|
|
172
|
+
while (queue.length > 0) {
|
|
173
|
+
if (++expanded > limit) return UNDECIDED
|
|
174
|
+
const node = queue.shift() as { left: number; right: number; word: string[] }
|
|
175
|
+
if (first.accept[node.left] && !second.accept[node.right]) return node.word
|
|
176
|
+
for (const [symbolIndex, symbol] of symbols.entries()) {
|
|
177
|
+
const left = first.delta[node.left][symbolIndex]
|
|
178
|
+
const right = second.delta[node.right][symbolIndex]
|
|
179
|
+
const key = left + ':' + right
|
|
180
|
+
if (seen.has(key)) continue
|
|
181
|
+
seen.add(key)
|
|
182
|
+
queue.push({ left, right, word: [...node.word, symbol] })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every state that can be reached from the start. */
|
|
189
|
+
function reachable(dfa: Dfa, symbols: string[]): boolean[] {
|
|
190
|
+
const seen = new Array<boolean>(dfa.delta.length).fill(false)
|
|
191
|
+
seen[dfa.start] = true
|
|
192
|
+
const queue = [dfa.start]
|
|
193
|
+
while (queue.length > 0) {
|
|
194
|
+
const current = queue.shift() as number
|
|
195
|
+
for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
|
|
196
|
+
const next = dfa.delta[current][symbolIndex]
|
|
197
|
+
if (!seen[next]) {
|
|
198
|
+
seen[next] = true
|
|
199
|
+
queue.push(next)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return seen
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Every state from which an accepting state can be reached. */
|
|
207
|
+
function productive(dfa: Dfa, symbols: string[]): boolean[] {
|
|
208
|
+
const reverse = new Map<number, number[]>()
|
|
209
|
+
for (let state = 0; state < dfa.delta.length; state++) {
|
|
210
|
+
for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
|
|
211
|
+
const target = dfa.delta[state][symbolIndex]
|
|
212
|
+
reverse.set(target, [...(reverse.get(target) ?? []), state])
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const seen = new Array<boolean>(dfa.delta.length).fill(false)
|
|
216
|
+
const queue: number[] = []
|
|
217
|
+
for (let state = 0; state < dfa.accept.length; state++) {
|
|
218
|
+
if (dfa.accept[state]) {
|
|
219
|
+
seen[state] = true
|
|
220
|
+
queue.push(state)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
while (queue.length > 0) {
|
|
224
|
+
const current = queue.shift() as number
|
|
225
|
+
for (const previous of reverse.get(current) ?? []) {
|
|
226
|
+
if (!seen[previous]) {
|
|
227
|
+
seen[previous] = true
|
|
228
|
+
queue.push(previous)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return seen
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type AutomatonRelation = 'equivalent' | 'subset' | 'superset'
|
|
236
|
+
|
|
237
|
+
export interface AutomatonReport {
|
|
238
|
+
ok: boolean
|
|
239
|
+
ran: boolean
|
|
240
|
+
verdict: 'pass' | 'pass_with_findings' | 'fail'
|
|
241
|
+
verdictReason: string
|
|
242
|
+
schema: string
|
|
243
|
+
automaton: { states: number; transitions: number; alphabet: number; deterministic: boolean }
|
|
244
|
+
findings: AutomatonFinding[]
|
|
245
|
+
/** Set when the relation fails: the shortest word that breaks it. */
|
|
246
|
+
witness?: { word: string[]; reason: string }
|
|
247
|
+
/** True when the product search stopped before deciding, so `pass` is not a proof of equality. */
|
|
248
|
+
truncated?: boolean
|
|
249
|
+
hashes: { automaton: string; other?: string }
|
|
250
|
+
nextSteps: string[]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Determinism, as declared by the input rather than guessed: a repeated (from, symbol) pair. */
|
|
254
|
+
function isDeterministic(automaton: Automaton): boolean {
|
|
255
|
+
const seen = new Set<string>()
|
|
256
|
+
for (const transition of automaton.transitions) {
|
|
257
|
+
const key = transition.from + '\u0000' + transition.symbol
|
|
258
|
+
if (seen.has(key)) return false
|
|
259
|
+
seen.add(key)
|
|
260
|
+
}
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
export function verifyAutomaton(input: unknown, options: { other?: unknown; relation?: AutomatonRelation } = {}): AutomatonReport {
|
|
266
|
+
const parsed = validateAutomaton(input)
|
|
267
|
+
if (!parsed.ok) {
|
|
268
|
+
throw new OptionShapeError('AUTOMATON_INVALID', 'the automaton is not well formed: ' + parsed.errors.join('; '))
|
|
269
|
+
}
|
|
270
|
+
const automaton = parsed.automaton
|
|
271
|
+
const symbols = [...automaton.alphabet].sort()
|
|
272
|
+
const findings: AutomatonFinding[] = []
|
|
273
|
+
const dfa = determinise(automaton, symbols)
|
|
274
|
+
const deterministic = isDeterministic(automaton)
|
|
275
|
+
|
|
276
|
+
const reach = reachable(dfa, symbols)
|
|
277
|
+
const canAccept = productive(dfa, symbols)
|
|
278
|
+
if (!deterministic) {
|
|
279
|
+
findings.push({ code: 'AUT003_NONDETERMINISTIC', severity: 'info', message: `the automaton is not deterministic: ${String(dfa.delta.length)} subset states after determinisation, ${String(automaton.states.length)} declared. The analysis used the deterministic form.` })
|
|
280
|
+
}
|
|
281
|
+
// A state no word can reach is dead weight, and a state from which no accepting state is reachable
|
|
282
|
+
// accepts nothing: the first is a modelling smell, the second is usually a defect in the intended language.
|
|
283
|
+
for (const [index, label] of dfa.labels.entries()) {
|
|
284
|
+
if (label.length === 0) continue
|
|
285
|
+
if (!dfa.accept[index] && reach[index] && !canAccept[index]) {
|
|
286
|
+
findings.push({ code: 'AUT002_TRAP_STATE', severity: 'warning', message: `state "${label[0]}" is reachable but can never reach an accepting state` })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const state of automaton.states) {
|
|
290
|
+
if (!dfa.labels.some((label) => label.includes(state))) {
|
|
291
|
+
if (!findings.some((finding) => finding.code === 'AUT001_UNREACHABLE_STATE' && finding.message.includes(`"${state}"`))) {
|
|
292
|
+
findings.push({ code: 'AUT001_UNREACHABLE_STATE', severity: 'warning', message: `state "${state}" cannot be reached from init` })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const acceptingReachable = reach.some((value, index) => value && dfa.accept[index])
|
|
298
|
+
if (!acceptingReachable) {
|
|
299
|
+
findings.push({ code: 'AUT004_EMPTY_LANGUAGE', severity: 'warning', message: 'the automaton accepts nothing: no accepting state is reachable from init' })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let witness: AutomatonReport['witness']
|
|
303
|
+
let otherForHash: Automaton | undefined
|
|
304
|
+
const relation = options.relation ?? 'equivalent'
|
|
305
|
+
if (options.other !== undefined) {
|
|
306
|
+
const otherParsed = validateAutomaton(options.other)
|
|
307
|
+
if (!otherParsed.ok) {
|
|
308
|
+
throw new OptionShapeError('AUTOMATON_INVALID', 'the second automaton is not well formed: ' + otherParsed.errors.join('; '))
|
|
309
|
+
}
|
|
310
|
+
const other = otherParsed.automaton
|
|
311
|
+
otherForHash = other
|
|
312
|
+
if (other.alphabet.length !== automaton.alphabet.length || !other.alphabet.every((symbol) => automaton.alphabet.includes(symbol))) {
|
|
313
|
+
findings.push({ code: 'AUT005_ALPHABET_MISMATCH', severity: 'error', message: 'the two automata do not share an alphabet, so a language relation between them is not defined', detail: `first: ${automaton.alphabet.join(', ')}; second: ${other.alphabet.join(', ')}` })
|
|
314
|
+
} else {
|
|
315
|
+
const second = determinise(other, symbols)
|
|
316
|
+
const missing = difference(dfa, second, symbols)
|
|
317
|
+
const extra = difference(second, dfa, symbols)
|
|
318
|
+
if (missing === UNDECIDED || extra === UNDECIDED) {
|
|
319
|
+
findings.push({ code: 'AUT009_UNDECIDED', severity: 'warning', message: 'the product search stopped at its state cap before deciding the relation, so equality is neither proven nor refuted', detail: 'the product of two automata can be exponential; reduce one of them, or decide the relation with a tool that searches symbolically' })
|
|
320
|
+
} else
|
|
321
|
+
if (relation === 'equivalent') {
|
|
322
|
+
if (missing !== undefined) {
|
|
323
|
+
witness = { word: missing, reason: 'accepted by the first automaton, rejected by the second' }
|
|
324
|
+
findings.push({ code: 'AUT006_NOT_EQUIVALENT', severity: 'error', message: 'the two automata accept different languages', detail: `shortest witness: ${missing.length === 0 ? 'the empty word' : missing.join(' ')}; ${witness.reason}` })
|
|
325
|
+
} else if (extra !== undefined) {
|
|
326
|
+
witness = { word: extra, reason: 'accepted by the second automaton, rejected by the first' }
|
|
327
|
+
findings.push({ code: 'AUT006_NOT_EQUIVALENT', severity: 'error', message: 'the two automata accept different languages', detail: `shortest witness: ${extra.length === 0 ? 'the empty word' : extra.join(' ')}; ${witness.reason}` })
|
|
328
|
+
}
|
|
329
|
+
} else if (relation === 'subset') {
|
|
330
|
+
if (missing !== undefined) {
|
|
331
|
+
witness = { word: missing, reason: 'accepted by the first automaton, rejected by the second' }
|
|
332
|
+
findings.push({ code: 'AUT007_NOT_SUBSET', severity: 'error', message: 'the first automaton accepts a word the second rejects', detail: `shortest witness: ${missing.length === 0 ? 'the empty word' : missing.join(' ')}` })
|
|
333
|
+
}
|
|
334
|
+
} else if (extra !== undefined) {
|
|
335
|
+
witness = { word: extra, reason: 'accepted by the second automaton, rejected by the first' }
|
|
336
|
+
findings.push({ code: 'AUT008_NOT_SUPERSET', severity: 'error', message: 'the second automaton accepts a word the first rejects', detail: `shortest witness: ${extra.length === 0 ? 'the empty word' : extra.join(' ')}` })
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const errorFindings = findings.filter((finding) => finding.severity === 'error')
|
|
342
|
+
const errors = errorFindings.length
|
|
343
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning').length
|
|
344
|
+
const verdict = errors > 0 ? 'fail' : warnings > 0 ? 'pass_with_findings' : 'pass'
|
|
345
|
+
const reason = errors > 0
|
|
346
|
+
? `${String(errors)} error finding(s) (first: ${errorFindings[0].code})`
|
|
347
|
+
: warnings > 0
|
|
348
|
+
? `no error findings; ${String(warnings)} warning finding(s)`
|
|
349
|
+
: 'every check passed'
|
|
350
|
+
|
|
351
|
+
const nextSteps: string[] = []
|
|
352
|
+
if (findings.some((finding) => finding.code === 'AUT004_EMPTY_LANGUAGE')) nextSteps.push('An automaton that accepts nothing usually has a transition or an accepting set wrong; check that the accepting set is reachable from init.')
|
|
353
|
+
if (witness !== undefined) nextSteps.push('The witness is the shortest word that separates the two languages; replay it through both automata to see which transition makes the difference.')
|
|
354
|
+
if (!deterministic) nextSteps.push('The analysis determinised the input; if nondeterminism was accidental, the subset count is the tell.')
|
|
355
|
+
nextSteps.push('This decides finite-word languages only. Liveness, fairness and timing are not in scope: those need a Büchi or timed model, which this family does not build.')
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
ran: true,
|
|
360
|
+
verdict,
|
|
361
|
+
verdictReason: reason,
|
|
362
|
+
schema: 'model-everything/automaton/v1',
|
|
363
|
+
automaton: { states: automaton.states.length, transitions: automaton.transitions.length, alphabet: automaton.alphabet.length, deterministic },
|
|
364
|
+
findings,
|
|
365
|
+
...(witness === undefined ? {} : { witness }),
|
|
366
|
+
...(findings.some((finding) => finding.code === 'AUT009_UNDECIDED') ? { truncated: true } : {}),
|
|
367
|
+
hashes: { automaton: hashAutomaton(automaton), ...(otherForHash === undefined ? {} : { other: hashAutomaton(otherForHash) }) },
|
|
368
|
+
nextSteps,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const createHashHex = (text: string): string => createHash('sha256').update(text).digest('hex')
|
|
373
|
+
|
|
374
|
+
function hashAutomaton(automaton: Automaton): string {
|
|
375
|
+
return createHashHex(canonicalJson({ states: [...automaton.states].sort(), alphabet: [...automaton.alphabet].sort(), transitions: automaton.transitions.map((t) => `${t.from}\u0000${t.symbol}\u0000${t.to}`).sort(), init: automaton.init, accepting: [...automaton.accepting].sort() }))
|
|
376
|
+
}
|
package/src/compose-tool.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
|
-
import { runCompositionVerification } from './engine.js'
|
|
3
|
+
import { runCompositionVerification } from './engine.js'
|
|
4
|
+
import { reportCallView, reportCard, reportResultView } from './presentation.js'
|
|
4
5
|
import type { SharedAccess, SharedLocation } from './engine.js'
|
|
5
6
|
import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
|
|
6
7
|
import { renderReportText } from './report-view.js'
|
|
@@ -38,10 +39,10 @@ export const modelComposeTool = defineTool({
|
|
|
38
39
|
type: 'integer',
|
|
39
40
|
description: 'Maximum composite states to explore. Default 10000.',
|
|
40
41
|
},
|
|
41
|
-
check: {
|
|
42
|
-
type: 'string',
|
|
43
|
-
description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
|
|
44
|
-
},
|
|
42
|
+
check: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
|
|
45
|
+
},
|
|
45
46
|
reportView: {
|
|
46
47
|
type: 'string',
|
|
47
48
|
enum: ['digest', 'full'],
|
|
@@ -56,7 +57,14 @@ export const modelComposeTool = defineTool({
|
|
|
56
57
|
render(args, value) {
|
|
57
58
|
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
|
|
58
59
|
},
|
|
60
|
+
// The card a UI shows, separated from the text the model reads.
|
|
61
|
+
presentationMeta(_args, value) {
|
|
62
|
+
return reportCard(value, COMPOSE_TOOL_NAME) as unknown as JsonValue
|
|
63
|
+
},
|
|
59
64
|
},
|
|
65
|
+
presentCall: reportCallView(COMPOSE_TOOL_NAME),
|
|
66
|
+
presentResult: reportResultView(COMPOSE_TOOL_NAME),
|
|
67
|
+
|
|
60
68
|
timeoutMs: 10_000,
|
|
61
69
|
isConcurrencySafe: () => true,
|
|
62
70
|
async execute(args) {
|
package/src/concurrency-tool.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
3
|
import { runConcurrencyScan } from './concurrency.js'
|
|
4
4
|
import { renderReportText } from './report-view.js'
|
|
5
|
+
import { reportCallView, reportCard, scanResultView } from './presentation.js'
|
|
5
6
|
|
|
6
7
|
export const INTERLEAVING_SCAN_TOOL_NAME = 'model_interleaving_scan'
|
|
7
8
|
|
|
@@ -35,7 +36,14 @@ export const modelConcurrencyScanTool = defineTool({
|
|
|
35
36
|
render(args, value) {
|
|
36
37
|
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
37
38
|
},
|
|
39
|
+
// The card a UI shows, separated from the text the model reads.
|
|
40
|
+
presentationMeta(_args, value) {
|
|
41
|
+
return reportCard(value, INTERLEAVING_SCAN_TOOL_NAME) as unknown as JsonValue
|
|
42
|
+
},
|
|
38
43
|
},
|
|
44
|
+
presentCall: reportCallView(INTERLEAVING_SCAN_TOOL_NAME),
|
|
45
|
+
presentResult: scanResultView,
|
|
46
|
+
|
|
39
47
|
timeoutMs: 10_000,
|
|
40
48
|
isConcurrencySafe: () => true,
|
|
41
49
|
async execute(args) {
|
package/src/data-tool.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.j
|
|
|
4
4
|
import { oneOf } from './model-file.js'
|
|
5
5
|
import { optionRefusal, requireArrayOption, requireObjectOption } from './option-shape.js'
|
|
6
6
|
import { renderReportText } from './report-view.js'
|
|
7
|
+
import { reportCallView, reportCard, reportResultView } from './presentation.js'
|
|
7
8
|
|
|
8
9
|
export const DATAMODEL_VERIFY_TOOL_NAME = 'model_datamodel_verify'
|
|
9
10
|
|
|
@@ -64,7 +65,14 @@ export const modelDataModelVerifyTool = defineTool({
|
|
|
64
65
|
render(args, value) {
|
|
65
66
|
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
|
|
66
67
|
},
|
|
68
|
+
// The card a UI shows, separated from the text the model reads.
|
|
69
|
+
presentationMeta(_args, value) {
|
|
70
|
+
return reportCard(value, DATAMODEL_VERIFY_TOOL_NAME) as unknown as JsonValue
|
|
71
|
+
},
|
|
67
72
|
},
|
|
73
|
+
presentCall: reportCallView(DATAMODEL_VERIFY_TOOL_NAME),
|
|
74
|
+
presentResult: reportResultView(DATAMODEL_VERIFY_TOOL_NAME),
|
|
75
|
+
|
|
68
76
|
timeoutMs: 10_000,
|
|
69
77
|
isConcurrencySafe: () => true,
|
|
70
78
|
async execute(args) {
|