dsh-math-modeling-agent 0.2.8 → 0.4.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.
- package/README.md +16 -1
- package/package.json +1 -1
- package/skills/math-modeling-agent/SKILL.md +27 -14
- package/skills/math-modeling-agent/references/claims-evidence.md +3 -1
- package/skills/math-modeling-agent/references/interaction-protocol.md +13 -1
- package/skills/math-modeling-agent/references/math-grill.md +8 -0
- package/skills/math-modeling-agent/references/original-project-parity.md +19 -0
- package/skills/math-modeling-agent/references/report-contract.md +6 -0
- package/skills/math-modeling-agent/references/subagent-dispatch.md +3 -2
- package/skills/math-modeling-agent/references/tool-policy.md +11 -0
- package/skills/math-modeling-agent/references/workflow.md +32 -9
- package/skills/math-modeling-agent/scripts/computation/README.md +20 -0
- package/skills/math-modeling-agent/scripts/computation/backend-inventory.schema.json +78 -0
- package/skills/math-modeling-agent/scripts/computation/backend_inventory.ps1 +351 -0
- package/skills/math-modeling-agent/scripts/computation/backend_inventory.py +322 -0
- package/skills/math-modeling-agent/scripts/computation/computation_record.py +361 -0
- package/skills/math-modeling-agent/scripts/computation/probe_backends.ps1 +396 -0
- package/skills/math-modeling-agent/scripts/computation/probe_backends.py +230 -0
- package/skills/math-modeling-agent/scripts/distribution-parity.mjs +123 -0
- package/skills/math-modeling-agent/scripts/run-state.mjs +34 -7
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[ValidateSet('ReadOrCreate', 'Refresh', 'Invalidate', 'RecordMcp')]
|
|
4
|
+
[string]$Mode = 'ReadOrCreate',
|
|
5
|
+
[string]$StateFile = '',
|
|
6
|
+
[ValidateSet('all', 'mathematica', 'primecount', 'sagemath', 'python')]
|
|
7
|
+
[string[]]$Backend = @('all'),
|
|
8
|
+
[string]$ReasonCode = '',
|
|
9
|
+
[int]$MaxAgeHours = 168,
|
|
10
|
+
[string]$ProbeScript = (Join-Path $PSScriptRoot 'probe_backends.ps1'),
|
|
11
|
+
[string]$ProbeJsonFile = '',
|
|
12
|
+
[string]$PythonCommand = 'python',
|
|
13
|
+
[string]$SageCommand = '',
|
|
14
|
+
[string]$WslDistro = '',
|
|
15
|
+
[string]$WslSageCommand = 'sage',
|
|
16
|
+
[string]$PrimecountCommand = '',
|
|
17
|
+
[string]$McpServerName = '',
|
|
18
|
+
[string]$McpProtocolVersion = '',
|
|
19
|
+
[string]$McpServerVersion = '',
|
|
20
|
+
[string]$McpWolframLanguageVersion = '',
|
|
21
|
+
[string]$McpObservedAtUtc = ''
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
$ErrorActionPreference = 'Stop'
|
|
25
|
+
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
26
|
+
if ($Mode -eq 'Invalidate' -and $ReasonCode -notmatch '^[a-z][a-z0-9_]{0,31}$') {
|
|
27
|
+
throw "Invalidate mode requires a bounded lowercase reason code."
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Get-DefaultStateFile {
|
|
31
|
+
if ($env:MATH_SCIENCE_BACKEND_INVENTORY) {
|
|
32
|
+
return $env:MATH_SCIENCE_BACKEND_INVENTORY
|
|
33
|
+
}
|
|
34
|
+
return (Join-Path (Join-Path (Join-Path ([IO.Path]::GetTempPath()) 'DSH') 'math-science-computation') 'backend-inventory.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Get-HostIdentity {
|
|
38
|
+
$system = if ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
|
|
39
|
+
'Windows'
|
|
40
|
+
}
|
|
41
|
+
elseif ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::OSX)) {
|
|
42
|
+
'Darwin'
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
'Linux'
|
|
46
|
+
}
|
|
47
|
+
$rawArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
|
|
48
|
+
$architecture = switch ($rawArchitecture) {
|
|
49
|
+
'x64' { 'x86_64' }
|
|
50
|
+
'amd64' { 'x86_64' }
|
|
51
|
+
'arm64' { 'arm64' }
|
|
52
|
+
'x86' { 'x86' }
|
|
53
|
+
default { $rawArchitecture }
|
|
54
|
+
}
|
|
55
|
+
return [ordered]@{ system = $system; architecture = $architecture; powershell_edition = $PSVersionTable.PSEdition }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Read-Inventory {
|
|
59
|
+
param([Parameter(Mandatory = $true)][string]$Path)
|
|
60
|
+
|
|
61
|
+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
62
|
+
return $null
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
$inventory = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable
|
|
66
|
+
if ($inventory.inventory_schema_version -ne '1.0' -or $inventory.local.schema_version -ne '1.0') {
|
|
67
|
+
return $null
|
|
68
|
+
}
|
|
69
|
+
return $inventory
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return $null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Invoke-LocalProbe {
|
|
77
|
+
if ($ProbeJsonFile) {
|
|
78
|
+
return (Get-Content -Raw -LiteralPath $ProbeJsonFile | ConvertFrom-Json -AsHashtable)
|
|
79
|
+
}
|
|
80
|
+
if (-not (Test-Path -LiteralPath $ProbeScript -PathType Leaf)) {
|
|
81
|
+
throw "Backend probe script is unavailable."
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
$probeArguments = @{
|
|
85
|
+
PythonCommand = $PythonCommand
|
|
86
|
+
SageCommand = $SageCommand
|
|
87
|
+
WslDistro = $WslDistro
|
|
88
|
+
WslSageCommand = $WslSageCommand
|
|
89
|
+
PrimecountCommand = $PrimecountCommand
|
|
90
|
+
}
|
|
91
|
+
$raw = & $ProbeScript @probeArguments
|
|
92
|
+
if ($LASTEXITCODE -notin @($null, 0)) {
|
|
93
|
+
throw "Backend probe failed with a nonzero exit code."
|
|
94
|
+
}
|
|
95
|
+
return (($raw -join "`n") | ConvertFrom-Json -AsHashtable)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function New-Inventory {
|
|
99
|
+
param([Parameter(Mandatory = $true)][hashtable]$Local)
|
|
100
|
+
|
|
101
|
+
$now = [DateTime]::UtcNow.ToString('o')
|
|
102
|
+
return [ordered]@{
|
|
103
|
+
inventory_schema_version = '1.0'
|
|
104
|
+
created_at_utc = $now
|
|
105
|
+
updated_at_utc = $now
|
|
106
|
+
local = $Local
|
|
107
|
+
mcp = [ordered]@{
|
|
108
|
+
authority = 'current_session_tool_discovery_and_call'
|
|
109
|
+
persisted_status = 'historical_only'
|
|
110
|
+
required_action = 'Build a current-session overlay and live-check only the selected MCP backend.'
|
|
111
|
+
}
|
|
112
|
+
invalidations = @()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function New-McpObservation {
|
|
117
|
+
$required = [ordered]@{
|
|
118
|
+
server_name = $McpServerName
|
|
119
|
+
protocol_version = $McpProtocolVersion
|
|
120
|
+
server_version = $McpServerVersion
|
|
121
|
+
wolfram_language_version = $McpWolframLanguageVersion
|
|
122
|
+
}
|
|
123
|
+
$missing = @($required.GetEnumerator() | Where-Object { -not $_.Value.Trim() } | ForEach-Object Key)
|
|
124
|
+
if ($missing.Count -gt 0) {
|
|
125
|
+
throw ('RecordMcp requires: ' + ($missing -join ', '))
|
|
126
|
+
}
|
|
127
|
+
if ($McpProtocolVersion -notmatch '^\d{4}-\d{2}-\d{2}$') {
|
|
128
|
+
throw 'MCP protocol version must use the negotiated YYYY-MM-DD form.'
|
|
129
|
+
}
|
|
130
|
+
$observedAt = if ($McpObservedAtUtc) { $McpObservedAtUtc } else { [DateTime]::UtcNow.ToString('o') }
|
|
131
|
+
try { [void][DateTimeOffset]::Parse($observedAt) }
|
|
132
|
+
catch { throw 'MCP observation time must be an ISO-8601 timestamp.' }
|
|
133
|
+
return [ordered]@{
|
|
134
|
+
server_name = $McpServerName
|
|
135
|
+
protocol_version = $McpProtocolVersion
|
|
136
|
+
server_version = $McpServerVersion
|
|
137
|
+
wolfram_language_version = $McpWolframLanguageVersion
|
|
138
|
+
observed_at_utc = $observedAt
|
|
139
|
+
evidence = 'initialize_handshake_and_evaluator'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Write-InventoryAtomic {
|
|
144
|
+
param(
|
|
145
|
+
[Parameter(Mandatory = $true)][hashtable]$Inventory,
|
|
146
|
+
[Parameter(Mandatory = $true)][string]$Path
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
$parent = Split-Path -Parent $Path
|
|
150
|
+
if (-not $parent) {
|
|
151
|
+
$parent = (Get-Location).Path
|
|
152
|
+
$Path = Join-Path $parent $Path
|
|
153
|
+
}
|
|
154
|
+
[IO.Directory]::CreateDirectory($parent) | Out-Null
|
|
155
|
+
$temporary = Join-Path $parent ('.backend-inventory-' + [Guid]::NewGuid().ToString('N') + '.tmp')
|
|
156
|
+
try {
|
|
157
|
+
$json = $Inventory | ConvertTo-Json -Depth 12
|
|
158
|
+
[IO.File]::WriteAllText($temporary, $json, [Text.UTF8Encoding]::new($false))
|
|
159
|
+
[IO.File]::Move($temporary, $Path, $true)
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (Test-Path -LiteralPath $temporary -PathType Leaf) {
|
|
163
|
+
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function Get-MissingBackendPaths {
|
|
169
|
+
param([Parameter(Mandatory = $true)][hashtable]$Inventory)
|
|
170
|
+
|
|
171
|
+
$missing = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
172
|
+
foreach ($installation in @($Inventory.local.mathematica.installations)) {
|
|
173
|
+
if ($installation.executable -and -not (Test-Path -LiteralPath $installation.executable -PathType Leaf)) {
|
|
174
|
+
[void]$missing.Add('mathematica')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
$checks = @(
|
|
178
|
+
@('mathematica', $Inventory.local.mathematica.wolframscript.path),
|
|
179
|
+
@('primecount', $Inventory.local.primecount.path),
|
|
180
|
+
@('sagemath', $Inventory.local.sagemath.native.path),
|
|
181
|
+
@('python', $Inventory.local.python.path)
|
|
182
|
+
)
|
|
183
|
+
foreach ($check in $checks) {
|
|
184
|
+
if ($check[1] -and -not (Test-Path -LiteralPath $check[1] -PathType Leaf)) {
|
|
185
|
+
[void]$missing.Add([string]$check[0])
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return @($missing)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function Test-InventoryExpired {
|
|
192
|
+
param([Parameter(Mandatory = $true)][hashtable]$Inventory)
|
|
193
|
+
|
|
194
|
+
if ($MaxAgeHours -le 0) {
|
|
195
|
+
return $false
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
$updated = [DateTimeOffset]::Parse([string]$Inventory.updated_at_utc)
|
|
199
|
+
return ([DateTimeOffset]::UtcNow - $updated).TotalHours -ge $MaxAgeHours
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return $true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Test-InventoryHostMismatch {
|
|
207
|
+
param([Parameter(Mandatory = $true)][hashtable]$Inventory)
|
|
208
|
+
|
|
209
|
+
$current = Get-HostIdentity
|
|
210
|
+
$stored = $Inventory.local.host
|
|
211
|
+
if (-not $stored) {
|
|
212
|
+
return $true
|
|
213
|
+
}
|
|
214
|
+
return ($stored.system -ne $current.system -or $stored.architecture -ne $current.architecture)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function Merge-Backends {
|
|
218
|
+
param(
|
|
219
|
+
[Parameter(Mandatory = $true)][hashtable]$Inventory,
|
|
220
|
+
[Parameter(Mandatory = $true)][hashtable]$FreshLocal,
|
|
221
|
+
[Parameter(Mandatory = $true)][string[]]$Names
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
$selected = if ($Names -contains 'all') {
|
|
225
|
+
@('mathematica', 'primecount', 'sagemath', 'python')
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
@($Names | Select-Object -Unique)
|
|
229
|
+
}
|
|
230
|
+
foreach ($name in $selected) {
|
|
231
|
+
$Inventory.local[$name] = $FreshLocal[$name]
|
|
232
|
+
}
|
|
233
|
+
$Inventory.local.probed_at_utc = $FreshLocal.probed_at_utc
|
|
234
|
+
$Inventory.local.host = $FreshLocal.host
|
|
235
|
+
$Inventory.updated_at_utc = [DateTime]::UtcNow.ToString('o')
|
|
236
|
+
return $Inventory
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function Write-Result {
|
|
240
|
+
param(
|
|
241
|
+
[Parameter(Mandatory = $true)][hashtable]$Inventory,
|
|
242
|
+
[Parameter(Mandatory = $true)][string]$CacheStatus,
|
|
243
|
+
[string[]]$RefreshedBackends = @(),
|
|
244
|
+
[string[]]$InvalidPaths = @(),
|
|
245
|
+
[bool]$BackendStarted = $false,
|
|
246
|
+
[string]$WriteError = ''
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
$stopwatch.Stop()
|
|
250
|
+
$output = [ordered]@{
|
|
251
|
+
inventory_schema_version = $Inventory.inventory_schema_version
|
|
252
|
+
snapshot_updated_at_utc = $Inventory.updated_at_utc
|
|
253
|
+
cache = [ordered]@{
|
|
254
|
+
status = $CacheStatus
|
|
255
|
+
state_file = $StateFile
|
|
256
|
+
elapsed_ms = $stopwatch.ElapsedMilliseconds
|
|
257
|
+
backend_started = $BackendStarted
|
|
258
|
+
refreshed_backends = @($RefreshedBackends)
|
|
259
|
+
invalid_path_backends = @($InvalidPaths)
|
|
260
|
+
write_error = $WriteError
|
|
261
|
+
}
|
|
262
|
+
local = $Inventory.local
|
|
263
|
+
mcp = [ordered]@{
|
|
264
|
+
status = 'session_probe_required'
|
|
265
|
+
authority = 'current_session_tool_discovery_and_call'
|
|
266
|
+
note = 'The persisted snapshot is not evidence that an MCP tool is callable in this session.'
|
|
267
|
+
recorded_mathematica_observation = $Inventory.mcp.mathematica
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
$output | ConvertTo-Json -Depth 12 -Compress
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (-not $StateFile) {
|
|
274
|
+
$StateFile = Get-DefaultStateFile
|
|
275
|
+
}
|
|
276
|
+
$StateFile = [IO.Path]::GetFullPath($StateFile)
|
|
277
|
+
$inventory = Read-Inventory -Path $StateFile
|
|
278
|
+
$missingBackends = @()
|
|
279
|
+
|
|
280
|
+
if ($Mode -eq 'RecordMcp') {
|
|
281
|
+
$observation = New-McpObservation
|
|
282
|
+
$backendStarted = $false
|
|
283
|
+
if (-not $inventory) {
|
|
284
|
+
$freshLocal = Invoke-LocalProbe
|
|
285
|
+
if ($freshLocal.schema_version -ne '1.0') { throw 'Unsupported backend probe schema.' }
|
|
286
|
+
if ($freshLocal.mathematica -is [Collections.IDictionary] -and $freshLocal.mathematica.Contains('mcp')) {
|
|
287
|
+
[void]$freshLocal.mathematica.Remove('mcp')
|
|
288
|
+
}
|
|
289
|
+
$inventory = New-Inventory -Local $freshLocal
|
|
290
|
+
$backendStarted = $true
|
|
291
|
+
}
|
|
292
|
+
$inventory.mcp.mathematica = $observation
|
|
293
|
+
$inventory.updated_at_utc = [DateTime]::UtcNow.ToString('o')
|
|
294
|
+
Write-InventoryAtomic -Inventory $inventory -Path $StateFile
|
|
295
|
+
Write-Result -Inventory $inventory -CacheStatus 'mcp_recorded' -BackendStarted $backendStarted
|
|
296
|
+
exit 0
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if ($Mode -eq 'ReadOrCreate' -and $inventory) {
|
|
300
|
+
$missingBackends = @(Get-MissingBackendPaths -Inventory $inventory)
|
|
301
|
+
if ($missingBackends.Count -eq 0 -and -not (Test-InventoryExpired -Inventory $inventory) -and -not (Test-InventoryHostMismatch -Inventory $inventory)) {
|
|
302
|
+
Write-Result -Inventory $inventory -CacheStatus 'hit' -BackendStarted $false
|
|
303
|
+
exit 0
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
$freshLocal = Invoke-LocalProbe
|
|
308
|
+
if ($freshLocal.schema_version -ne '1.0') {
|
|
309
|
+
throw "Unsupported backend probe schema."
|
|
310
|
+
}
|
|
311
|
+
if ($freshLocal.mathematica -is [Collections.IDictionary] -and $freshLocal.mathematica.Contains('mcp')) {
|
|
312
|
+
[void]$freshLocal.mathematica.Remove('mcp')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
$refreshed = @('mathematica', 'primecount', 'sagemath', 'python')
|
|
316
|
+
$cacheStatus = 'created'
|
|
317
|
+
if (-not $inventory) {
|
|
318
|
+
$inventory = New-Inventory -Local $freshLocal
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
$cacheStatus = 'refreshed'
|
|
322
|
+
if ($Mode -eq 'Invalidate') {
|
|
323
|
+
$targets = if ($Backend -contains 'all') { @('mathematica', 'primecount', 'sagemath', 'python') } else { @($Backend) }
|
|
324
|
+
$inventory.invalidations = @($inventory.invalidations) + @($targets | ForEach-Object {
|
|
325
|
+
[ordered]@{ backend = $_; reason = $ReasonCode; recorded_at_utc = [DateTime]::UtcNow.ToString('o') }
|
|
326
|
+
})
|
|
327
|
+
if ($inventory.invalidations.Count -gt 20) {
|
|
328
|
+
$inventory.invalidations = @($inventory.invalidations | Select-Object -Last 20)
|
|
329
|
+
}
|
|
330
|
+
$refreshed = $targets
|
|
331
|
+
}
|
|
332
|
+
elseif ($Mode -eq 'Refresh') {
|
|
333
|
+
$refreshed = if ($Backend -contains 'all') { @('mathematica', 'primecount', 'sagemath', 'python') } else { @($Backend) }
|
|
334
|
+
}
|
|
335
|
+
elseif ($missingBackends.Count -gt 0) {
|
|
336
|
+
$refreshed = $missingBackends
|
|
337
|
+
}
|
|
338
|
+
$inventory = Merge-Backends -Inventory $inventory -FreshLocal $freshLocal -Names $refreshed
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
$writeError = ''
|
|
342
|
+
try {
|
|
343
|
+
Write-InventoryAtomic -Inventory $inventory -Path $StateFile
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
$writeError = $_.Exception.GetType().Name
|
|
347
|
+
$cacheStatus = 'write_failed'
|
|
348
|
+
}
|
|
349
|
+
Write-Result -Inventory $inventory -CacheStatus $cacheStatus -RefreshedBackends $refreshed -InvalidPaths $missingBackends -BackendStarted $true -WriteError $writeError
|
|
350
|
+
if ($writeError) { exit 1 }
|
|
351
|
+
exit 0
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read, refresh, or invalidate the cross-platform backend inventory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
BACKENDS = ("mathematica", "primecount", "sagemath", "python")
|
|
21
|
+
REASON_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
22
|
+
MCP_PROTOCOL_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def utc_now() -> str:
|
|
26
|
+
return datetime.now(timezone.utc).isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalized_architecture() -> str:
|
|
30
|
+
value = platform.machine().lower()
|
|
31
|
+
return {
|
|
32
|
+
"amd64": "x86_64",
|
|
33
|
+
"x64": "x86_64",
|
|
34
|
+
"aarch64": "arm64",
|
|
35
|
+
"arm64": "arm64",
|
|
36
|
+
"i386": "x86",
|
|
37
|
+
"i686": "x86",
|
|
38
|
+
"x86": "x86",
|
|
39
|
+
}.get(value, value or "unknown")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_state_file() -> Path:
|
|
43
|
+
override = os.environ.get("MATH_SCIENCE_BACKEND_INVENTORY")
|
|
44
|
+
if override:
|
|
45
|
+
return Path(override).expanduser()
|
|
46
|
+
return Path(tempfile.gettempdir(), "DSH", "math-science-computation", "backend-inventory.json")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_inventory(path: Path) -> dict | None:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
local = data.get("local") if isinstance(data, dict) else None
|
|
55
|
+
mcp = data.get("mcp") if isinstance(data, dict) else None
|
|
56
|
+
if (
|
|
57
|
+
not isinstance(data, dict)
|
|
58
|
+
or not isinstance(local, dict)
|
|
59
|
+
or not isinstance(mcp, dict)
|
|
60
|
+
or data.get("inventory_schema_version") != "1.0"
|
|
61
|
+
or local.get("schema_version") != "1.0"
|
|
62
|
+
):
|
|
63
|
+
return None
|
|
64
|
+
return data
|
|
65
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def invoke_probe(args: argparse.Namespace) -> dict:
|
|
70
|
+
if args.probe_json_file:
|
|
71
|
+
return json.loads(Path(args.probe_json_file).read_text(encoding="utf-8"))
|
|
72
|
+
probe_script = Path(args.probe_script)
|
|
73
|
+
if not probe_script.is_file():
|
|
74
|
+
raise RuntimeError("Backend probe script is unavailable.")
|
|
75
|
+
command = [
|
|
76
|
+
sys.executable,
|
|
77
|
+
str(probe_script),
|
|
78
|
+
"--python-command",
|
|
79
|
+
args.python_command,
|
|
80
|
+
"--wsl-sage-command",
|
|
81
|
+
args.wsl_sage_command,
|
|
82
|
+
]
|
|
83
|
+
for option, value in (
|
|
84
|
+
("--sage-command", args.sage_command),
|
|
85
|
+
("--wsl-distro", args.wsl_distro),
|
|
86
|
+
("--primecount-command", args.primecount_command),
|
|
87
|
+
):
|
|
88
|
+
if value:
|
|
89
|
+
command.extend([option, value])
|
|
90
|
+
process = subprocess.run(command, text=True, capture_output=True, check=False, timeout=60)
|
|
91
|
+
if process.returncode != 0:
|
|
92
|
+
message = (process.stderr or process.stdout).strip()
|
|
93
|
+
raise RuntimeError(f"Backend probe failed with exit code {process.returncode}: {message}")
|
|
94
|
+
return json.loads(process.stdout)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def new_inventory(local: dict) -> dict:
|
|
98
|
+
now = utc_now()
|
|
99
|
+
return {
|
|
100
|
+
"inventory_schema_version": "1.0",
|
|
101
|
+
"created_at_utc": now,
|
|
102
|
+
"updated_at_utc": now,
|
|
103
|
+
"local": local,
|
|
104
|
+
"mcp": {
|
|
105
|
+
"authority": "current_session_tool_discovery_and_call",
|
|
106
|
+
"persisted_status": "historical_only",
|
|
107
|
+
"required_action": "Build a current-session overlay and live-check only the selected MCP backend.",
|
|
108
|
+
},
|
|
109
|
+
"invalidations": [],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mcp_observation(args: argparse.Namespace) -> dict:
|
|
114
|
+
values = {
|
|
115
|
+
"server_name": args.mcp_server_name,
|
|
116
|
+
"protocol_version": args.mcp_protocol_version,
|
|
117
|
+
"server_version": args.mcp_server_version,
|
|
118
|
+
"wolfram_language_version": args.mcp_wolfram_language_version,
|
|
119
|
+
}
|
|
120
|
+
missing = [name for name, value in values.items() if not value.strip()]
|
|
121
|
+
if missing:
|
|
122
|
+
raise SystemExit("RecordMcp requires: " + ", ".join(missing))
|
|
123
|
+
if not MCP_PROTOCOL_PATTERN.fullmatch(values["protocol_version"]):
|
|
124
|
+
raise SystemExit("MCP protocol version must use the negotiated YYYY-MM-DD form.")
|
|
125
|
+
observed_at = args.mcp_observed_at_utc or utc_now()
|
|
126
|
+
try:
|
|
127
|
+
datetime.fromisoformat(observed_at.replace("Z", "+00:00"))
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise SystemExit("MCP observation time must be an ISO-8601 timestamp.") from exc
|
|
130
|
+
return {
|
|
131
|
+
**values,
|
|
132
|
+
"observed_at_utc": observed_at,
|
|
133
|
+
"evidence": "initialize_handshake_and_evaluator",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_inventory_atomic(inventory: dict, path: Path) -> None:
|
|
138
|
+
path = path.expanduser().resolve()
|
|
139
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
temporary = path.parent / f".backend-inventory-{uuid.uuid4().hex}.tmp"
|
|
141
|
+
try:
|
|
142
|
+
temporary.write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
143
|
+
os.replace(temporary, path)
|
|
144
|
+
finally:
|
|
145
|
+
try:
|
|
146
|
+
temporary.unlink()
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def missing_backend_paths(inventory: dict) -> list[str]:
|
|
152
|
+
local = inventory["local"]
|
|
153
|
+
missing: set[str] = set()
|
|
154
|
+
for installation in local.get("mathematica", {}).get("installations", []):
|
|
155
|
+
executable = installation.get("executable")
|
|
156
|
+
if executable and not Path(executable).is_file():
|
|
157
|
+
missing.add("mathematica")
|
|
158
|
+
checks = (
|
|
159
|
+
("mathematica", local.get("mathematica", {}).get("wolframscript", {}).get("path")),
|
|
160
|
+
("primecount", local.get("primecount", {}).get("path")),
|
|
161
|
+
("sagemath", local.get("sagemath", {}).get("native", {}).get("path")),
|
|
162
|
+
("python", local.get("python", {}).get("path")),
|
|
163
|
+
)
|
|
164
|
+
for name, value in checks:
|
|
165
|
+
if value and not Path(value).is_file():
|
|
166
|
+
missing.add(name)
|
|
167
|
+
return [name for name in BACKENDS if name in missing]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def inventory_expired(inventory: dict, max_age_hours: int) -> bool:
|
|
171
|
+
if max_age_hours <= 0:
|
|
172
|
+
return False
|
|
173
|
+
try:
|
|
174
|
+
updated = datetime.fromisoformat(inventory["updated_at_utc"].replace("Z", "+00:00"))
|
|
175
|
+
return (datetime.now(timezone.utc) - updated.astimezone(timezone.utc)).total_seconds() >= max_age_hours * 3600
|
|
176
|
+
except (KeyError, TypeError, ValueError):
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def host_changed(inventory: dict) -> bool:
|
|
181
|
+
host = inventory.get("local", {}).get("host")
|
|
182
|
+
if not isinstance(host, dict):
|
|
183
|
+
return True
|
|
184
|
+
return host.get("system") != platform.system() or host.get("architecture") != normalized_architecture()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def selected_backends(names: list[str]) -> list[str]:
|
|
188
|
+
if "all" in names:
|
|
189
|
+
return list(BACKENDS)
|
|
190
|
+
return [name for name in BACKENDS if name in names]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def merge_backends(inventory: dict, fresh_local: dict, names: list[str]) -> dict:
|
|
194
|
+
for name in selected_backends(names):
|
|
195
|
+
inventory["local"][name] = fresh_local[name]
|
|
196
|
+
inventory["local"]["probed_at_utc"] = fresh_local["probed_at_utc"]
|
|
197
|
+
inventory["local"]["host"] = fresh_local.get("host", {})
|
|
198
|
+
inventory["updated_at_utc"] = utc_now()
|
|
199
|
+
return inventory
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def emit_result(
|
|
203
|
+
inventory: dict,
|
|
204
|
+
state_file: Path,
|
|
205
|
+
started: float,
|
|
206
|
+
cache_status: str,
|
|
207
|
+
refreshed: list[str] | None = None,
|
|
208
|
+
invalid_paths: list[str] | None = None,
|
|
209
|
+
backend_started: bool = False,
|
|
210
|
+
write_error: str = "",
|
|
211
|
+
) -> None:
|
|
212
|
+
output = {
|
|
213
|
+
"inventory_schema_version": inventory["inventory_schema_version"],
|
|
214
|
+
"snapshot_updated_at_utc": inventory["updated_at_utc"],
|
|
215
|
+
"cache": {
|
|
216
|
+
"status": cache_status,
|
|
217
|
+
"state_file": str(state_file),
|
|
218
|
+
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
|
219
|
+
"backend_started": backend_started,
|
|
220
|
+
"refreshed_backends": refreshed or [],
|
|
221
|
+
"invalid_path_backends": invalid_paths or [],
|
|
222
|
+
"write_error": write_error,
|
|
223
|
+
},
|
|
224
|
+
"local": inventory["local"],
|
|
225
|
+
"mcp": {
|
|
226
|
+
"status": "session_probe_required",
|
|
227
|
+
"authority": "current_session_tool_discovery_and_call",
|
|
228
|
+
"note": "The persisted snapshot is not evidence that an MCP tool is callable in this session.",
|
|
229
|
+
"recorded_mathematica_observation": inventory.get("mcp", {}).get("mathematica"),
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
236
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
237
|
+
parser.add_argument("--mode", choices=("ReadOrCreate", "Refresh", "Invalidate", "RecordMcp"), default="ReadOrCreate")
|
|
238
|
+
parser.add_argument("--state-file", default="")
|
|
239
|
+
parser.add_argument("--backend", choices=("all", *BACKENDS), nargs="+", default=["all"])
|
|
240
|
+
parser.add_argument("--reason-code", default="")
|
|
241
|
+
parser.add_argument("--max-age-hours", type=int, default=168)
|
|
242
|
+
parser.add_argument("--probe-script", default=str(Path(__file__).with_name("probe_backends.py")))
|
|
243
|
+
parser.add_argument("--probe-json-file", default="")
|
|
244
|
+
parser.add_argument("--python-command", default=sys.executable)
|
|
245
|
+
parser.add_argument("--sage-command", default="")
|
|
246
|
+
parser.add_argument("--wsl-distro", default="")
|
|
247
|
+
parser.add_argument("--wsl-sage-command", default="sage")
|
|
248
|
+
parser.add_argument("--primecount-command", default="")
|
|
249
|
+
parser.add_argument("--mcp-server-name", default="")
|
|
250
|
+
parser.add_argument("--mcp-protocol-version", default="")
|
|
251
|
+
parser.add_argument("--mcp-server-version", default="")
|
|
252
|
+
parser.add_argument("--mcp-wolfram-language-version", default="")
|
|
253
|
+
parser.add_argument("--mcp-observed-at-utc", default="")
|
|
254
|
+
return parser
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main() -> int:
|
|
258
|
+
started = time.perf_counter()
|
|
259
|
+
args = build_parser().parse_args()
|
|
260
|
+
if args.mode == "Invalidate" and not REASON_PATTERN.fullmatch(args.reason_code):
|
|
261
|
+
raise SystemExit("Invalidate mode requires a bounded lowercase reason code.")
|
|
262
|
+
state_file = Path(args.state_file).expanduser() if args.state_file else default_state_file()
|
|
263
|
+
state_file = state_file.resolve()
|
|
264
|
+
inventory = read_inventory(state_file)
|
|
265
|
+
missing: list[str] = []
|
|
266
|
+
|
|
267
|
+
if args.mode == "RecordMcp":
|
|
268
|
+
observation = mcp_observation(args)
|
|
269
|
+
backend_started = False
|
|
270
|
+
if inventory is None:
|
|
271
|
+
fresh_local = invoke_probe(args)
|
|
272
|
+
if fresh_local.get("schema_version") != "1.0":
|
|
273
|
+
raise SystemExit("Unsupported backend probe schema.")
|
|
274
|
+
fresh_local.get("mathematica", {}).pop("mcp", None)
|
|
275
|
+
inventory = new_inventory(fresh_local)
|
|
276
|
+
backend_started = True
|
|
277
|
+
inventory["mcp"]["mathematica"] = observation
|
|
278
|
+
inventory["updated_at_utc"] = utc_now()
|
|
279
|
+
write_inventory_atomic(inventory, state_file)
|
|
280
|
+
emit_result(inventory, state_file, started, "mcp_recorded", backend_started=backend_started)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
if args.mode == "ReadOrCreate" and inventory:
|
|
284
|
+
missing = missing_backend_paths(inventory)
|
|
285
|
+
if not missing and not inventory_expired(inventory, args.max_age_hours) and not host_changed(inventory):
|
|
286
|
+
emit_result(inventory, state_file, started, "hit")
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
fresh_local = invoke_probe(args)
|
|
290
|
+
if fresh_local.get("schema_version") != "1.0":
|
|
291
|
+
raise SystemExit("Unsupported backend probe schema.")
|
|
292
|
+
fresh_local.get("mathematica", {}).pop("mcp", None)
|
|
293
|
+
|
|
294
|
+
refreshed = list(BACKENDS)
|
|
295
|
+
cache_status = "created"
|
|
296
|
+
if inventory is None:
|
|
297
|
+
inventory = new_inventory(fresh_local)
|
|
298
|
+
else:
|
|
299
|
+
cache_status = "refreshed"
|
|
300
|
+
if args.mode in {"Refresh", "Invalidate"}:
|
|
301
|
+
refreshed = selected_backends(args.backend)
|
|
302
|
+
elif missing and not host_changed(inventory):
|
|
303
|
+
refreshed = missing
|
|
304
|
+
if args.mode == "Invalidate":
|
|
305
|
+
inventory["invalidations"] = (inventory.get("invalidations", []) + [
|
|
306
|
+
{"backend": name, "reason": args.reason_code, "recorded_at_utc": utc_now()}
|
|
307
|
+
for name in refreshed
|
|
308
|
+
])[-20:]
|
|
309
|
+
inventory = merge_backends(inventory, fresh_local, refreshed)
|
|
310
|
+
|
|
311
|
+
write_error = ""
|
|
312
|
+
try:
|
|
313
|
+
write_inventory_atomic(inventory, state_file)
|
|
314
|
+
except OSError as exc:
|
|
315
|
+
write_error = type(exc).__name__
|
|
316
|
+
cache_status = "write_failed"
|
|
317
|
+
emit_result(inventory, state_file, started, cache_status, refreshed, missing, True, write_error)
|
|
318
|
+
return 1 if write_error else 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
raise SystemExit(main())
|